diff --git a/.github/workflows/ci_check.yml b/.github/workflows/ci_check.yml index 01b08db..0a38ffc 100644 --- a/.github/workflows/ci_check.yml +++ b/.github/workflows/ci_check.yml @@ -290,22 +290,33 @@ jobs: cache: false - run: pnpm install --frozen-lockfile - run: pnpm --filter @domus/frontend exec playwright install --with-deps chromium - - name: Execute administration in the actual isolated image from Chromium + - name: Execute household workflows in the actual isolated image from Chromium env: DOMUS_BROWSER_IMAGE: domus-app-ci DOMUS_TEST_SOURCE_COMMIT: ${{ github.sha }} DOMUS_TEST_ARCH: ${{ matrix.arch }} run: >- pnpm --filter @domus/frontend exec playwright test - -c playwright.entry.config.ts tests/e2e/administration.spec.ts - - name: Require all thirty-four actual-image administration journeys + -c playwright.entry.config.ts tests/e2e/administration.spec.ts tests/e2e/tasks.spec.ts + - name: Require all forty-four actual-image household journeys run: >- uv run python -c "import json; from pathlib import Path; result=json.loads(Path('frontend/test-results/entry-results.json').read_text()); - assert result['stats']['expected']==34 and result['stats']['unexpected']==0 + assert result['stats']['expected']==44 and result['stats']['unexpected']==0 and result['stats']['skipped']==0 and result['stats']['flaky']==0" + - name: Retain actual-image browser evidence + if: ${{ always() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: image-browser-${{ matrix.arch }}-${{ github.sha }} + path: frontend/test-results/ + include-hidden-files: true + if-no-files-found: warn + retention-days: 7 + - name: Require no surviving image browser fixtures + if: ${{ always() }} run: >- uv run python -c "import subprocess; assert not subprocess.check_output('docker ps --all --quiet --filter label=domus.browser-fixture=true'.split()).strip()" diff --git a/addon-edge/CHANGELOG.md b/addon-edge/CHANGELOG.md index ec5537d..ca56e04 100644 --- a/addon-edge/CHANGELOG.md +++ b/addon-edge/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 0.0.0-edge.9 + +- Recover the caller's original task action using its current recorded task authority, even when + general operation browsing is disabled; retain the separate browsing permission boundary. +- Enforce shared Unicode code-point limits in task forms, preserve overlong drafts and show the + field that needs correction; accept the exact limits for astral characters. +- Exercise restricted lost-response recovery and exact Unicode limits in both deployment modes, + increasing the actual-image campaign to 44 household/task journeys per CPU. +- Replace the earlier Edge.8 candidate with a distinct immutable reviewed runtime. +- Retain schema 9, cold backups and the existing Core API permission scope. + +## 0.0.0-edge.8 + +- Make Tasks the default household screen, with English/Spanish creation, list and detail. +- Claim and complete work as the current participant; approve, reject or reopen the current cycle. +- Review adjustable linked-material quantities without inventing stock or downstream delivery. +- Recover an uncertain original task request through an authorized read, without another command. +- Keep household administration available and move technical task details behind optional disclosure. +- Retain schema 9, cold backups, both CPU families and the existing Core API permission scope. +- Add eight task journeys to the actual-image browser campaign; owner adoption remains separate. + ## 0.0.0-edge.7 - Require cold Supervisor backups so the app is stopped while its private SQLite data is copied. diff --git a/addon-edge/DOCS.md b/addon-edge/DOCS.md index d949f01..0dec205 100644 --- a/addon-edge/DOCS.md +++ b/addon-edge/DOCS.md @@ -35,7 +35,7 @@ it preserves later operation evidence without reapplying rolled-away effects. St restore preparation/activation. Inspect and resume the same activation ID after an uncertain result. The retained source-checkpoint supports deliberate rollback reconciled against the latest history. Do not switch root pointers or run an older image manually. See the -[restoration guide](https://github.com/vypdev/domus/blob/3200bd9278f57f142ed03b54843a38a4138fc0af/docs/restoration.md) +[restoration guide](https://github.com/vypdev/domus/blob/93d7d77b98a196640bf0049bca36a3d90b7a5857/docs/restoration.md) for the exact console commands. Restore is not available from the household entry interface. App removal with data deletion removes the app's private records; use deliberate operator backup procedures before removal. @@ -69,7 +69,7 @@ The [artifact declaration](artifact.json) fixes the reviewed source/archive and file fingerprints. Application CI builds the actual pinned installer image on amd64 and aarch64, checks those bytes, exercises its native restart/authority boundary and standalone UI, and runs the installed Edge.5 upgrade/replay/restore cycle. The separate current-source image retains its -complete administration browser campaign. These fixtures use isolated owned data; actual owner +complete household administration and task browser campaign. These fixtures use isolated owned data; actual owner adoption and required human UX are additional acceptance steps. Edge.7 retains the Edge.6 runtime/schema and declares `backup: cold`. Supervisor stops Domus while @@ -91,3 +91,25 @@ Stopping does not sign out your HA frontend. There is no Domus OAuth grant to revoke for this native mode. Remove the development app/repository after its owned test; do not migrate production household data into this preview. A stable package will be published separately when its acceptance gates pass. + + +Edge.8 makes Tasks the default screen. Create a task with a title, optional description and priority; +open it to claim and mark your own work done. Current authorized reviewers can approve/reject the +current cycle or reopen it with a reason. Linked material quantities respect their unit precision; +a recorded completion never fabricates stock or downstream point delivery. Uncertain requests +remain inspectable by their original key without automatically sending another command. Technical +identifiers are under optional details. Household settings remain in the Household tab. + +This increment keeps schema 9 and the existing installation options. It adds no breaking-version +boundary, host exposure or Core API permission. The immutable artifact fixes the published runtime; +both CPU jobs exercise all 44 household/task browser journeys in the current-source image. The +calendar, full recurrence editors, points/rewards and supplies screens are still being developed. +Actual owner installation and usability review are separate from isolated browser fixtures. + + +Edge.9 corrects task-specific original recovery so it checks current task-read and the recorded +command grants without requiring operations.read for general browsing. The general operation-by-ID +query keeps that separate permission. Form-level title, description and reason limits count Unicode +code points, retain invalid input with a visible error and prevent submission until corrected. Both +deployments exercise restricted lost-response recovery and exact astral-character bounds. This is +a distinct immutable preview from Edge.8; it retains the same schema and permission scope. diff --git a/addon-edge/Dockerfile b/addon-edge/Dockerfile index 83e00aa..443c25b 100644 --- a/addon-edge/Dockerfile +++ b/addon-edge/Dockerfile @@ -1,8 +1,8 @@ # The Supervisor builds this folder. Fetch only the reviewed immutable runtime and UI. 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=3200bd9278f57f142ed03b54843a38a4138fc0af -ARG DOMUS_SOURCE_SHA256=ca991d7b3560dbdb7a6a86a34eea19708f01f10c15da502e49ed8ab740a8533b +ARG DOMUS_SOURCE_COMMIT=93d7d77b98a196640bf0049bca36a3d90b7a5857 +ARG DOMUS_SOURCE_SHA256=97dc46699ad7eab54c487386ffaffc28624e8e50f9d3a2f1ffc6b525fb7b8f0e 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 \ @@ -24,9 +24,9 @@ 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; assert load_bundle(Path(domus.__file__).parent / 'ui') is not None" FROM python:3.14.6-slim-trixie@sha256:7bec7ddcddeff7975d6ba9b4be7dd6f6b2f55e7491539145e2978f7f97ce9144 -ARG BUILD_VERSION=0.0.0-edge.7 +ARG BUILD_VERSION=0.0.0-edge.9 ARG BUILD_ARCH -ARG DOMUS_SOURCE_COMMIT=3200bd9278f57f142ed03b54843a38a4138fc0af +ARG DOMUS_SOURCE_COMMIT=93d7d77b98a196640bf0049bca36a3d90b7a5857 LABEL io.hass.type="app" \ io.hass.version="${BUILD_VERSION}" \ io.hass.arch="${BUILD_ARCH}" \ diff --git a/addon-edge/README.md b/addon-edge/README.md index 13e6a64..352b9dc 100644 --- a/addon-edge/README.md +++ b/addon-edge/README.md @@ -3,7 +3,14 @@ Development preview of Domus installed as a Home Assistant app. Open it through HA's Open Web UI; it uses your existing HA sign-in without another login. -Edge 0.0.0-edge.7 includes the English/Spanish household administration workspace: participant roster, +Edge 0.0.0-edge.9 includes the Tasks workspace: create, claim, complete, approve/reject and reopen work +in English or Spanish. Tasks open by default; household administration remains available from the +Household tab. Material quantities and original-request recovery preserve the existing server +authorization and exact operation identity. Calendar, recurrence controls, points and supplies +remain in progress. Task forms retain invalid drafts and enforce exact Unicode character limits; +original task recovery uses the current task grants independently of general operation browsing. + +It retains the English/Spanish household administration workspace: participant roster, household settings, HA administrator mapping, member roles/ordinary permissions, public aliases and owner-private preferences. Reviewed changes retain exact revisions and original operation identity; a changed verified household retires private drafts. Saved preferences describe stored @@ -18,7 +25,7 @@ household records, history and original-operation evidence are retained. The ins recipe is preserved separately as an immutable compatibility fixture. Set your Home Assistant browser origins in Configuration before starting. Edge remains experimental; -the remaining domain screens, required human UX/owner adoption and complete Stable product are +the remaining domain screens, required human UX review and complete Stable product are still in progress. See [installation and permissions](DOCS.md) and the [reviewed artifact declaration](artifact.json). diff --git a/addon-edge/artifact.json b/addon-edge/artifact.json index 74f23ee..e36320e 100644 --- a/addon-edge/artifact.json +++ b/addon-edge/artifact.json @@ -1,10 +1,10 @@ { "schemaVersion": 1, - "version": "0.0.0-edge.7", - "sourceCommit": "3200bd9278f57f142ed03b54843a38a4138fc0af", - "sourceArchiveSHA256": "ca991d7b3560dbdb7a6a86a34eea19708f01f10c15da502e49ed8ab740a8533b", + "version": "0.0.0-edge.9", + "sourceCommit": "93d7d77b98a196640bf0049bca36a3d90b7a5857", + "sourceArchiveSHA256": "97dc46699ad7eab54c487386ffaffc28624e8e50f9d3a2f1ffc6b525fb7b8f0e", "storageSchema": 9, - "wheelSHA256": "6578a595350e00b0df2fb7e12a436a100a6cf8a661ad4147a5fc074d6de05351", + "wheelSHA256": "7c3e1d6b7078a2ab7177407c027df10afdacb2ef66f63b790782987538d3e0c4", "pythonFiles": { "domus/__init__.py": "7182c88b92234b4328f94497f4e70222d4390d8093e4b62893dfa78ac5530ac6", "domus/application/__init__.py": "1e8c0c87bf2a8ca947112543070583c0cc55d9d40b56eab0d86ed59a4ec1e82c", @@ -49,7 +49,7 @@ "domus/application/task_material_views.py": "1f4e1b52c17064b73e3c6502b38fc578a7589d68044279312c783b656e5aa5bf", "domus/application/task_materials.py": "caa132a7b16fca293770deba3da149cf9bda2e24d4719abe9de39470d686698e", "domus/application/task_ports.py": "67ede6022534205e35878c695c0fee4354ccfa207140e8c4043888fc133c5076", - "domus/application/task_queries.py": "e4e8256e49acd19af6d850606168e649ea00813712b0f3bae1d8cf57e0a3ba97", + "domus/application/task_queries.py": "4a36d6712634ed778755d52a7203af91132c5532ad05d3300e374ec337f50c83", "domus/application/task_requests.py": "ca196b2fdfecd1110d03caff6121be0ca50c505a4f7713f97a8f62d2449556ec", "domus/application/task_reviews.py": "dcc288cda6cdcf5073bad4f24133b9d3496c0b0cf1272e8328e6df38129f3328", "domus/composition/__init__.py": "a902d8faf7dd19da6829fb730d81e32d34ec38ee6cfafd676966a95d0ad31366", @@ -106,7 +106,7 @@ "domus/entrypoints/native_http.py": "5a92ac6094dea64b06e13af9e05453a2f302bd6261ab46195bed0198b3d69482", "domus/entrypoints/openapi.py": "01a3fab9e5890ebcc1fb9c70bce9ced43d45b9190afd134db7dd45e0c5eb28f1", "domus/entrypoints/task_dto.py": "7df14d696be6290f4f3f412dc5ec6ffe1682750f410ad38260552d5e1b770c9f", - "domus/entrypoints/task_http.py": "bb646552a3a54a538219470c0e160f281b9926c553630036a7a569f87eb3e84a", + "domus/entrypoints/task_http.py": "7096eba00065fcdaccef04126b465919281b9efe06acaf655ee5ae2041ccf63a", "domus/entrypoints/task_response.py": "33cb58fba8e45dfaf46474905bb8bef0145c392ee4ed3155bff7958677742435", "domus/entrypoints/ui_bundle.py": "beae3d4a7a57375ee195cca81efccdbc899d9db4286029d53eabaf30de6e14f7", "domus/entrypoints/ui_http.py": "b6c47baa742f3862173a93e4121dc9914fd972eb6287ffffb9ffccecbf2ebe4a", @@ -159,9 +159,9 @@ "domus/infrastructure/task_materials.py": "d5d2934e36f290c7bf44b473392f014eab8749f1f3b3d26a3402079e65d2818f" }, "uiFiles": { - "index.html": "fb4f250c231b0f0b07fd4d99fa00925610f0573b01f033a4bf75178f860887fd", - ".vite/manifest.json": "07f6f6a7f49b0c8691b16dd3e687e17698b4e540e6f3cd2577fcfc187514f159", - "assets/index-CS8EClQ3.js": "c3103fcc8d107cff163ab5a98c8a587ef5203367624dd1f23fea56b96227dedd", - "assets/index-CjJnHZzV.css": "0f2c8c2e31576c0b64fe60373150f09390dafb896df6c07777b2a220ad430e48" + ".vite/manifest.json": "31afa1ff0c4629340409460e52b6d2901e2a53154efb7c4a12fede871bde200c", + "assets/index-CjJnHZzV.css": "0f2c8c2e31576c0b64fe60373150f09390dafb896df6c07777b2a220ad430e48", + "assets/index-Cu0tEo5S.js": "d2a7ce802d45bbbf6cc7fcea110e51bd0339f6279a0a7d4ee9df66b0785feee8", + "index.html": "1a9d70fdea01faba7f0ea2e5e7a9d3bfdaa2e4a2bd0209c66d85f1d6d1c36048" } } diff --git a/addon-edge/config.yaml b/addon-edge/config.yaml index aaaf011..8b4f4eb 100644 --- a/addon-edge/config.yaml +++ b/addon-edge/config.yaml @@ -1,5 +1,5 @@ name: Domus Edge -version: "0.0.0-edge.7" +version: "0.0.0-edge.9" slug: domus_edge description: Development preview of the local Domus household application url: https://github.com/vypdev/domus diff --git a/backend/src/domus/application/recurrence_sources.py b/backend/src/domus/application/recurrence_sources.py new file mode 100644 index 0000000..45e7d4b --- /dev/null +++ b/backend/src/domus/application/recurrence_sources.py @@ -0,0 +1,65 @@ +"""A composition-scoped source reads original current/history facts without granting effects.""" + +from dataclasses import dataclass + +from domus.application.fixed_materialization import MaintenanceScope +from domus.application.ports import Transactions +from domus.application.task_authorization import recorded_request +from domus.domain.errors import InvalidInput, StorageFailure +from domus.domain.restoration import RetainedOperation +from domus.domain.task_events import TaskLifecycleEvent, TaskSourceRecord + + +@dataclass(frozen=True, slots=True) +class RecurrenceObservation: + original: RetainedOperation + event: TaskLifecycleEvent + + +@dataclass(frozen=True, slots=True) +class RecurrenceSourcePage: + items: tuple[RecurrenceObservation, ...] + after_position: int + complete: bool + source_rows_examined: int + + +@dataclass(frozen=True, slots=True) +class RecurrenceSourceObservations: + transactions: Transactions + scope: MaintenanceScope + + def read(self, *, limit: int = 100, after: int = 0) -> RecurrenceSourcePage: + if type(limit) is not int or not 1 <= limit <= 100: + raise InvalidInput("invalid_page_size") + if type(after) is not int or not 0 <= after <= 2**63 - 1: + raise InvalidInput("invalid_task_position") + with self.transactions.read() as unit: + self.scope.require(unit) + records = unit.tasks.lifecycle_sources( + self.scope.household_id, limit=limit, after=after + ) + if type(records) is not tuple or len(records) > limit: + raise StorageFailure("invalid_recurrence_source") + items = [] + previous = after + for record in records: + if not isinstance(record, TaskSourceRecord): + raise StorageFailure("invalid_recurrence_source") + recorded_request(record.original.evidence) + try: + event = record.event(self.scope.installation) + except InvalidInput, TypeError, ValueError: + raise StorageFailure("invalid_recurrence_source") from None + if ( + event.cycle.household_id != self.scope.household_id + or event.position <= previous + ): + raise StorageFailure("invalid_recurrence_source") + items.append(RecurrenceObservation(record.original, event)) + previous = event.position + # No lookahead is fetched or skipped. An exact full page is conservatively incomplete. + # Even an empty scan consumes one unit of the independent source currency. + return RecurrenceSourcePage( + tuple(items), previous, len(records) < limit, max(1, len(records)) + ) diff --git a/backend/src/domus/application/relative_materialization.py b/backend/src/domus/application/relative_materialization.py new file mode 100644 index 0000000..f852a03 --- /dev/null +++ b/backend/src/domus/application/relative_materialization.py @@ -0,0 +1,228 @@ +"""A bounded initial relative step commits work, mapping, original proof and continuation.""" + +from collections.abc import Callable +from dataclasses import dataclass, replace +from typing import cast + +from domus.application.commands import AtomicCommands, Change +from domus.application.fixed_materialization import MaintenanceScope +from domus.application.ports import Clock, Identifiers, Transactions, UnitOfWork +from domus.application.recurrence_ports import RecurrenceTimes +from domus.domain.errors import Conflict, Forbidden, InvalidInput, NotFound, StorageFailure +from domus.domain.recurrence_records import ScheduleState +from domus.domain.relative_recurrence import ( + RelativeOperation, + RelativeProgress, + RelativeSchedule, + RelativeSlot, +) +from domus.domain.storage import CommandKey, JsonDocument, Receipt, validate_identifier +from domus.domain.task_values import DefinitionState +from domus.domain.tasks import TaskDefinition + + +@dataclass(frozen=True, slots=True) +class RelativeStep: + status: str + schedule_id: str + receipt: Receipt | None = None + occurrence_id: str | None = None + + +@dataclass(frozen=True, slots=True) +class _Source: + schedule: RelativeSchedule + progress: RelativeProgress + definition: TaskDefinition + + +@dataclass(frozen=True, slots=True) +class _InitialAuthority: + scope: MaintenanceScope + source: _Source + command: CommandKey + + def require(self, command: CommandKey, unit: UnitOfWork) -> None: + self.scope.require(unit) + if command != self.command: + raise Forbidden("maintenance_scope_denied") + rule = self.source.schedule + if ( + unit.recurrence.relative_schedule(rule.household_id, rule.schedule_id) != rule + or unit.recurrence.relative_progress(rule.household_id, rule.schedule_id) + != self.source.progress + or unit.tasks.definition(rule.household_id, rule.definition_id) + != self.source.definition + ): + raise Conflict("recurrence_source_changed") + + +@dataclass +class RelativeMaintenance: + transactions: Transactions + scope: MaintenanceScope + times: RecurrenceTimes + clock: Clock + identifiers: Identifiers + + def _source(self, unit: UnitOfWork, schedule_id: str) -> _Source: + self.scope.require(unit) + rule = unit.recurrence.relative_schedule(self.scope.household_id, schedule_id) + if rule is None: + raise NotFound("recurrence_rule_missing") + progress = unit.recurrence.relative_progress(rule.household_id, schedule_id) + definition = unit.tasks.definition(rule.household_id, rule.definition_id) + if progress is None or definition is None or not progress.matches_schedule(rule): + raise StorageFailure("invalid_recurrence_source") + return _Source(rule, progress, definition) + + @staticmethod + def _has_work(unit: UnitOfWork, rule: RelativeSchedule) -> bool: + return bool( + unit.tasks.occurrences(rule.household_id, definition_id=rule.definition_id, limit=1) + ) + + @staticmethod + def _result(source: _Source, slot: RelativeSlot) -> JsonDocument: + continuation = replace( + source.progress, + current_occurrence_id=slot.occurrence_id, + current_slot_key=slot.key, + ) + return JsonDocument.from_value( + { + "version": 1, + "slot_key": slot.key, + "occurrence_id": slot.occurrence_id, + "schedule_id": slot.schedule.schedule_id, + "definition_id": slot.schedule.definition_id, + "created_at": slot.created_at, + "relative_slot_fingerprint": slot.evidence_fingerprint, + "relative_progress": continuation.state_document().decode(), + } + ) + + def _original_slot(self, source: _Source, receipt: Receipt) -> RelativeSlot: + data = receipt.result.decode() + try: + slot = RelativeSlot( + self.scope.installation, + source.schedule, + source.schedule.initial, + cast(str, data.get("occurrence_id")), + cast(int, data.get("created_at")), + ) + except InvalidInput: + raise StorageFailure("invalid_recurrence_effect") from None + if ( + receipt.command != slot.command + or receipt.created_at < slot.created_at + or receipt.result != self._result(source, slot) + ): + raise StorageFailure("invalid_recurrence_effect") + return slot + + def initial( + self, + schedule_id: str, + *, + expected_schedule: RelativeSchedule | None = None, + should_continue: Callable[[], bool] = lambda: True, + ) -> RelativeStep: + """Create one initial obligation; existing manual work needs its explicit review flow.""" + validate_identifier(schedule_id) + with self.transactions.read() as unit: + source = self._source(unit, schedule_id) + existing_work = ( + self._has_work(unit, source.schedule) if source.progress.pristine else False + ) + rule, progress = source.schedule, source.progress + if expected_schedule is not None and expected_schedule != rule: + raise Conflict("recurrence_source_changed") + if rule.state is not ScheduleState.ACTIVE: + return RelativeStep(rule.state.value, schedule_id) + if source.definition.state is not DefinitionState.ACTIVE: + return RelativeStep("definition_inactive", schedule_id) + if not progress.pristine: + return RelativeStep( + "current", schedule_id, occurrence_id=progress.current_occurrence_id + ) + if not should_continue(): + return RelativeStep("stopped", schedule_id) + probe = RelativeSlot( + self.scope.installation, rule, rule.initial, "pending", self.clock.now() + ) + payload = JsonDocument.from_value( + { + "version": 1, + "slot_key": probe.key, + "generation": rule.binding, + "initial": rule.initial.value(), + } + ) + commands = AtomicCommands( + self.transactions, + _InitialAuthority(self.scope, source, probe.command), + self.clock, + self.identifiers, + ) + previous = commands.replay(probe.command, payload) + if previous is None: + if existing_work: + return RelativeStep("initial_review_required", schedule_id) + self.times.require_policy(rule.policy) + if not should_continue(): + return RelativeStep("stopped", schedule_id) + created = False + + def apply(unit: UnitOfWork) -> Change: + nonlocal created + # Recheck after provider I/O and immediately before any new write. + if self._has_work(unit, rule): + raise Conflict("relative_initial_review_required") + work = source.definition.new_occurrence( + self.identifiers.new(), + self.clock.now(), + due_at=None if rule.initial.resolved is None else rule.initial.resolved.instant_ms, + ) + slot = RelativeSlot( + self.scope.installation, rule, rule.initial, work.occurrence_id, work.created_at + ) + unit.tasks.save_occurrence(work, 0) + unit.recurrence.add_relative_slot(slot) + created = True + result = self._result(source, slot) + return Change( + result, + JsonDocument.from_value({"request": payload.decode(), "result": result.decode()}), + JsonDocument.from_value( + {"version": 1, "kind": probe.command.action, **result.decode()} + ), + ) + + with self.transactions.write() as unit: + if not should_continue(): + return RelativeStep("stopped", schedule_id) + receipt = commands.in_unit(unit, probe.command, payload, apply) + original = self._original_slot(source, receipt) + saved = unit.recurrence.relative_slot(rule.household_id, original.key) + if receipt.historical: + if saved is not None: + raise StorageFailure("invalid_recurrence_effect") + status = "historical_absent" + else: + if saved is None or saved.evidence_fingerprint != original.evidence_fingerprint: + raise StorageFailure("invalid_recurrence_effect") + status = "created" if created else "replayed" + unit.recurrence.save_relative_progress( + replace( + progress, + current_occurrence_id=original.occurrence_id, + current_slot_key=original.key, + last_run_at=self.clock.now(), + revision=progress.revision + 1, + evidence=RelativeOperation(receipt.command, receipt.operation_id), + ), + progress.revision, + ) + return RelativeStep(status, schedule_id, receipt, original.occurrence_id) diff --git a/backend/src/domus/application/task_ports.py b/backend/src/domus/application/task_ports.py index 3e9b21b..8335021 100644 --- a/backend/src/domus/application/task_ports.py +++ b/backend/src/domus/application/task_ports.py @@ -5,6 +5,7 @@ from domus.domain.restoration import OperationEvidence, RetainedOperation from domus.domain.storage import JsonDocument +from domus.domain.task_events import TaskSourceRecord from domus.domain.task_reviews import DependencyReviewState from domus.domain.task_values import DefinitionState, OccurrenceState from domus.domain.tasks import HouseholdTaskPolicy, TaskCycle, TaskDefinition, TaskOccurrence @@ -24,6 +25,10 @@ class TaskReviewEvidence: class TaskRepository(Protocol): + def lifecycle_sources( + self, household_id: str, *, limit: int = 100, after: int = 0 + ) -> tuple[TaskSourceRecord, ...]: ... + def lifecycle_events( self, household_id: str, *, limit: int = 25, after: int = 0 ) -> tuple[OperationEvidence, ...]: ... diff --git a/backend/src/domus/application/task_queries.py b/backend/src/domus/application/task_queries.py index 3d7102c..8be9e2c 100644 --- a/backend/src/domus/application/task_queries.py +++ b/backend/src/domus/application/task_queries.py @@ -56,6 +56,13 @@ class TaskPage[T]: next_cursor: str | None +@dataclass(frozen=True, slots=True) +class TaskWorkspaceView: + household_id: str + member_id: str + can_create: bool + + @dataclass(frozen=True, slots=True) class TaskPolicyView: rules: TaskRules @@ -489,28 +496,63 @@ def _history_item( material_intent_id=material_intent_id, ) - def operation(self, operation_id: str) -> OperationView: - validate_identifier(operation_id) + def workspace(self) -> TaskWorkspaceView: + """Return task-specific affordances without widening the administration permission scope.""" with self.transactions.read() as unit: actor = self._read(unit) - HouseholdAccess(self.human).require( - unit, - self.household_id, - Permission.OPERATIONS_READ, - subject_member_id=actor.member.member_id, + can_create = _possible( + lambda: TaskAccess(self.human).require( + unit, self.household_id, Permission.TASKS_CREATE + ) ) - row = unit.tasks.operation(self.household_id, self.human.ha_user_id, operation_id) - if row is None: + return TaskWorkspaceView(self.household_id, actor.member.member_id, can_create) + + def _operation_access(self, unit: UnitOfWork) -> None: + actor = self._read(unit) + HouseholdAccess(self.human).require( + unit, + self.household_id, + Permission.OPERATIONS_READ, + subject_member_id=actor.member.member_id, + ) + + def _operation_view(self, unit: UnitOfWork, operation_id: str) -> OperationView: + row = unit.tasks.operation(self.household_id, self.human.ha_user_id, operation_id) + if row is None: + raise NotFound("operation_missing") + authority = recorded_authorization(TaskAccess(self.human), row.evidence) + authority.require(row.evidence.receipt.command, unit) + receipt = replace( + row.evidence.receipt, + restoration=None + if row.archived_by is None + else RestorationProvenance(row.archived_by, row.current), + ) + return OperationView(receipt, row.evidence.delivered) + + def operation(self, operation_id: str) -> OperationView: + validate_identifier(operation_id) + with self.transactions.read() as unit: + self._operation_access(unit) + return self._operation_view(unit, operation_id) + + def original(self, action: str, key: str) -> OperationView: + """Recover the caller's original workspace request without executing or resending it.""" + if action not in { + "task.create", + "task.claim", + "task.complete", + "task.verify", + "task.reopen", + }: + raise Forbidden("permission_denied") + command = CommandKey(self.household_id, self.human.ha_user_id, action, key) + with self.transactions.read() as unit: + self._read(unit) + receipt = unit.receipts.get(command) + if receipt is None: raise NotFound("operation_missing") - authority = recorded_authorization(TaskAccess(self.human), row.evidence) - authority.require(row.evidence.receipt.command, unit) - receipt = replace( - row.evidence.receipt, - restoration=None - if row.archived_by is None - else RestorationProvenance(row.archived_by, row.current), - ) - return OperationView(receipt, row.evidence.delivered) + return self._operation_view(unit, receipt.operation_id) def _actions( self, diff --git a/backend/src/domus/domain/relative_lifecycle.py b/backend/src/domus/domain/relative_lifecycle.py new file mode 100644 index 0000000..3ab655f --- /dev/null +++ b/backend/src/domus/domain/relative_lifecycle.py @@ -0,0 +1,216 @@ +"""Immutable consumer closure and pure successor policy do not grant authority or change work.""" + +import json +from dataclasses import asdict, dataclass +from enum import StrEnum + +from domus.domain.errors import InvalidInput +from domus.domain.recurrence_records import MAINTENANCE_ACTOR +from domus.domain.relative_recurrence import RelativeOperation, RelativeSchedule, RelativeSlot +from domus.domain.storage import Installation, JsonDocument, Receipt +from domus.domain.task_values import OccurrenceState, VerificationState, validate_task_time +from domus.domain.tasks import TaskCycle, TaskOccurrence + + +class RelativeClosureKind(StrEnum): + GENERATED = "generated" + REJECTED = "rejected" + REOPENED = "reopened" + HISTORICAL_ABSENT = "historical_absent" + + +class WorkHistory(StrEnum): + UNSTARTED = "unstarted" + STARTED = "started" + UNKNOWN = "unknown" + + +class SuccessorDisposition(StrEnum): + CURRENT = "current" + CLOSED = "closed" + ABSENT = "absent" + RETAINED = "retained" + REVIEW = "review" + CANCEL = "cancel" + + +@dataclass(frozen=True, slots=True) +class RelativeClosure: + """One original cycle is fenced across rule generations and compatible restoration. + + This is consumer proof, distinct from closing or approving the human TaskCycle. + A generated closure keeps its original slot even if later work needs invalidation. + Persistence must enforce uniqueness by key and validate the original receipt. + """ + + installation: Installation + schedule: RelativeSchedule + cycle: TaskCycle + kind: RelativeClosureKind + recorded_at: int + operation: RelativeOperation + successor: RelativeSlot | None = None + + def __post_init__(self) -> None: + if ( + not isinstance(self.installation, Installation) + or not isinstance(self.schedule, RelativeSchedule) + or not isinstance(self.cycle, TaskCycle) + or not isinstance(self.kind, RelativeClosureKind) + or not isinstance(self.operation, RelativeOperation) + ): + raise InvalidInput("invalid_relative_closure") + validate_task_time(self.recorded_at) + latest = max( + self.schedule.created_at, + self.cycle.recorded_at, + self.cycle.verified_at if self.cycle.verified_at is not None else 0, + self.cycle.closed_at if self.cycle.closed_at is not None else 0, + ) + if self.cycle.household_id != self.schedule.household_id or self.recorded_at < latest: + raise InvalidInput("invalid_relative_closure") + slot = self.successor + command = self.operation.command + if self.kind is RelativeClosureKind.GENERATED: + if ( + not isinstance(slot, RelativeSlot) + or not self.schedule.allows_successor(self.cycle) + or slot.installation != self.installation + or slot.schedule != self.schedule + or slot.source is None + or slot.source.cycle != self.cycle.completion_snapshot() + or slot.created_at != self.recorded_at + or command != slot.command + ): + raise InvalidInput("invalid_relative_closure") + elif ( + slot is not None + or command.household_id != self.schedule.household_id + or command.actor_id != MAINTENANCE_ACTOR + or command.action != "recurrence.relative.close" + or command.key != self.key + or ( + self.kind is RelativeClosureKind.REJECTED + and self.cycle.verification is not VerificationState.REJECTED + ) + or (self.kind is RelativeClosureKind.REOPENED and self.cycle.closed_at is None) + ): + raise InvalidInput("invalid_relative_closure") + + @staticmethod + def cycle_key(schedule: RelativeSchedule, cycle: TaskCycle, installation: Installation) -> str: + if ( + not isinstance(schedule, RelativeSchedule) + or not isinstance(cycle, TaskCycle) + or not isinstance(installation, Installation) + or cycle.household_id != schedule.household_id + ): + raise InvalidInput("invalid_relative_closure") + return JsonDocument.from_value( + { + "version": 1, + "installation": asdict(installation), + "household_id": schedule.household_id, + "schedule_id": schedule.schedule_id, + "definition_id": schedule.definition_id, + "occurrence_id": cycle.occurrence_id, + "cycle_id": cycle.cycle_id, + } + ).fingerprint + + @property + def key(self) -> str: + return self.cycle_key(self.schedule, self.cycle, self.installation) + + def document(self) -> JsonDocument: + return JsonDocument.from_value( + { + "version": 1, + "key": self.key, + "kind": self.kind.value, + "installation": asdict(self.installation), + "schedule": self.schedule.generation_document().decode(), + "cycle": JsonDocument(json.dumps(asdict(self.cycle))).decode(), + "recorded_at": self.recorded_at, + "successor": None + if self.successor is None + else { + "slot_key": self.successor.key, + "occurrence_id": self.successor.occurrence_id, + "fingerprint": self.successor.evidence_fingerprint, + }, + } + ) + + def matches_receipt(self, receipt: Receipt | None) -> bool: + if ( + receipt is None + or receipt.command != self.operation.command + or receipt.operation_id != self.operation.operation_id + ): + return False + return JsonDocument.from_value( + {"relative_closure": self.document().decode()} + ) == JsonDocument.from_value( + {"relative_closure": receipt.result.decode().get("relative_closure")} + ) + + def matches_source(self, schedule: RelativeSchedule, cycle: TaskCycle) -> bool: + return ( + isinstance(schedule, RelativeSchedule) + and isinstance(cycle, TaskCycle) + and self.schedule.schedule_id == schedule.schedule_id + and self.schedule.household_id == schedule.household_id + and self.schedule.definition_id == schedule.definition_id + and self.schedule.created_at == schedule.created_at + and self.cycle.completion_snapshot() == cycle.completion_snapshot() + ) + + +def successor_disposition( + closure: RelativeClosure, + current_cycle: TaskCycle | None, + successor: TaskOccurrence | None, + history: WorkHistory, +) -> SuccessorDisposition: + """Plan only; the writer must recheck exact revisions and original consumer proof.""" + if ( + not isinstance(closure, RelativeClosure) + or not isinstance(history, WorkHistory) + or ( + current_cycle is not None + and ( + not isinstance(current_cycle, TaskCycle) + or not closure.matches_source(closure.schedule, current_cycle) + ) + ) + ): + raise InvalidInput("invalid_relative_invalidation") + slot = closure.successor + if successor is not None and ( + not isinstance(successor, TaskOccurrence) + or slot is None + or successor.occurrence_id != slot.occurrence_id + or successor.household_id != closure.schedule.household_id + or successor.definition_snapshot.definition_id != closure.schedule.definition_id + ): + raise InvalidInput("invalid_relative_invalidation") + if closure.kind is not RelativeClosureKind.GENERATED: + return SuccessorDisposition.CLOSED + if current_cycle is None: + return SuccessorDisposition.REVIEW + if ( + current_cycle.closed_at is None + and current_cycle.verification is not VerificationState.REJECTED + ): + return SuccessorDisposition.CURRENT + if successor is None: + return SuccessorDisposition.ABSENT + if successor.state in (OccurrenceState.CANCELLED, OccurrenceState.SKIPPED): + return SuccessorDisposition.RETAINED + if ( + successor.state in (OccurrenceState.IN_PROGRESS, OccurrenceState.COMPLETED) + or history is not WorkHistory.UNSTARTED + ): + return SuccessorDisposition.REVIEW + return SuccessorDisposition.CANCEL diff --git a/backend/src/domus/domain/task_events.py b/backend/src/domus/domain/task_events.py index d14f7c2..62a8277 100644 --- a/backend/src/domus/domain/task_events.py +++ b/backend/src/domus/domain/task_events.py @@ -8,7 +8,7 @@ from domus.domain.errors import InvalidInput from domus.domain.identity import validate_revision -from domus.domain.restoration import OperationEvidence +from domus.domain.restoration import OperationEvidence, RetainedOperation from domus.domain.storage import Installation, JsonDocument, JsonObject, validate_identifier from domus.domain.task_values import ( TaskPerformer, @@ -222,9 +222,10 @@ def validate_reopening(evidence: OperationEvidence, cycle: TaskCycle) -> JsonObj return result -def lifecycle_event( - evidence: OperationEvidence, work: TaskOccurrence, cycle: TaskCycle, installation: Installation +def original_lifecycle_event( + evidence: OperationEvidence, cycle: TaskCycle, installation: Installation ) -> TaskLifecycleEvent: + """Decode the original fact without asserting that its work is present or currently eligible.""" action = evidence.receipt.command.action try: kind = { @@ -250,19 +251,43 @@ def lifecycle_event( != JsonDocument.from_value({"cycle": _cycle_snapshot(snapshot).decode()}) ): raise InvalidInput("invalid_task_event") - if ( - cycle.household_id != work.household_id - or cycle.occurrence_id != work.occurrence_id - or result.get("definition_id") != work.definition_snapshot.definition_id - ): - raise InvalidInput("invalid_task_event") return TaskLifecycleEvent( installation, evidence.receipt.operation_id, evidence.position, kind, - work.definition_snapshot.definition_id, + cast(str, result["definition_id"]), cast(int, result["revision"]), snapshot, evidence.receipt.created_at, ) + + +def lifecycle_event( + evidence: OperationEvidence, work: TaskOccurrence, cycle: TaskCycle, installation: Installation +) -> TaskLifecycleEvent: + event = original_lifecycle_event(evidence, cycle, installation) + if ( + cycle.household_id != work.household_id + or cycle.occurrence_id != work.occurrence_id + or event.definition_id != work.definition_snapshot.definition_id + ): + raise InvalidInput("invalid_task_event") + return event + + +@dataclass(frozen=True, slots=True) +class TaskSourceRecord: + """An original decoded cycle and its retained evidence; current eligibility is separate.""" + + original: RetainedOperation + cycle: TaskCycle + + def __post_init__(self) -> None: + if not isinstance(self.original, RetainedOperation) or not isinstance( + self.cycle, TaskCycle + ): + raise InvalidInput("invalid_task_event") + + def event(self, installation: Installation) -> TaskLifecycleEvent: + return original_lifecycle_event(self.original.evidence, self.cycle, installation) diff --git a/backend/src/domus/entrypoints/task_http.py b/backend/src/domus/entrypoints/task_http.py index 3e881ec..a042e41 100644 --- a/backend/src/domus/entrypoints/task_http.py +++ b/backend/src/domus/entrypoints/task_http.py @@ -2,7 +2,7 @@ from collections.abc import Callable from dataclasses import dataclass -from typing import Annotated +from typing import Annotated, Literal from fastapi import APIRouter, Query, Request from fastapi.params import Depends @@ -16,6 +16,7 @@ TaskPage, TaskPolicyView, TaskQueries, + TaskWorkspaceView, ) from domus.application.task_requests import ( AssignTask, @@ -71,6 +72,15 @@ detail_response, ) +TaskWorkspaceAction = Literal[ + "task.create", "task.claim", "task.complete", "task.verify", "task.reopen" +] + + +class TaskOriginalResponse(OperationResponse): + action: TaskWorkspaceAction + command_key: str + @dataclass(frozen=True, slots=True) class TaskHTTPContext: @@ -89,6 +99,10 @@ def create_task_router( ] } + @router.get("/access", response_model=TaskWorkspaceView) + def workspace(request: Request, query: Annotated[_EmptyQuery, Query()]) -> TaskWorkspaceView: + return context(request).queries().workspace() + @router.get("/policy", response_model=TaskPolicyView) def policy(request: Request, query: Annotated[_EmptyQuery, Query()]) -> TaskPolicyView: return context(request).queries().policy() @@ -447,4 +461,23 @@ def operation( else "pending", ) + @router.get("/requests/{action}/{key}", response_model=TaskOriginalResponse) + def original_request( + action: TaskWorkspaceAction, + key: Identifier, + request: Request, + query: Annotated[_EmptyQuery, Query()], + ) -> TaskOriginalResponse: + view = context(request).queries().original(action, key) + return TaskOriginalResponse( + **_receipt(view.receipt).model_dump(), + action=action, + command_key=key, + delivery="suppressed" + if view.receipt.historical + else "delivered" + if view.delivered + else "pending", + ) + return router diff --git a/backend/src/domus/infrastructure/sqlite_relative_lifecycle.py b/backend/src/domus/infrastructure/sqlite_relative_lifecycle.py new file mode 100644 index 0000000..922137e --- /dev/null +++ b/backend/src/domus/infrastructure/sqlite_relative_lifecycle.py @@ -0,0 +1,65 @@ +"""Closed closure snapshots prepare durable storage without granting consumer authority.""" + +from typing import cast + +from domus.domain.relative_lifecycle import RelativeClosure, RelativeClosureKind +from domus.domain.storage import Installation, JsonDocument, JsonObject, JsonValue +from domus.infrastructure.sqlite_recurrence_records import _object, _read, _record +from domus.infrastructure.sqlite_relative_recurrence_records import ( + _operation, + read_relative_schedule, + read_relative_slot, + relative_schedule_document, + relative_slot_document, +) +from domus.infrastructure.sqlite_task_records import cycle_document, read_cycle + + +def relative_closure_document(value: RelativeClosure) -> JsonDocument: + document: JsonObject = { + "version": 1, + "installation": { + "instance_id": value.installation.instance_id, + "channel": value.installation.channel, + }, + "schedule": relative_schedule_document(value.schedule).decode(), + "cycle": cycle_document(value.cycle).decode(), + "kind": value.kind.value, + "recorded_at": value.recorded_at, + "operation": { + "command": { + "household_id": value.operation.command.household_id, + "actor_id": value.operation.command.actor_id, + "action": value.operation.command.action, + "key": value.operation.command.key, + }, + "operation_id": value.operation.operation_id, + }, + "successor": None + if value.successor is None + else relative_slot_document(value.successor).decode(), + } + return JsonDocument.from_value(document) + + +def _closure(value: JsonValue) -> RelativeClosure: + row = _record( + value, + ("installation", "schedule", "cycle", "kind", "recorded_at", "operation", "successor"), + ) + installation = _object(row["installation"], ("instance_id", "channel")) + return RelativeClosure( + Installation(cast(str, installation["instance_id"]), cast(str, installation["channel"])), + read_relative_schedule(JsonDocument.from_value(row["schedule"]).encoded), + read_cycle(JsonDocument.from_value(row["cycle"]).encoded), + RelativeClosureKind(cast(str, row["kind"])), + cast(int, row["recorded_at"]), + _operation(row["operation"]), + None + if row["successor"] is None + else read_relative_slot(JsonDocument.from_value(row["successor"]).encoded), + ) + + +def read_relative_closure(document: str) -> RelativeClosure: + return _read(document, _closure) diff --git a/backend/src/domus/infrastructure/sqlite_task_history.py b/backend/src/domus/infrastructure/sqlite_task_history.py index c3b46fa..5c9cbcf 100644 --- a/backend/src/domus/infrastructure/sqlite_task_history.py +++ b/backend/src/domus/infrastructure/sqlite_task_history.py @@ -6,8 +6,10 @@ from domus.domain.errors import InvalidInput, StorageFailure from domus.domain.restoration import OperationEvidence, RetainedOperation -from domus.domain.storage import validate_identifier +from domus.domain.storage import JsonDocument, validate_identifier +from domus.domain.task_events import TaskSourceRecord from domus.infrastructure.sqlite_history import SQLiteHistory, _evidence, _record, _same +from domus.infrastructure.sqlite_task_records import read_cycle _CURRENT = ( "SELECT r.*,a.detail AS audit_detail,o.payload AS event_payload,o.position,o.delivered," @@ -24,6 +26,44 @@ class SQLiteTaskHistory: connection: sqlite3.Connection + def lifecycle_sources( + self, household_id: str, *, limit: int = 100, after: int = 0 + ) -> tuple[TaskSourceRecord, ...]: + validate_identifier(household_id) + if type(limit) is not int or not 1 <= limit <= 100: + raise InvalidInput("invalid_page_size") + if type(after) is not int or not 0 <= after <= 2**63 - 1: + raise InvalidInput("invalid_task_position") + candidates = self.connection.execute( + "SELECT r.actor_id,r.operation_id,o.position,1 AS current FROM receipts r " + "JOIN outbox o ON o.event_id=r.operation_id " + "WHERE r.household_id=? AND r.action IN ('task.complete','task.verify','task.reopen') " + "AND o.position>? UNION ALL " + "SELECT actor_id,operation_id,position,0 AS current FROM retained_operations " + "WHERE household_id=? AND current=0 " + "AND action IN ('task.complete','task.verify','task.reopen') AND position>? " + "ORDER BY position LIMIT ?", + (household_id, after, household_id, after, limit), + ).fetchall() + result = [] + previous = after + for row in candidates: + retained = self.operation(household_id, row["actor_id"], row["operation_id"]) + if ( + retained is None + or retained.current != bool(row["current"]) + or retained.evidence.position != row["position"] + or row["position"] <= previous + ): + raise StorageFailure("invalid_recurrence_source") + raw = retained.evidence.receipt.result.decode().get("cycle") + if not isinstance(raw, dict): + raise StorageFailure("invalid_recurrence_source") + cycle = read_cycle(JsonDocument.from_value({"version": 1, **raw}).encoded) + result.append(TaskSourceRecord(retained, cycle)) + previous = row["position"] + return tuple(result) + def lifecycle_events( self, household_id: str, *, limit: int = 25, after: int = 0 ) -> tuple[OperationEvidence, ...]: diff --git a/backend/src/domus/infrastructure/sqlite_tasks.py b/backend/src/domus/infrastructure/sqlite_tasks.py index f02ab58..c04da57 100644 --- a/backend/src/domus/infrastructure/sqlite_tasks.py +++ b/backend/src/domus/infrastructure/sqlite_tasks.py @@ -9,6 +9,7 @@ from domus.domain.errors import Conflict, InvalidInput, StorageFailure from domus.domain.restoration import OperationEvidence, RetainedOperation from domus.domain.storage import validate_identifier +from domus.domain.task_events import TaskSourceRecord from domus.domain.task_reviews import DependencyReviewState from domus.domain.task_values import DefinitionState, OccurrenceState, VerificationState from domus.domain.tasks import HouseholdTaskPolicy, TaskCycle, TaskDefinition, TaskOccurrence @@ -106,6 +107,13 @@ class SQLiteTasks(TaskRepository): connection: sqlite3.Connection record_version: int = 2 + def lifecycle_sources( + self, household_id: str, *, limit: int = 100, after: int = 0 + ) -> tuple[TaskSourceRecord, ...]: + return SQLiteTaskHistory(self.connection).lifecycle_sources( + household_id, limit=limit, after=after + ) + def lifecycle_events( self, household_id: str, *, limit: int = 25, after: int = 0 ) -> tuple[OperationEvidence, ...]: diff --git a/docs/architecture-recurrence.md b/docs/architecture-recurrence.md index 0ca6770..74e6a45 100644 --- a/docs/architecture-recurrence.md +++ b/docs/architecture-recurrence.md @@ -398,3 +398,87 @@ historically processed creation returns original evidence without recreating its definition/rule or event delivery. This is distinct from the still-required relative materializer, source inbox/closure/eligibility/invalidation, reviewed attachment and mixed-rule runtime. See [relative creation evidence](evidence/recurrence-relative-creation.json) for measured scope and limits. + + +## Cycle-closure policy before durable integration + +The pure RelativeClosure policy records one original consumer grant or terminal fence per source +cycle. Its identity spans rule generations and later approval/reopening while preserving the exact +installation, channel, household, rule, definition and source occurrence/cycle. Original successor +and completion snapshots remain immutable. This consumer proof does not close or approve the human +TaskCycle, authorize maintenance or create an obligation by construction. + +A later rejection or reopening may cancel only derived work proven never to have started. Existing +cancelled/skipped work is retained; started/completed work or unknown history requires review. +Retained processing proof cannot recreate an absent successor. Closure chronology includes rule +creation and the observed human decision; zero is a valid timestamp. + +The 61 pure-policy tests and full backend campaign are recorded in +[lifecycle evidence](evidence/recurrence-lifecycle-calendar.json). Durable inbox/closure storage, +atomic source revalidation and successor effects, schema-10 compatibility, mixed execution and +calendar/UI integration remain required. The current runtime runner does not yet consume this policy. + + +## Bounded current and historical source observations + +RecurrenceSourceObservations now reads original task completion, verification and reopening facts +through a composition-owned MaintenanceScope and the semantic task source port. SQLite orders one +bounded union of live and retained-absent operations by their stable position. No lookahead is fetched +or discarded. A requested page contains at most 100 facts; an exact full page remains incomplete, +and an empty scan still consumes one unit of the independent source currency. + +Original receipt, audit, event and decoded cycle evidence remain distinct from current work. Actual +restore tests retain historical approvals/reopenings without changing a live pending cycle and +observe a restored-away completion whose live cycle no longer exists. Transport acknowledgement +does not hide facts. Current application reads validate immutable request/result shape and reject +malformed, foreign or unordered evidence without writing progress or allocating identifiers. + +This is a read boundary for the future durable consumer. Historical facts cannot grant new work; +the successor writer still needs atomic live revalidation, inbox/closure and invalidation/attention +persistence. Schema 9, original task event behavior and the three retained recurrence owners stay +unchanged. See [lifecycle evidence](evidence/recurrence-lifecycle-calendar.json) for measured scope. + +## Atomic initial relative work + +The application [initial relative step](../backend/src/domus/application/relative_materialization.py) +now materializes one initial obligation for an active, pristine relative rule. The composition-owned +maintenance scope and the exact current rule, definition and progress are rechecked inside the +writer. One real task occurrence, immutable relative slot, original receipt, audit, outbox and +compare-and-swap continuation commit together. Date-only work preserves its date without inventing +a UTC due time. This technical action never completes human work or awards points. + +An original receipt is checked before requesting timezone-provider work or allocating new effect +identifiers. Restart returns current continuation. A retained original can reconcile pristine +continuation after restoration; absent effects stay absent and are not re-created. Canonical +result fields and the saved slot must agree with the original proof. Existing manual work requires +explicit review, and racing source changes abort without partial writes. + +[The 51 focused cases](../tests/backend/test_relative_materialization.py) use actual SQLite for +rollback, restoration, independent writers, scope and source races, lifetime stops and integrity +failures. The complete backend campaign passes 4,552 cases with 100% statement and branch coverage +globally and per module; all five import contracts and 51 architecture cases pass. See the +[updated lifecycle evidence](evidence/recurrence-lifecycle-calendar.json). + +This is a source-level application service. It is not yet wired into the mixed maintenance runner, +an API or the installed Edge image. Reviewed manual adoption, successor materialization, durable +cycle closure, shared fairness and protected calendar acceptance remain required. Schema stays 9. + +## Closed relative lifecycle storage values + +The [closure codec](../backend/src/domus/infrastructure/sqlite_relative_lifecycle.py) encodes the +complete original installation, rule, human cycle, technical operation and optional successor +snapshot. It reuses the existing closed rule/slot/cycle decoders and reconstructs the immutable +closure. Unsupported versions, missing or unknown fields, duplicate keys, noncanonical storage +bytes and contradictory source/command/successor relationships fail without defaults or repair. +Date-only successors keep their original nominal date; no instant is invented. + +The [64 codec cases](../tests/backend/test_sqlite_relative_lifecycle.py) cover generated timed and +date-only work, rejected/reopened/historical absence, original receipt matching, and nested corruption +in canonical fixtures. The complete current backend campaign passes 4,616 tests across 155 modules, +with 100% supported statement/branch coverage globally and per module, strict types and architecture +checks. [Lifecycle evidence](evidence/recurrence-lifecycle-calendar.json) records the exact scope. + +This codec prepares storage values; it does not read a database original or grant consumer authority. +The repository must independently check original proof and row scope when its schema-10 closure +table is implemented. Durable inbox/closure writes, successor generation, maintenance wiring and +calendar acceptance remain pending. Schema stays 9. diff --git a/docs/architecture.md b/docs/architecture.md index 5cc23f0..0bee7f5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -50,7 +50,8 @@ Directory names will follow these boundaries, but no fake source files are creat ## Selected stack and trade-offs -The owner selected Python/FastAPI, SQLite and Lit/TypeScript/Vite on 2026-10-29 after reviewing Gateway and Symphonia. Domus owns HA-coherent presentation components, a Storybook catalog and Playwright browser contracts. Components contain presentation behavior; API clients, authorization and domain orchestration remain outside them. Neither gateway code nor private HA bundles become runtime dependencies. +The owner selected Python/FastAPI, SQLite and Lit/TypeScript/Vite on 2026-10-29 after reviewing Gateway and Symphonia. The 2026-11-04 clarification supersedes separate ownership of presentation primitives: Domus and Gateway must consume one maintained, independently versioned presentation package. The upper dashboard-style tab bar is common; Domus owns its household information architecture, composition and workflows. Components contain presentation behavior only; API clients, authorization and domain orchestration stay in their applications. The reviewed package is bundled into each app; Gateway's application/runtime and private HA bundles are not runtime dependencies. Current Domus still uses its original components until the extraction and consumer adoption in [issue #30](https://github.com/vypdev/domus/issues/30) are verified. See the [early-M2 sequence and section responsibilities](roadmap.md). +The shared package has its own planned repository, vypdev/ha-ui, npm coordinate @vypdev/ha-ui and Storybook hosted by that repository's GitHub Pages. This separates library API/version/catalog ownership from either application. Exact artifacts and integrity are recorded per consumer; publication, both consumer adoptions and human UX acceptance are distinct gates. No repository or package publication is claimed yet. React was an evaluated alternative and is not the selected UI stack. A later change needs explicit evidence and a superseding decision. One household per installation is confirmed. A single DB writer remains the initial concurrency proposal; a port-based storage seam permits a reviewed later database migration. There is no broker, microservice mesh, cloud backend, event-sourced reconstruction requirement or MCP server. diff --git a/docs/contracts/household-api.openapi.json b/docs/contracts/household-api.openapi.json index 1aa6c6a..5d50f25 100644 --- a/docs/contracts/household-api.openapi.json +++ b/docs/contracts/household-api.openapi.json @@ -3184,6 +3184,90 @@ "title": "TaskOccurrence", "type": "object" }, + "TaskOriginalResponse": { + "additionalProperties": false, + "properties": { + "action": { + "enum": [ + "task.create", + "task.claim", + "task.complete", + "task.verify", + "task.reopen" + ], + "title": "Action", + "type": "string" + }, + "action_required": { + "default": "Keep the operation ID. If the response was lost, retry the same payload and key.", + "title": "Action Required", + "type": "string" + }, + "archived_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Archived By" + }, + "command_key": { + "title": "Command Key", + "type": "string" + }, + "created_at": { + "title": "Created At", + "type": "integer" + }, + "delivery": { + "enum": [ + "pending", + "delivered", + "suppressed" + ], + "title": "Delivery", + "type": "string" + }, + "message": { + "default": "The household change was saved. Event delivery is tracked separately.", + "title": "Message", + "type": "string" + }, + "operation_id": { + "title": "Operation Id", + "type": "string" + }, + "result": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "title": "Result", + "type": "object" + }, + "state": { + "default": "committed", + "enum": [ + "committed", + "historical" + ], + "title": "State", + "type": "string" + } + }, + "required": [ + "operation_id", + "result", + "created_at", + "delivery", + "action", + "command_key" + ], + "title": "TaskOriginalResponse", + "type": "object" + }, "TaskOverrides": { "description": "None inherits; explicit false, zero and an empty tuple remain real overrides.", "properties": { @@ -3510,6 +3594,29 @@ "title": "TaskRules", "type": "object" }, + "TaskWorkspaceView": { + "properties": { + "can_create": { + "title": "Can Create", + "type": "boolean" + }, + "household_id": { + "title": "Household Id", + "type": "string" + }, + "member_id": { + "title": "Member Id", + "type": "string" + } + }, + "required": [ + "household_id", + "member_id", + "can_create" + ], + "title": "TaskWorkspaceView", + "type": "object" + }, "TransitionBody": { "additionalProperties": false, "properties": { @@ -8192,6 +8299,157 @@ "summary": "Session" } }, + "/api/v1/tasks/access": { + "get": { + "operationId": "workspace_api_v1_tasks_access_get", + "parameters": [ + { + "in": "header", + "name": "X-Domus-Context", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Domus-Context" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskWorkspaceView" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Workspace" + } + }, "/api/v1/tasks/definitions": { "get": { "operationId": "definitions_api_v1_tasks_definitions_get", @@ -12361,6 +12619,183 @@ ], "summary": "Update Policy" } + }, + "/api/v1/tasks/requests/{action}/{key}": { + "get": { + "operationId": "original_request_api_v1_tasks_requests__action___key__get", + "parameters": [ + { + "in": "path", + "name": "action", + "required": true, + "schema": { + "enum": [ + "task.create", + "task.claim", + "task.complete", + "task.verify", + "task.reopen" + ], + "title": "Action", + "type": "string" + } + }, + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$", + "title": "Key", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Domus-Context", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Domus-Context" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskOriginalResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "DomusSession": [] + } + ], + "summary": "Original Request" + } } } } diff --git a/docs/contracts/native-household-api.openapi.json b/docs/contracts/native-household-api.openapi.json index 4e893f6..b724d5a 100644 --- a/docs/contracts/native-household-api.openapi.json +++ b/docs/contracts/native-household-api.openapi.json @@ -3146,6 +3146,90 @@ "title": "TaskOccurrence", "type": "object" }, + "TaskOriginalResponse": { + "additionalProperties": false, + "properties": { + "action": { + "enum": [ + "task.create", + "task.claim", + "task.complete", + "task.verify", + "task.reopen" + ], + "title": "Action", + "type": "string" + }, + "action_required": { + "default": "Keep the operation ID. If the response was lost, retry the same payload and key.", + "title": "Action Required", + "type": "string" + }, + "archived_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Archived By" + }, + "command_key": { + "title": "Command Key", + "type": "string" + }, + "created_at": { + "title": "Created At", + "type": "integer" + }, + "delivery": { + "enum": [ + "pending", + "delivered", + "suppressed" + ], + "title": "Delivery", + "type": "string" + }, + "message": { + "default": "The household change was saved. Event delivery is tracked separately.", + "title": "Message", + "type": "string" + }, + "operation_id": { + "title": "Operation Id", + "type": "string" + }, + "result": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "title": "Result", + "type": "object" + }, + "state": { + "default": "committed", + "enum": [ + "committed", + "historical" + ], + "title": "State", + "type": "string" + } + }, + "required": [ + "operation_id", + "result", + "created_at", + "delivery", + "action", + "command_key" + ], + "title": "TaskOriginalResponse", + "type": "object" + }, "TaskOverrides": { "description": "None inherits; explicit false, zero and an empty tuple remain real overrides.", "properties": { @@ -3472,6 +3556,29 @@ "title": "TaskRules", "type": "object" }, + "TaskWorkspaceView": { + "properties": { + "can_create": { + "title": "Can Create", + "type": "boolean" + }, + "household_id": { + "title": "Household Id", + "type": "string" + }, + "member_id": { + "title": "Member Id", + "type": "string" + } + }, + "required": [ + "household_id", + "member_id", + "can_create" + ], + "title": "TaskWorkspaceView", + "type": "object" + }, "TransitionBody": { "additionalProperties": false, "properties": { @@ -7833,6 +7940,157 @@ "summary": "Session" } }, + "/api/v1/tasks/access": { + "get": { + "operationId": "workspace_api_v1_tasks_access_get", + "parameters": [ + { + "in": "header", + "name": "X-Domus-Context", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Domus-Context" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskWorkspaceView" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "HomeAssistantIngress": [] + } + ], + "summary": "Workspace" + } + }, "/api/v1/tasks/definitions": { "get": { "operationId": "definitions_api_v1_tasks_definitions_get", @@ -12003,6 +12261,183 @@ "summary": "Update Policy" } }, + "/api/v1/tasks/requests/{action}/{key}": { + "get": { + "operationId": "original_request_api_v1_tasks_requests__action___key__get", + "parameters": [ + { + "in": "path", + "name": "action", + "required": true, + "schema": { + "enum": [ + "task.create", + "task.claim", + "task.complete", + "task.verify", + "task.reopen" + ], + "title": "Action", + "type": "string" + } + }, + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$", + "title": "Key", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Domus-Context", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Domus-Context" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskOriginalResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "405": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Method Not Allowed" + }, + "408": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "HomeAssistantIngress": [] + } + ], + "summary": "Original Request" + } + }, "/health": { "get": { "operationId": "health_health_get", diff --git a/docs/evidence/recurrence-lifecycle-calendar.json b/docs/evidence/recurrence-lifecycle-calendar.json new file mode 100644 index 0000000..2aa769e --- /dev/null +++ b/docs/evidence/recurrence-lifecycle-calendar.json @@ -0,0 +1,163 @@ +{ + "recordedAtUTC": "2026-11-04T03:36:30.445127+00:00", + "status": "Genuine schema-9 acceptance, pure cycle policy, bounded source observations and atomic initial relative work verified in source; maintenance integration, durable successor lifecycle and calendar remain outstanding.", + "issue": 23, + "nativePublication": { + "commit": "91adfe902055cb2c5bb42ceeb57c2b7ab53fb635", + "base": "8b1cbc110c389b9cdb6ecd11317c95abf2c4b0a9", + "run": 41715125228, + "job": 126193121874, + "scope": "Original native publication and exact managed branch verified; three prior owners byte-identical." + }, + "consumerCorrection": { + "nativeOriginalCommit": "91adfe902055cb2c5bb42ceeb57c2b7ab53fb635", + "immutableBase": "8b1cbc110c389b9cdb6ecd11317c95abf2c4b0a9", + "characters": 53984, + "sha256": "0bcf08c32342824b586a5a3a447dc5acc5e00421adcbaf4e13b88f3106ed1c1b", + "sections": 20, + "retainedCountedCases": 93, + "retainedPublishedNonCountedAliases": 18, + "independentRefinements": 18, + "reciprocalRequirements": 9, + "pathOperations": 99, + "pathCounts": { + "create": 44, + "update": 48, + "reference": 7 + }, + "scope": "Independent consumer correction preflight only; original native SDK validation of this correction is still required.", + "upstreamReport": "https://github.com/vypdev/copilot/issues/858#issuecomment-6615277620", + "engineeringClarification": "Consumer grant closure is separate from human task-cycle closure; source period/source-row currencies are distinct." + }, + "supportedSchema9Input": { + "path": "tests/fixtures/sqlite/v9.sql", + "sha256": "e3c81ef1fa42e1ea302de60662c10e7231466ab32da1931ecde1e6d7a49213b7", + "bytes": 46361, + "sourceRuntime": "e5f08ebe46f1ae3e2f81080746c0fd76c705b9fc", + "runtimePin": "3200bd9278f57f142ed03b54843a38a4138fc0af", + "origin": "Previously captured genuine schema8 migrated by the actual151-module reviewed runtime and two actual profile commands using fictional HA proof; not owner HA data." + }, + "executedChecks": { + "pytest": { + "command": "uv run --frozen pytest tests/backend/test_recurrence_lifecycle_migration.py -q", + "passed": 3, + "assertions": [ + "Exact captured SQL SHA256 before newDDL.", + "Current supported9read/reopen preserves2fixed slots,1pristine relative schedule/progress, null consumer evidence and explicit Spanish/false private preferences.", + "Independent snapshot schema9inspection preserves every original table row and source database bytes; source file is explicitly prepared0600." + ] + }, + "documentation": "uv run domus-check docs: passed", + "style": "Ruff check and format-check for the new test file: passed", + "independentDraftPreflight": "20sections,9requirements,18refinements,18noncounted publishedaliases,93retained counted owners; reciprocal traceability,99actual prospective path operations and original owner bytes verified." + }, + "testPreparation": "Raw SQL import initially produced a0644test file and the snapshot correctly rejected unsafe_installation_file. SQLiteDatabase.initialize is not the ownership-permission boundary. The fixture now explicitly prepares0600 before invoking snapshot creation; both original failures remain private diagnostic evidence. No production safety check changed.", + "pending": [ + "Original native SDK validation/review of the contributor correction and new exact-head complete CI.", + "Mixed maintenance wiring and reviewed manual initial adoption, successor generation, durable source inbox/closure/invalidation, shared fairness, reviewed schedule changes and actual task/calendar models/APIs/UI.", + "Genuine schema10migration/refusal/rollback if required by the planned durable lifecycle data.", + "Every93retained case,18refinement and publishedalias, actual human UX, HA companion surfaces, both deployment modes and isolated Stable/Edge full product acceptance." + ], + "noRuntimeOrCopilotChanges": false, + "goalComplete": false, + "pureClosurePolicy": { + "recordedAtUTC": "2026-11-04T00:34:31.945147+00:00", + "path": "backend/src/domus/domain/relative_lifecycle.py", + "tests": "tests/backend/test_relative_lifecycle.py", + "focusedPassed": 61, + "statements": 77, + "branches": 30, + "coverage": "100% statement and branch coverage", + "invariants": [ + "Cycle closure identity remains stable across rule generations, mutable verification and revision changes, while preserving installation/channel/household/rule/definition/source occurrence/cycle scope.", + "Grant proof retains its exact original successor, operation and immutable completion basis; consumer closure does not close or approve the human TaskCycle.", + "Canonical original receipts, including retained absent-effect history, can prove prior processing without granting recreation.", + "Late approval preserves the original completion basis; rejection/reopening cancels only proven unstarted derived work, retains stopped work and requires review for started/completed/unknown history.", + "Closure cannot predate rule creation or the observed completion/approval/reopening; zero remains a valid timestamp." + ], + "notImplemented": [ + "No current maintenance runner consumes this policy yet.", + "No durable inbox/closure/attention tables, schema-10 migration, atomic successor writer, calendar projection or product UI acceptance is claimed." + ] + }, + "copilotModified": false, + "storageSchema": 9, + "schemaChanged": false, + "boundedSourceObservations": { + "recordedAtUTC": "2026-11-04T00:55:13.412741+00:00", + "tests": "tests/backend/test_recurrence_source_observations.py", + "focusedPassed": 29, + "combinedSourceAndExistingEventPassed": 75, + "application": "backend/src/domus/application/recurrence_sources.py", + "logicalSourceBudget": "One SQL-ordered union of current and retained-absent originals; at most the requested 1..100 facts, without lookahead or skipped rows. An empty scan costs one source unit; an exact full page is conservatively incomplete.", + "verified": [ + "Stable original event/cycle identity and positional order when timestamps tie; delivery acknowledgement does not remove facts.", + "Current and genuinely restored historical approval/reopening retain their original snapshots independently of the current pending cycle.", + "A genuinely restored-away completion is readable as historical evidence even when its live cycle is absent; no work, receipt, outbox delivery or identifier is created.", + "Canonical original request/result/audit/event shape, bounded typed ports and composition-owned installation/channel/household scope are checked before observation.", + "Malformed cycle evidence and missing audit records fail closed without changing database bytes." + ], + "limitations": [ + "This read port is not a durable inbox, per-cycle closure writer or successor consumer.", + "Historical facts are observations, not eligibility or permission to create new work; the future writer must revalidate live facts atomically.", + "No schema change, calendar or full retained-case acceptance is claimed." + ] + }, + "currentBackendCampaign": { + "passed": 4616, + "runtimeModules": 155, + "statements": 10742, + "branches": 3022, + "coverage": "100% globally and per module, independently source-audited", + "strictTypes": "passed (155 source files)", + "cleanArchitecture": "5 import contracts kept; 51 architecture tests passed" + }, + "initialRelativeMaterialization": { + "application": "backend/src/domus/application/relative_materialization.py", + "tests": "tests/backend/test_relative_materialization.py", + "focusedPassed": 51, + "statements": 108, + "branches": 36, + "coverage": "100% statement and branch coverage", + "redFirst": "The new test module failed collection with ModuleNotFoundError before implementation; retained in private validation evidence.", + "verified": [ + "One initial obligation, scoped immutable slot, original receipt, audit, outbox and continuation commit atomically in actual SQLite for date-only and timed rules.", + "Installation/channel/household scope and exact current rule, definition and progress are rechecked in the writer; changed sources, a racing manual occurrence and stopped lifetimes create no partial effects.", + "Restart does not create another obligation. Existing live original proof reconciles pristine continuation without new provider work or identifiers.", + "Compatible restoration retains original processing proof, advances continuation as historical absence and never recreates absent work, slot, receipt or outbox.", + "Canonical proof and slot relationships are checked; malformed or contradictory typed port results fail closed.", + "Six injected transaction failures roll back all writes; independent writers preserve one original effect." + ], + "limitations": [ + "This service is not yet invoked by the mixed maintenance runner or exposed by an API/UI.", + "Existing manual work returns initial_review_required; explicit reviewed adoption remains to implement.", + "Successor generation, durable source inbox/closure, fair shared budgets, calendar projection, complete retained-case acceptance and owner HA execution remain pending.", + "Schema remains 9; no migration or new compiled image acceptance is claimed." + ] + }, + "closureStorageCodec": { + "path": "backend/src/domus/infrastructure/sqlite_relative_lifecycle.py", + "tests": "tests/backend/test_sqlite_relative_lifecycle.py", + "focusedPassed": 64, + "statements": 15, + "branches": 0, + "coverage": "100% supported statements; all successor/non-successor, timed/date-only encoding and decoding paths exercised.", + "verified": [ + "Full immutable schedule/cycle/operation/successor snapshots survive encode/decode without changing original receipt matching or cycle-stable key.", + "Generated timed/date-only, rejected, reopened and historical-absent records retain their distinct original facts.", + "Canonical versioned storage rejects missing/unknown fields, duplicate JSON keys, malformed UTF-8, noncanonical bytes and unsupported scalar/nested versions without defaults or repair.", + "Scope, original command and successor/source relationships are checked by the existing domain constructors after closed nested decoding." + ], + "limitations": [ + "Encoding/decoding alone does not prove the record has a matching stored original receipt or current authorization; the future repository/application writer must verify those independently.", + "No closure table, SQL persistence API, schema-10 migration, durable inbox, successor writer, invalidation/review flow, calendar or new owner HA execution is claimed.", + "The schema remains 9 and all retained owners and genuine schema-9 fixture remain unchanged." + ], + "sourcePackage": { + "pythonModules": 155, + "uiFiles": 4, + "wheelSha256": "e928f2a8ba749e75ea8e36b898cb48e97fbca8847e11d88b1898b0d13e15fdc0", + "scope": "Every runtime Python/compiled UI byte matches the current source wheel; no installed image acceptance claimed." + } + } +} diff --git a/docs/evidence/task-workspace.json b/docs/evidence/task-workspace.json new file mode 100644 index 0000000..2ef82be --- /dev/null +++ b/docs/evidence/task-workspace.json @@ -0,0 +1,269 @@ +{ + "recordedAtUTC": "2026-11-04T03:02:55.107721+00:00", + "status": "Task workspace adopted in owner Home Assistant as Edge.9; the subsequent required-reason form correction is verified in source and pending a future preview package.", + "issue": 23, + "pullRequest": 29, + "preparationBase": "a954ce091d19f6350d64eec759670d5a1fadcfb5", + "storageSchema": 9, + "implemented": [ + "Real task creation with title, description and priority; bounded list and task detail.", + "Current-member claiming and completion at the server-anchored actual action time.", + "Current-cycle adult approval/rejection and reasoned reopening; restricted work waits for approval by default.", + "Adjustable linked-material quantities, exact decimal canonicalization, per-unit precision and explicit zero; no fabricated Inventory effects.", + "Task-specific access affordances without widening administration permissions; current-authorized original-by-key GET recovery.", + "English/Spanish UI, current-account/member/household fencing, bounded private recovery records, session deadlines, keyboard focus and responsive layouts." + ], + "currentSourcePackage": { + "scope": "Current 155-module source wheel, including required review reason gating, initial relative application service and closed lifecycle codec. The installed Edge.9 remains separately pinned to 93d7d77; relative maintenance/lifecycle wiring and owner adoption remain pending.", + "wheelSHA256": "e928f2a8ba749e75ea8e36b898cb48e97fbca8847e11d88b1898b0d13e15fdc0", + "actualProductFiles": { + ".vite/manifest.json": "d2bc7b53fd3413e3a6eb29ab24694bf4db287d5c36e1701204bf242001c17d60", + "assets/index-CjJnHZzV.css": "0f2c8c2e31576c0b64fe60373150f09390dafb896df6c07777b2a220ad430e48", + "assets/index-CwOkFHeO.js": "f328b3ee9e0a8cdb706ce8d583feea426fba10de4e8d348f0bf4e1986d5a0d5b", + "index.html": "72b7d0ea7474d7023082fea09a2821b9910e947243477a325a4c41d2746aa456" + } + }, + "executedChecks": { + "backend": { + "passed": 4501, + "modules": 153, + "statements": 10619, + "branches": 2986, + "coverage": "100% globally and per module", + "independentAudit": "passed", + "scope": "Current backend with pure closure policy and bounded current/historical source observations. Installed Edge.9 remains pinned to the earlier 151-module source." + }, + "frontend": { + "passed": 1512, + "runtimeFiles": 39, + "metrics": { + "lines": { + "total": 2110, + "covered": 2110, + "skipped": 0, + "pct": 100 + }, + "statements": { + "total": 3208, + "covered": 3208, + "skipped": 0, + "pct": 100 + }, + "functions": { + "total": 683, + "covered": 683, + "skipped": 0, + "pct": 100 + }, + "branches": { + "total": 2608, + "covered": 2608, + "skipped": 0, + "pct": 100 + }, + "branchesTrue": { + "total": 0, + "covered": 0, + "skipped": 0, + "pct": 100 + } + }, + "independentAudit": "passed" + }, + "taskBrowser": { + "passed": 10, + "deployments": [ + "native Ingress composition", + "standalone composition" + ], + "scope": "Real compiled product UI, Python HTTP and SQLite; HA/Supervisor transports explicitly fictional.", + "journeys": [ + "English and Spanish create/claim/complete/reload for both deployments.", + "Restricted performer claims/completes; adult approves and reopens, preserving the original cycle for both deployments.", + "Lost committed creation reply survives reload and recovers by protected GET without another POST for both deployments.", + "Restricted completion loses its committed reply, reloads and recovers its own original by GET before adult review in both deployments.", + "Form-level invalid drafts are retained, and exact160/4000Unicode code-point maximums persist through real HTTP/SQLite in both deployments.", + "Reject and reopen remain disabled for blank or whitespace-only reasons; approval stays available without a reason, in both deployments." + ] + }, + "catalog": { + "passed": 43, + "scope": "Built catalog; actual component states in English/Spanish, dark/light narrow layout; no API or foreign requests." + }, + "toolingNode": { + "passed": 38, + "coverage": "100% all four supported metrics across both authored runtime files" + }, + "compiledProductBrowser": { + "passed": 99, + "scope": "Previous complete compiled UI / real Python / SQLite campaign at source 93d7d77 in both deployments, including ten task journeys; no retries or skips. The subsequent reason gating correction reran the complete frontend coverage and all ten affected task journeys." + }, + "platformAndPackage": { + "passed": 231, + "scope": "Architecture, CI/source-scope contracts, Python tooling and exact wheel/UI acceptance." + } + }, + "recovery": { + "namespace": "tasks", + "version": 1, + "maximumRecords": 8, + "maximumUTF8Bytes": 65536, + "lifetimeMilliseconds": 3600000, + "scope": [ + "installation", + "channel", + "HA subject", + "household", + "canonical membership" + ], + "credentialsStored": false, + "automaticPOSTRetry": false, + "unresolvedEviction": false + }, + "limitations": [ + "Actual owner Edge.9 now serves the task workspace and retained task/preferences after reload. This agent-operated owner test is separate from human UX review. Its pinned source predates the later required-reason form gating correction.", + "Relative recurrence lifecycle, protected calendar and timing editors remain outstanding; all 93 retained recurrence cases and 18 refinements remain required.", + "Multi-performer entry, full task/dependency editors, configurable points/rewards, integrated real Inventory/Shopping, automation, HA companion surfaces and complete first-release acceptance remain outstanding.", + "A recorded source event does not prove downstream points, stock, notification or automation delivery.", + "Original native SDK validation of the contributor SDD correction remains separate; no local SDK cache was restored or modified." + ], + "copilotModified": false, + "gatewayModified": false, + "goalComplete": false, + "specificationIndependentPreflight": { + "sections": 20, + "characters": 57120, + "sha256": "1d237e989faeed58bc3a1d2d4c486d1754635f996851420c747fdcb953eaa924", + "pathOperations": 141, + "pathCounts": { + "create": 60, + "update": 75, + "reference": 6 + }, + "retainedOwners": { + "specs/recurrence-calendar.md": "20ef11a108758e1d264c7fedacf6572af871041751aa959f7b00d57210c35ea0", + "specs/recurrence-execution-foundation.md": "e0575cf09f6bd2b805956c9a17d16ebe1bd4808b3062b2d0588513d2991d8c4d", + "specs/recurrence-storage-continuation.md": "c79c018e359e26e6f6ab0d774c55c7bd39d39452dc06dee81f0d138eb469b43b" + }, + "scope": "Independent 20-section/path-inventory/retained-owner-byte preflight. Native SDK format/budget/catalog acceptance remains separate; no SDK cache was restored or modified." + }, + "publishedRuntime": { + "commit": "93d7d77b98a196640bf0049bca36a3d90b7a5857", + "sourceArchiveSHA256": "97dc46699ad7eab54c487386ffaffc28624e8e50f9d3a2f1ffc6b525fb7b8f0e" + }, + "sourceCorrections": { + "taskRecovery": "Current task-read and recorded original command authorization, actor/action/key scope, without requiring the separate operations.read browsing grant. The general operation-by-ID query retains its permission.", + "textLimits": "Shared title160, description4000 and reason1000 Unicode code-point limits; visible invalid state prevents submission and preserves the entered draft. Native maxlength remains a UTF-16 capacity bound that permits astral characters.", + "regressions": "Explicit operations.read denial with retained claim/complete grants; current permission revocation and foreign actor; real restricted lost completion recovery; exact code-point limits in both browser deployments.", + "previewRepackaging": "Edge.9 exact pinned installer verified on both CPU families with44household/task browser journeys each.", + "upstreamAccuracyReport": "https://github.com/vypdev/copilot/issues/879", + "findingState": "The five task/documentation observations were natively verified fixed, and pagination classified obsolete at b2729f3 with complete coverage and zero active findings. Fresh review is required for later commits; no manual finding resolution or dismissal.", + "reviewReasons": "Reject/reopen are disabled for blank or whitespace-only reasons. Approval may omit a reason. The 1,000-code-point bound and current authorization remain unchanged." + }, + "precedingCandidateVerification": { + "version": "0.0.0-edge.8", + "storageSchema": 9, + "artifact": "addon-edge/artifact.json", + "bothCPUVerification": { + "scope": "Actual pinned installer images; exact155product file fingerprints, native restart/authority, standalone UI and frozen Edge.5 upgrade/replay/restore per CPU. Browser campaign uses real image product with fictional HA/Supervisor transports.", + "images": { + "aarch64": { + "imageID": "sha256:5b5662d99e4d2d843dc5c6452eece07a195925411123128bbf0284ea851f85de", + "pinnedImageChecksPassed": 4, + "actualImageBrowserJourneysPassed": 42 + }, + "amd64": { + "imageID": "sha256:7daea4c9c04e92bc30e5f3b50c136cb4cccc62ef24aa282aec1e8efc54c56dfb", + "pinnedImageChecksPassed": 4, + "actualImageBrowserJourneysPassed": 42 + } + } + }, + "ownerAdoption": "Pending: Chrome browser control reports native pipe unavailable; native observation has stale/empty page content and a uniform screenshot. HA responds to an unauthenticated HTTP request. No installation/update is claimed.", + "supersededByPendingCorrectedPreview": true + }, + "previewCandidate": { + "version": "0.0.0-edge.9", + "sourceCommit": "93d7d77b98a196640bf0049bca36a3d90b7a5857", + "sourceArchiveSHA256": "97dc46699ad7eab54c487386ffaffc28624e8e50f9d3a2f1ffc6b525fb7b8f0e", + "storageSchema": 9, + "artifact": "addon-edge/artifact.json", + "bothCPUVerification": { + "scope": "Actual pinned installers, exact 151 Python/four UI fingerprints, native authority/restart, standalone delivery and frozen Edge.5 upgrade/replay/restore on both CPU families. Each actual-image browser campaign has44expected, zero unexpected/skipped/flaky; HA/Supervisor transports are fictional fixtures, distinct from owner adoption.", + "images": { + "aarch64": { + "imageID": "sha256:3f46dc72e93c6aa2df637948580ed8d5d3093c5aa08470db751b7fb24e55a39f", + "pinnedImageChecksPassed": 4, + "actualImageBrowserJourneysPassed": 44 + }, + "amd64": { + "imageID": "sha256:5e0408f902cc98c48b911b7bebaf983c419d419145d6a17123dee6a4412668a3", + "pinnedImageChecksPassed": 4, + "actualImageBrowserJourneysPassed": 44 + } + } + }, + "ownerAdoption": "Installed, configured and started on actual owner HA; existing HA session, development household setup, create/claim/complete/reopen/second completion, saved Spanish preference and persisted detail after reload observed. Source is exactly the pinned 93d7d77 candidate; later source corrections are not silently included." + }, + "paginationReview": { + "scope": "Next page replaces one bounded page; Refresh tasks rereads the first page, with fresh current authority. No accumulating list is promised.", + "regression": "frontend/tests/task-controller.test.ts", + "upstreamReport": "https://github.com/vypdev/copilot/issues/880" + }, + "ownerPreparation": { + "scope": "The owner Edge.7 app was freshly observed running in Chrome; a selected local partial backup was created and its catalog inspected, then the app was observed started. Actual backup restoration has not been exercised. Private identifiers/screenshots remain local.", + "next": "Keep the selected Edge.7 backup and rollback until the next corrected preview has been verified; continue recurrence/calendar implementation." + }, + "ownerAdoption": { + "version": "0.0.0-edge.9", + "sourceCommit": "93d7d77b98a196640bf0049bca36a3d90b7a5857", + "deployment": "Actual Home Assistant Supervisor installation and Ingress, operated through Chrome native controls.", + "dataBoundary": "Separate app/private data from the prior Edge.7 repository; a new development household was explicitly created. No in-place migration claim.", + "observed": [ + "Installation completed with an ok result; Supervisor reported Edge.9 installed and running.", + "The exact local HA browser origin was saved; existing HA identity was verified without another login.", + "Created one development task, claimed it, completed it, reopened it with a reason and completed a second cycle.", + "Changed the visible UI to Spanish, reviewed/saved the private Spanish preference and reloaded HA.", + "The task list and selected second completed cycle remained present after reload, in Spanish." + ], + "notExercised": [ + "Actual two-account adult/restricted review on owner HA; that journey is covered by isolated real-product fixtures.", + "Actual backup restoration, stock posting, points, notification or automation delivery.", + "Human UX acceptance; agent observation is distinct." + ], + "privateEvidence": "Original owner screenshots and compact receipt retained locally under /private/tmp/domus-owner-edge9-93d7d77; private identifiers are not published.", + "priorRollback": "Selected local Edge.7 backup was created and inspected; prior app is retained until the next corrected preview is accepted." + }, + "actualImageBrowserHarnessCorrection": { + "runtime": { + "imageId": "sha256:a29e09738084d5573c71b9606fbafe63741f068e56a454ccc8fa18880a3f1301", + "runtimeSource": "8d2742ef596f23128ea2e0600db9f41e79c70531", + "pythonModules": 154, + "uiFiles": 4, + "wheelSha256": "0f2ed83b9e25874decf1017f354e446c8670bcd7bf3e12f010d8401dde183674", + "allRuntimeBytes": "image matches exact reviewed wheel" + }, + "originalFailure": { + "run": 41762169957, + "job": 126352129417, + "architecture": "aarch64", + "failed": 2, + "observation": "Chromium Network.getResponseBody could not return the original reopening body, even after capture was moved to the response event. Cache eviction or navigation cause is not independently established. The CI job did not yet retain its trace files." + }, + "correction": "The test intercepts the actual UI reopening request, fetches once with zero retries/redirects, retains real upstream bytes before forwarding that same status/headers/body, then checks one POST, no navigation, retained cycle and current SQLite state. No fabricated payload or production change.", + "verified": { + "toolingTests": 181, + "toolingCoverage": "100% supported statement and branch coverage", + "newCiContracts": "2 red first, then green: per-CPU always-retained browser evidence and post-failure fixture-retirement audit", + "frontendTypecheck": "passed", + "targetedBrowserExecutions": 6, + "actualAarch64ImageJourneys": 44, + "unexpected": 0, + "skipped": 0, + "flaky": 0, + "actualImageCampaignMilliseconds": 76857.33, + "survivingFixtureContainers": 0 + }, + "pending": "Fresh exact-head hosted validation of the test/CI correction in both CPU jobs; complete recurrence/calendar and installed owner product acceptance remain separate." + } +} diff --git a/docs/installation-home-assistant.md b/docs/installation-home-assistant.md index 0ebbf79..bcf1ac7 100644 --- a/docs/installation-home-assistant.md +++ b/docs/installation-home-assistant.md @@ -1,42 +1,46 @@ # Home Assistant app installation -Current development state (2026-11-03): the owner runs Edge `0.0.0-edge.5` on an `aarch64` HA -installation. Edge.3 is a separate stopped preview. Edge.7 is the current installation candidate -for the administration workspace; it is not yet installed on that HA. Stable is not yet published. -Current image, API and browser verification is recorded in -[administration evidence](evidence/household-administration-browser.json). Historical receipts below -retain the original Edge.1–3 observations and do not describe the current installation. +Current development state (2026-11-04): Edge `0.0.0-edge.9` is installed and running on the +owner's `aarch64` Home Assistant. Through its existing HA session and Ingress, the agent created, +claimed, completed and reopened a development task, completed a second cycle, and verified the task +and saved Spanish preference after reloading. This is agent-operated acceptance on actual hardware; +human usability review and complete first-release acceptance remain separate. Stable is not yet +published. Source, package and adoption evidence are recorded in +[task workspace evidence](evidence/task-workspace.json). Historical receipts below retain their +original Edge.1–3 observations and do not describe the current installation. ## Current preview and update path -The existing Edge.5 app is `60361632_domus_edge`; its source, read from Supervisor, is: +The current Edge.9 repository source, verified in Supervisor and tracked in PR #29, is: ```text -https://github.com/vypdev/domus#feature/8-deliver-the-household-entry-interface-for-native-and-standalone-deployments +https://github.com/vypdev/domus#feature/23-complete-relative-recurrence-lifecycle-and-protected-calendar-after-the-execution-checkpoint ``` -The reviewed Edge.7 candidate is in the existing PR #28 development source: - -```text -https://github.com/vypdev/domus#feature/27-complete-the-household-administration-workspace-after-the-verified-api-and-controller-checkpoint -``` - -These are temporary Action-managed development sources. Publishing Edge.7 on the latter does not -change the former. HA still offers Edge.5 for the installed source; its repository metadata links -to the same GitHub homepage, so the homepage alone does not identify the branch. Inspect the -Supervisor store's exact source and installed app slug before deciding which preview to open. -Adding the new branch as a repository is a separate preview path, not proof of an in-place update -or migration of the existing app's private data. Keep that existing app and its backup until the -chosen adoption/data path has been exercised. Never overwrite an old managed Git branch to make -an installed development repository follow unrelated work. - -Edge.7 retains the Edge.6 runtime pin `3200bd9278f57f142ed03b54843a38a4138fc0af` and archive SHA-256 -`ca991d7b3560dbdb7a6a86a34eea19708f01f10c15da502e49ed8ab740a8533b`. -The [artifact declaration](../addon-edge/artifact.json) fixes the Python and compiled UI bytes. -Read [configuration, permissions and schema-9 rollback limits](../addon-edge/DOCS.md) before adoption. -Existing Edge.5/schema7 and old Edge.3 data are distinct from the isolated test fixtures. -The candidate keeps Ingress, its private listener and the same Core API scope; it requests no -Supervisor management API, host mount or public port. +These temporary branch sources are owned by the Action. Prefer the durable development source +`https://github.com/vypdev/domus#develop` after its verified merge contains the intended version. +A published candidate on another source does not update the installed repository. Inspect the +Supervisor store's exact source, version and app slug; its GitHub homepage does not identify the +branch. A different repository URL may create a separate app with separate private data. Never +claim an in-place upgrade or data migration when installing a separate preview. Preserve the +selected backup before deliberate development data replacement; do not overwrite a managed branch +to redirect an old repository. + +Edge.9 pins published runtime `93d7d77b98a196640bf0049bca36a3d90b7a5857` and archive SHA-256 +`97dc46699ad7eab54c487386ffaffc28624e8e50f9d3a2f1ffc6b525fb7b8f0e`. +The [artifact declaration](../addon-edge/artifact.json) fixes all Python and compiled UI bytes. +It retains schema 9, cold backups, Ingress and the same Core API scope; no Supervisor management +API, host mount or public port is requested. Read the +[configuration, permissions and schema-9 rollback limits](../addon-edge/DOCS.md) before adoption. +The branch repository created a separate app with separate private data. A new development +household was created explicitly; the earlier Edge.7 household/preferences were not migrated into +it. Edge.7 and its selected local app backup remain the prior rollback while the next corrected +preview is prepared. No actual backup restoration is claimed. The older Edge.3 and Edge.5 apps +were uninstalled. + +The later required-reason button correction is verified in current source and will ship in the +next preview. The installed Edge.9 remains fixed to the runtime above; a newer PR commit does not +silently replace its immutable application bytes. ## A local pre-update app backup @@ -57,7 +61,7 @@ that it is stopped before requesting the backup. Keep it stopped until the same Restart it with `ha apps start ` only if it was running beforehand, then inspect its state again. If backup fails, retain the error and restore that prior running/stopped state; do not update based only on a dispatched job. Never start a preview that was already stopped. -Edge.7 declares `backup: cold`, so Supervisor handles stopping it during a backup; expect a brief +Edge.7 and later previews declare `backup: cold`, so Supervisor handles stopping it during a backup; expect a brief availability interruption and fresh identity verification after restart. This is a package policy, not evidence of an exercised owner restore. See the [official backup configuration](https://contributors.home-assistant.io/apps/reference/configuration/#backups). @@ -74,7 +78,7 @@ original device observations remain local. A subsequent selected-app capture was successful stop and an independent `app_not_running_error` confirmation. Its job and all children completed without errors. Since the app had been running beforehand, it was started afterward; the CLI observation timed out, but a fresh app-info read confirmed Edge.5 started with its original -configuration. The start was not repeated. No owner backup was restored and no Edge.7 adoption is claimed. +configuration. The start was not repeated. No owner backup was restored in that capture. Edge.7 was subsequently adopted separately. A Supervisor app backup and a Domus console snapshot have different boundaries. The app backup captures its application/data/configuration at one time; it does not prove that later original diff --git a/docs/roadmap.md b/docs/roadmap.md index 3e57c26..f844c4c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -5,30 +5,114 @@ The following milestones are internal integration steps, not proposals to drop l | Milestone | Contract and implementation | Exit evidence | |---|---|---| -| M0 — Specification foundation | Research, vision, eleven Draft SDDs, decisions and review | Format/traceability checks; explicit unresolved items | +| M0 — Specification foundation | Research, vision, SDDs, decisions and review | Native format/traceability checks; explicit unresolved items | | M1 — Tested platform | Identity, persistence, architecture rules, CI Check and channel isolation | 100% supported coverage; restart, migration and deployment fixtures | -| M2 — Household work | Members, tasks, recurrence, calendar, points | Complete/reopen/verify/replay races and localized UI | -| M3 — Household supplies | Products, stock ledger, demand, purchase and receipt | End-to-end material and replenishment consistency | +| M2 — Household work and shared presentation | Shared presentation package and upper tab shell first; members, tasks, recurrence, calendar and points | Common component adoption; complete/reopen/verify/replay races; localized usable journeys | +| M3 — Household supplies | Products, stock ledger, demand, purchase and receipt using the common components | End-to-end material and replenishment consistency | | M4 — Automation and HA surface | One durable engine, simple/default and optional advanced editors, entities and response services across domains | Replay/restart/loop/partial-run cases plus disposable-HA contracts in both deployment targets | -| M5 — Integrated acceptance | Dashboard, responsive UX, access control, Stable/Edge packaging | Full CI Check; local Google Chrome dogfooding and recorded cleanup | +| M5 — Integrated acceptance | Complete household overview, cross-domain UX/accessibility, access control, Stable/Edge packaging | Full CI Check; local Google Chrome dogfooding and recorded cleanup | The product is not considered first-release complete at M2 or M3. -Test, user documentation and failure recovery travel with each change. -PR #3 merged the documentation foundation into develop. Issue #4 begins M1 with locked development -toolchains, tested quality policies and the project CI Check workflow. This is partial M1 evidence; -the complete identity/persistence journeys, real HA coverage and channel acceptance remain pending. -No implementation begins on an invented branch: maintainers and the Action retain their installed lifecycle. -Issue #6 now has its Action-published companion and verified branch. Its first source checkpoint adds -the tested SQLite transaction kernel, semantic ports, separate backend coverage and actual import graph. -The next checkpoint adds pure identity/permission rules, current local authorization, uniquely linked -member/policy storage, schema 2 migration and bounded session leases. HA protocol adapters now have -provider/loopback evidence. Standalone browser/session authentication and its HTTP/CLI entrypoints -now have executable offline coverage. Membership/policy and recovery use cases, manual snapshots, -business HTTP/console entrypoints and verified offline restore/resume/rollback are implemented and -tested locally and in the real Edge.3 arm64 image, including actual Edge.2 upgrade/refusal. -Edge.3 image CI passes on both CPU families and its owner update/session/reload/restart acceptance is bounded. Complete deployment restore, -product UI, live household/recovery, standalone companion/browser and full -channel/release acceptance remain M1 work. These source checkpoints do not complete M1 or reduce -first-release scope. -Release, merge and deployment require the existing maintainer flow and explicit authorization. -Future optional depth includes predictive consumption, workload recommendations, rich rewards and external calendars. +Tests, user documentation and recovery accompany each usable workflow. + +## Current state + +As of 2026-11-04, with PR #29 still under development: + +- M0: the specification foundation and decisions are recorded. Contracts continue to evolve with implementation evidence. +- M1: HA identity/session, SQLite, recovery foundations, architecture checks and CI are implemented. Full standalone/Ingress, upgrade/restore and Stable production acceptance remains open. +- M2: task creation, claiming, completion, review and reopening are usable in the owner's Edge.9 through Ingress, alongside household administration. English/Spanish and task/preference persistence were exercised after reload. The backend also has dependency/material foundations, fixed recurrence execution and relative-rule creation/storage. Initial relative materialization, the pure cycle-closure policy and closed lifecycle storage encoding have focused tests and full backend coverage. The durable relative consumer, calendar/recurrence editors, full task editors, configurable point distribution and complete participation/reward flows remain open. The pure policy and initial service are not yet wired into maintenance. +- M3: task-material foundations exist. Integrated product, stock, shopping, receipt, barcode and optional online lookup workflows remain open. +- M4: the shared automation engine, both editors and Domus-owned HA entities/services remain open. The existing gateway is already integrated; its MCP/business behavior is outside this work. +- M5: complete cross-domain overview/acceptance, both deployment journeys and Stable release acceptance remain open. Foundational UI reuse is scheduled in M2, not deferred to this final milestone. + +## Shared components, Domus-owned experience + +The owner's clarification on 2026-11-04 is a fixed product direction: Domus and Home Assistant +Gateway reuse one maintained presentation component package. The common navigation pattern is the +upper tab bar used by Gateway and current HA dashboards. Domus owns its section structure, page +composition, information hierarchy, wording and household workflows; it does not copy Gateway's +technical/MCP dashboard. + +[ha-ui issue #7](https://github.com/vypdev/ha-ui/issues/7) owns the shared component/adapter contract. +[Domus issue #30](https://github.com/vypdev/domus/issues/30) owns consumer adoption and the Domus shell. Gateway currently has local Lit primitives/layouts under frontend/src/ui, not an +independently published dependency. Reusable styles are split between application and catalog files. +The first step is therefore a coherent extraction with one source and versioned public API, followed +by actual adoption in both apps. Copying components/CSS into Domus is not completion of reuse. + +The chosen ownership/delivery direction is a dedicated vypdev/ha-ui repository, an independently +versioned @vypdev/ha-ui npm package, and its own static component Storybook on GitHub Pages. The +repository now exists, and the official web-generated Copilot installation has been reviewed and +merged into master and develop. The packages and Storybook remain unimplemented and unpublished; +publisher rights and source-to-artifact integrity must be verified before publication. The library owns its tests, API compatibility and +catalog. Both apps consume exact reviewed versions and may upgrade independently within tested +compatibility. Storybook is built from the same component implementation, with reviewable PR catalog +builds. Domus retains app-level stories for its own composed household pages. + + +The owner requires delivered React, Vue, Angular and Svelte adapter packages alongside direct Lit +and HTML/JavaScript consumption. Keep their public interaction contract on DOM properties, +attributes, slots, typed events and CSS theme tokens. Lit is the common implementation tool; +application-specific render callbacks and TemplateResult values do not enter the common API. +Each adapter has only its own framework dependencies and delegates visual/accessibility behavior +to the same core. Keep one repository, one component Storybook and a compatible version cohort. + +Before broad extraction, prove actual packed-package consumption in compiled Lit/DOM, React, Vue, +Angular and Svelte fixtures with browser and type checks. Test events, complex property updates, +slots, lifecycle, disabled/focus behavior, themes and forms integration. Document and verify the +supported version matrix and Next.js/Nuxt/SvelteKit client/import boundaries; generic Web Component +interoperability does not establish SSR or hydration acceptance. Core and adapter implementations +start with tests and complete supported coverage. Preserve Gateway's MIT source notices and asset +provenance. Package publication and adoption in each app remain separate gates. + +The shared package owns presentation tokens, type/spacing scales, tabs, buttons, fields, selects, +checkboxes, cards, rows/grids, dialogs and feedback states. Each app owns its controllers, routing, +permissions, domain models, API clients and data. Package changes must not require Gateway to run, +read parent DOM/private storage, load HA private bundles or widen authentication/API permissions. +All reviewed component code/assets are bundled for both Ingress and standalone. + +| Planned Domus section | Household purpose | +|---|---| +| Overview | First destination: today's/overdue/my/unclaimed work, upcoming maintenance, shopping/stock attention and household participation, with useful next actions | +| Tasks | Create, assign, claim, perform and review work; manage recurring definitions and their per-task policies | +| Calendar | Explore actual planned/due/performed/approval facts and recurring obligations by date; link back to the owning task | +| Shopping | Consolidated requirements, manual extras, purchases and receipt | +| Inventory | Products, quantities, locations, expiry, materials and replenishment | +| Family | Participants, contribution, configurable scoring/point allocation, balances and rewards | +| Settings | Household policy, preferences and authorized administration; detailed diagnostics remain secondary | + +These describe the complete product architecture, not seven already implemented screens. Recurrence +editing stays with Tasks and its temporal projection stays in Calendar. Points/rewards stay with +Family, with contextual links from completed work. This avoids duplicate rule or ledger ownership. +Tabs have localized accessible names and retain the common flat active indicator, keyboard/focus and +narrow-screen overflow behavior. Their content and grouping remain appropriate to Domus. + +The Overview must use authoritative, permission-scoped projections. Unavailable or incomplete data +never becomes a zero, a success badge or a household total inferred from one paginated task page. +Domain summaries and quick actions are added with the underlying workflows; no nonfunctional +placeholder is presented as a finished feature. Existing Tasks/Household entry links, private drafts, +original requests and exact revisions must survive the shell migration. + +## Immediate sequence + +1. Establish the native shared-package SDD, framework-neutral API and real Lit/DOM plus React/Vue/Angular/Svelte consumer fixtures. Then extract reusable Gateway presentation styles/behavior once, with Storybook and versioned artifact acceptance. Record ownership and dependency delivery before claiming extraction. +2. Adopt that same package for the Domus shell and existing task/administration workflows. Introduce the upper tabs and an actionable Overview as real scoped summaries become available. Test light/dark, EN/ES, narrow/wide, keyboard, reduced motion, Ingress and standalone. Do this before expanding additional bespoke domain screens. +3. Complete the durable relative lifecycle, shared maintenance budgets, reviewed recurrence changes and protected calendar using the common components. Backend work may continue while the presentation package is prepared. +4. Complete points, configurable distribution, participation and rewards in the Family experience. +5. Deliver M3 supplies, then M4 automation/HA surfaces, composing their screens from the same package. +6. Complete M5 integrated overview, usability, recovery and Stable/Edge acceptance. This verifies the established foundation; it is not a late design-system rewrite. + +Every implemented first-party component starts with tests and complete supported coverage. The shared +catalog covers normal/loading/disabled/error/empty/dialog states and both themes/locales. Real browser +journeys must preserve current commands, drafts and original recovery in both consumers. Package +publication, consumer adoption, compiled-image acceptance and required human UX are separate evidence. + +PR #29 / issue #23 retains all recurrence/calendar obligations: all 93 counted retained cases and +18 refinements remain required. The genuine schema-9 fixture and pure closure policy do not prove +the durable consumer or calendar is implemented. Household administration's remaining acceptance +inventory also remains required after its checkpoint merge. Issue #30 does not close or replace these. + +The Action owns managed remote branches and native SDD publication. Normal contributor commits use +its admitted ref; package/release/deployment work follows each owning repository's authorized flow. +Copilot remains unchanged. Future optional depth includes predictive consumption, workload +recommendations, richer rewards and external calendars. diff --git a/docs/tasks/execution.md b/docs/tasks/execution.md index 04782a1..bcf6ecf 100644 --- a/docs/tasks/execution.md +++ b/docs/tasks/execution.md @@ -210,3 +210,18 @@ delivery. Current task cycle/source results explicitly retain `consumer_delivery Existing valid sessions may read local tasks during temporary HA unavailability, subject to current local permissions and the original session expiry. Changes remain blocked until HA can reverify the actor. Explicit HA rejection removes access instead of opening an outage grace period. + + +## Task workspace access and original recovery + +GET `/api/v1/tasks/access` requires current task-read authority and returns only the canonical `household_id`, current `member_id` and server-derived `can_create` affordance. It does not widen the separate household-administration permission scope and never accepts a client-selected principal. Task detail continues to supply current `allowed_actions`. + +GET `/api/v1/tasks/requests/{action}/{key}` recovers the caller's original workspace request when its response and operation ID were lost. Supported actions are `task.create`, `task.claim`, `task.complete`, `task.verify` and `task.reopen`. Current task-read, exact own actor/action/key scope and recorded original command authority are checked before disclosure. This task-specific recovery does not require the separate operations.read browsing grant; the operation-by-ID query retains that grant. The response binds `action`, `command_key` and `operation_id`, and distinguishes pending/delivered current event evidence from suppressed historical evidence. A historical receipt does not recreate an absent task effect. + +The UI retains at most eight originals and 65,536 UTF-8 bytes for one hour in its own `tasks` journal namespace, separated by installation, channel, HA subject, household and canonical membership. It stores no credentials. A write is prepared only after storage is confirmed. Lost replies are recovered by GET; the UI does not automatically resend a POST, mint a replacement key or evict unresolved work. Clock rollback, corrupt storage, an unconfirmed write or an external storage change blocks new effects while preserving the original bytes. Expired records are pruned without refreshing identity or renewing the session deadline. + +Task forms enforce the same Unicode code-point limits as their command inputs: 160 for a title, 4,000 for a description and 1,000 for a review reason. An overlong draft stays visible with its field marked invalid and submission disabled until corrected. The native UTF-16 maxlength capacity permits astral characters without treating them as two business characters. + +The task list uses bounded page navigation, not an accumulating feed. Next page replaces the displayed page with the next at-most-25 items using its opaque cursor; Refresh tasks returns to the first page. Every navigation rechecks current identity, household/member scope and local task grants. Earlier pages are not cached or appended, so an arbitrarily large household list cannot become an unbounded private browser collection. + +Reject and reopen stay disabled until a nonblank reason is entered. Approval remains available without a reason; all three actions still enforce the shared 1,000-code-point maximum. diff --git a/docs/user-guide.md b/docs/user-guide.md index bc128ab..449bf5c 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -3,17 +3,32 @@ These complete household workflows are still under development. This guide describes the intended first integrated release. The [current HA preview](installation-home-assistant.md) describes what is available today. -Open Domus, sign in with your own Home Assistant account and use Dashboard, Tasks, Calendar, Shopping, +Open Domus with your own Home Assistant account. Ingress uses the existing HA session; standalone offers HA sign-in. The complete release will provide Dashboard, Tasks, Calendar, Shopping, Inventory, Family or Settings. English and Spanish are supported. Your role controls permitted actions, with backend checks applying even when a client presents an outdated button. During explicit setup, choose how administration maps from HA: all HA administrators by default, only the verified administrator completing setup, or an explicitly selected HA user. The default gives each proven HA administrator inherited Domus administration; ordinary users are not promoted by opening the app. An administrator configures household permission profiles and per-member exceptions. Known suspension or revoked authority still blocks access; changing a label or choosing another performer never changes the caller's permission. +## Current task workspace + +The current source increment adds a real task workspace. Published Edge.7 remains administration-only until the next verified preview package is adopted; refreshing that older app does not install this source change. + +- **Tasks** is the default workspace. Open **New task**, enter a title, optional instructions and priority, then choose **Create task**. The title-only flow creates actual work, not a schedule draft. +- Select a task to inspect it. **Claim task** assigns eligible work to your own verified membership. **Mark done now** records you as the performer at the current server-anchored time. +- Restricted performers require adult approval by default. An authorized adult can approve or reject the current cycle; rejection and reopening require a reason. Reopening preserves the previous cycle and its verification evidence. +- If a task already has linked materials, review its quantities before completion. Decimal input is converted exactly, including a comma separator, and the linked unit's precision is enforced. Zero means unused. Separate-recording tasks do not silently submit planned consumption. +- A lost reply leaves a visible saved request. **Check saved request** reads its original result without sending another change. New changes remain blocked while an earlier request is unconfirmed. Resolved browser records can be dismissed without deleting server history. +- **Household** opens identity, participants, preferences and access settings. Task IDs, revisions and resolved recovery records stay in optional details. A saved language preference applies to both workspaces. + +The workspace uses the server's current allowed actions. Read-only HA outage grace permits local queries until the original session deadline and blocks changes. A changed account or membership retires the previous private draft. + +This increment does not yet supply calendar or recurrence editors, multi-performer entry, point/reward accounting or the integrated supplies/automation/dashboard workflows. Real Inventory integration is still pending; the current task API cannot link nonempty material selections without an actual provider. Backend event recording is not proof that points, stock or downstream consumers were updated. + ## Daily work The development task API is documented in [task execution](tasks/execution.md), with [operation/recovery guidance](tasks/operator-recovery.md) and [migration limits](tasks/migration.md). -Those backend contracts do not yet supply the complete task UI or real Points/Inventory effects. +The current workspace covers the flows above. The remaining paragraphs describe the complete intended release; real Points/Inventory effects and the other task editors remain pending. Create a task with only a title, then optionally set category, priority, duration, assignment, materials, schedule, recurrence and verification. Claim available work or inspect assignments. diff --git a/frontend/playwright.entry.config.ts b/frontend/playwright.entry.config.ts index bee533a..4f1447e 100644 --- a/frontend/playwright.entry.config.ts +++ b/frontend/playwright.entry.config.ts @@ -1,7 +1,7 @@ import {defineConfig} from '@playwright/test'; export default defineConfig({ - testDir: 'tests/e2e', testMatch: ['entry.spec.ts','administration.spec.ts'], forbidOnly: true, retries: 0, workers: 1, + testDir: 'tests/e2e', testMatch: ['entry.spec.ts','administration.spec.ts','tasks.spec.ts'], forbidOnly: true, retries: 0, workers: 1, globalTimeout: 180_000, outputDir: 'test-results/entry-artifacts', reporter: [['list'], ['json', {outputFile: 'test-results/entry-results.json'}]], // Playwright normally disables BFCache. Exercise the real production page lifecycle instead. diff --git a/frontend/src/adapters/task-http.ts b/frontend/src/adapters/task-http.ts new file mode 100644 index 0000000..89576c2 --- /dev/null +++ b/frontend/src/adapters/task-http.ts @@ -0,0 +1,67 @@ +/** Same-origin task transport with bounded reads and no automatic write retry. */ +import {EntryReadError} from '../application/ports'; +import type {TaskCommandResult, TaskOriginal, TaskRequest, TaskServer} from '../application/task-ports'; +import type {JournalContext} from '../domain/journal'; +import type {TaskCommand} from '../domain/tasks'; +import {BoundedHTTP, reason} from './http-transport'; +import type {FetchTransport} from './http-transport'; +import type {EntryEndpoint} from './http-values'; +import {assertion, csrf, failure} from './http-values'; +import {boolean, check, fields, identifier} from './json-values'; +import {taskCommandBody, taskDetail, taskPage, taskReceipt, taskRequest} from './task-values'; +function route(command: TaskCommand): string { + if (command.action === 'task.create') return 'tasks/definitions'; + const action = command.action.slice(5); + return 'tasks/occurrences/' + command.occurrenceId + '/' + action; +} +export class HttpTaskServer implements TaskServer { + private readonly network: BoundedHTTP; + constructor(target: EntryEndpoint, transport: FetchTransport) {this.network = new BoundedHTTP(target, transport);} + access(expected: JournalContext) { + return this.network.owned(async signal => { + const access = fields(await this.network.get('tasks/access', expected, signal), ['household_id', 'member_id', 'can_create'], []); + return Object.freeze({householdId: identifier(access.household_id), memberId: identifier(access.member_id), canCreate: boolean(access.can_create)}); + }); + } + list(expected: JournalContext, cursor: string | null) { + return this.network.owned(async signal => { + check(cursor === null || typeof cursor === 'string' && cursor.length > 0 && cursor.length <= 512); + const query = new URLSearchParams({limit: '25'}); if (cursor !== null) query.set('cursor', cursor); + return taskPage(await this.network.get('tasks/occurrences?' + query.toString(), expected, signal)); + }); + } + detail(occurrenceId: string, householdId: string, expected: JournalContext) { + return this.network.owned(async signal => { + const id = identifier(occurrenceId), home = identifier(householdId); + return taskDetail(await this.network.get('tasks/occurrences/' + id, expected, signal), id, home); + }); + } + original(value: TaskOriginal, expected: JournalContext) { + return this.network.owned(async signal => { + const input = taskRequest(value, true); + return taskReceipt(await this.network.get('tasks/requests/' + input.command.action + '/' + input.key, expected, signal), input, true); + }); + } + async send(value: TaskRequest, expected: JournalContext): Promise { + let posted = false; + return this.network.owned(async signal => { + let input: TaskRequest; + try {input = taskRequest(value, false);} + catch {return Object.freeze({kind: 'rejected' as const, reason: 'invalid_input' as const, correlationId: null});} + let token: string; + try {assertion(expected); token = csrf(await this.network.get('auth/csrf', expected, signal));} + catch (error) {return Object.freeze({kind: 'rejected' as const, reason: error instanceof EntryReadError ? error.code : 'unavailable' as const, correlationId: null});} + try { + const body = taskCommandBody(input.command); + if (signal.aborted) throw new EntryReadError('unavailable'); + posted = true; + const reply = await this.network.request(route(input.command), 'POST', expected, body, input.key, token, signal); + if (reply.status === 200) return taskReceipt(reply.value, input, false); + if ([401,403,409,422,503].includes(reply.status)) return Object.freeze({kind: 'rejected' as const, + reason: reason(reply.status), correlationId: failure(reply.value)}); + } catch {/* An unbound or missing reply cannot establish absence of the committed effect. */} + return Object.freeze({kind: 'uncertain' as const}); + }).result.catch(() => posted ? Object.freeze({kind: 'uncertain' as const}) + : Object.freeze({kind: 'rejected' as const, reason: 'unavailable' as const, correlationId: null})); + } +} diff --git a/frontend/src/adapters/task-journal.ts b/frontend/src/adapters/task-journal.ts new file mode 100644 index 0000000..267d165 --- /dev/null +++ b/frontend/src/adapters/task-journal.ts @@ -0,0 +1,113 @@ +/** One owned session-storage key retains bounded original task requests, never credentials. */ +import {JournalStorageError} from '../application/ports'; +import type {TaskJournalPort, TaskJournalScope, TaskRecord} from '../application/task-ports'; +import {emptyJournal, JournalError, JOURNAL_LIFETIME, JOURNAL_MAX_BYTES, JOURNAL_MAX_RECORDS} from '../domain/journal'; +import {taskCommand, taskIdentifier} from '../domain/tasks'; +import type {TaskCommand} from '../domain/tasks'; +import type {OwnedStorage} from './session-journal'; +export const TASK_JOURNAL_KEY = 'domus.tasks.journal.v1'; +interface State {readonly namespace: 'tasks'; readonly version: 1; readonly observedAt: number; readonly records: readonly TaskRecord[];} +function requireValue(condition: unknown): asserts condition {if (!condition) throw new JournalError('invalid_journal');} +function object(value: unknown, keys: readonly string[]): Record { + requireValue(typeof value === 'object' && value !== null && !Array.isArray(value) + && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key))); + return value as Record; +} +function time(value: unknown): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) throw new JournalError('invalid_journal_time'); + return value; +} +function scope(value: unknown): TaskJournalScope { + const item = object(value, ['context', 'householdId', 'memberId']); + return Object.freeze({context: emptyJournal(item.context as TaskJournalScope['context'], 0).context, + householdId: taskIdentifier(item.householdId), memberId: taskIdentifier(item.memberId)}); +} +export function taskScopeKey(value: TaskJournalScope): string { + const selected = scope(value); + return JSON.stringify([selected.context.installationId, selected.context.channel, selected.context.subject, selected.householdId, selected.memberId]); +} +function record(value: unknown, observedAt: number): TaskRecord { + const item = object(value, ['key', 'command', 'scope', 'createdAt', 'expiresAt', 'stage', 'operationId']); + const command = taskCommand(item.command); requireValue(command !== undefined); + const createdAt = time(item.createdAt), expiresAt = time(item.expiresAt); + if (createdAt > observedAt || expiresAt !== createdAt + JOURNAL_LIFETIME) throw new JournalError('invalid_journal_time'); + requireValue(['prepared', 'uncertain', 'committed', 'historical', 'rejected'].includes(item.stage as string)); + const known = item.stage === 'committed' || item.stage === 'historical'; + const operationId = known ? taskIdentifier(item.operationId) : null; requireValue(known || item.operationId === null); + return Object.freeze({key: taskIdentifier(item.key), command, scope: scope(item.scope), createdAt, expiresAt, + stage: item.stage as TaskRecord['stage'], operationId}); +} +function snapshot(now: number, records: readonly TaskRecord[]): State { + return Object.freeze({namespace: 'tasks', version: 1, observedAt: time(now), records: Object.freeze(records)}); +} +function inspect(value: unknown, now: number): State { + const item = object(value, ['namespace', 'version', 'observedAt', 'records']); + requireValue(item.namespace === 'tasks' && item.version === 1); + const observedAt = time(item.observedAt); if (time(now) < observedAt) throw new JournalError('invalid_journal_time'); + requireValue(Array.isArray(item.records) && item.records.length <= JOURNAL_MAX_RECORDS); + const records = item.records.map(raw => record(raw, observedAt)); + const identities = records.map(item => JSON.stringify([taskScopeKey(item.scope), item.key])); + if (new Set(identities).size !== records.length) throw new JournalError('duplicate_request'); + return snapshot(now, records.filter(item => now < item.expiresAt)); +} +function decode(raw: string): unknown { + if (raw.length > JOURNAL_MAX_BYTES || new TextEncoder().encode(raw).byteLength > JOURNAL_MAX_BYTES) + throw new JournalError('journal_too_large'); + try {const value: unknown = JSON.parse(raw); requireValue(JSON.stringify(value) === raw); return value;} + catch {throw new JournalError('invalid_journal');} +} +export class TaskSessionJournal implements TaskJournalPort { + private active: Readonly<{scope: TaskJournalScope; state: State; encoded: string}> | null = null; + constructor(private readonly storage: OwnedStorage | undefined) {} + private read(): string | null { + try {if (this.storage === undefined) throw Error(); const raw = this.storage.getItem(TASK_JOURNAL_KEY); + if (raw !== null && typeof raw !== 'string') throw Error(); return raw; + } catch {throw new JournalStorageError('storage_unavailable');} + } + private persist(expected: string | null, state: State, selected: TaskJournalScope): readonly TaskRecord[] { + this.active = null; + if (this.read() !== expected) throw new JournalStorageError('storage_changed'); + const encoded = JSON.stringify(state); decode(encoded); + try {this.storage!.setItem(TASK_JOURNAL_KEY, encoded);} catch {throw new JournalStorageError('storage_unavailable');} + if (this.read() !== encoded) throw new JournalStorageError('storage_unconfirmed'); + this.active = {scope: selected, state, encoded}; + const key = taskScopeKey(selected); return Object.freeze(state.records.filter(item => taskScopeKey(item.scope) === key)); + } + open(value: TaskJournalScope, now: number): readonly TaskRecord[] { + this.active = null; const selected = scope(value), raw = this.read(); + return this.persist(raw, raw === null ? snapshot(now, []) : inspect(decode(raw), now), selected); + } + private change(now: number, update: (state: State, selected: TaskJournalScope) => readonly TaskRecord[]): readonly TaskRecord[] { + const prior = this.active; this.active = null; + if (prior === null) throw new JournalStorageError('journal_not_open'); + return this.persist(prior.encoded, snapshot(now, update(inspect(prior.state, now), prior.scope)), prior.scope); + } + prepare(key: string, command: TaskCommand, now: number): readonly TaskRecord[] { + return this.change(now, (state, selected) => { + if (state.records.length >= JOURNAL_MAX_RECORDS) throw new JournalError('journal_capacity'); + if (state.records.some(item => taskScopeKey(item.scope) === taskScopeKey(selected) && item.key === key)) throw new JournalError('duplicate_request'); + return [...state.records, record({key, command, scope: selected, createdAt: now, expiresAt: now + JOURNAL_LIFETIME, stage: 'prepared', operationId: null}, now)]; + }); + } + private target(state: State, selected: TaskJournalScope, key: string): TaskRecord { + const original = state.records.find(item => taskScopeKey(item.scope) === taskScopeKey(selected) && item.key === key); + if (!original) throw new JournalError('request_not_found'); return original; + } + record(key: string, stage: Exclude, operationId: string | null, now: number): readonly TaskRecord[] { + return this.change(now, (state, selected) => { + const original = this.target(state, selected, key); + requireValue(stage !== ('prepared' as string)); + const next = record({...original, stage, operationId}, now); + if (original.operationId !== null && original.operationId !== next.operationId) throw new JournalError('original_operation_changed'); + return state.records.map(item => item === original ? next : item); + }); + } + forget(key: string, now: number): readonly TaskRecord[] { + return this.change(now, (state, selected) => { + const original = this.target(state, selected, key); + if (original.stage === 'prepared' || original.stage === 'uncertain') throw new JournalError('unresolved_request'); + return state.records.filter(item => item !== original); + }); + } + detach(): void {this.active = null;} +} diff --git a/frontend/src/adapters/task-values.ts b/frontend/src/adapters/task-values.ts new file mode 100644 index 0000000..326d5a6 --- /dev/null +++ b/frontend/src/adapters/task-values.ts @@ -0,0 +1,135 @@ +/** Project the task wire contract into immutable UI facts, preserving exact integers. */ +import {nextRevision} from '../domain/administration'; +import {taskCommand, taskIdentifier, taskQuantity, taskText, TASK_PRIORITIES, TASK_STATES} from '../domain/tasks'; +import type {TaskCommand, TaskCycleSummary, TaskDetail, TaskPage, TaskReceipt, TaskSummary} from '../domain/tasks'; +import type {TaskOriginal, TaskRequest} from '../application/task-ports'; +import {boolean, check, choice, fields, identifier, invalid, responseInteger, responseRevision, text} from './json-values'; + +function literal(value: unknown, maximum: number, required = true, multiline = false): string { + try {return taskText(value, maximum, required, multiline);} catch {return invalid();} +} +function ids(value: unknown, maximum = 32): readonly string[] { + check(Array.isArray(value) && value.length <= maximum); + const result = value.map(identifier); check(new Set(result).size === result.length); return Object.freeze(result); +} +export function taskTimestamp(value: unknown): string { + try {return responseRevision(value);} catch {check(responseInteger(value) === 0); return '0';} +} +function nullableId(value: unknown): string | null {return value === null ? null : identifier(value);} +function nullableTime(value: unknown): string | null {return value === null ? null : taskTimestamp(value);} +function summary(value: unknown): TaskSummary { + const item = fields(value, ['occurrence_id', 'definition_id', 'definition_revision', 'title', 'state', 'revision', 'assignee_ids', 'due_at', 'current_cycle_id'], []); + return Object.freeze({occurrenceId: identifier(item.occurrence_id), definitionId: identifier(item.definition_id), + definitionRevision: responseRevision(item.definition_revision), title: literal(item.title, 160), state: choice(item.state, TASK_STATES), + revision: responseRevision(item.revision), assigneeIds: ids(item.assignee_ids), dueAt: nullableTime(item.due_at), currentCycleId: nullableId(item.current_cycle_id)}); +} +export function taskPage(value: unknown): TaskPage { + const item = fields(value, ['items', 'next_cursor'], []); check(Array.isArray(item.items) && item.items.length <= 25); + const items = item.items.map(summary); check(new Set(items.map(item => item.occurrenceId)).size === items.length); + return Object.freeze({items: Object.freeze(items), nextCursor: item.next_cursor === null ? null : text(item.next_cursor, 512)}); +} +function cycle(value: unknown, occurrenceId: string, householdId?: string): TaskCycleSummary { + const item = fields(value, ['cycle_id', 'household_id', 'occurrence_id', 'recorded_by', 'performers', 'rules', + 'definition_revision', 'task_policy_revision', 'identity_policy_revision', 'performed_at', 'recorded_at', 'verification', + 'verified_by', 'verified_at', 'verification_reason', 'closed_at', 'revision'], []); + check(identifier(item.occurrence_id) === occurrenceId); const household = identifier(item.household_id); + check(householdId === undefined || householdId === household); responseRevision(item.revision); + check(Array.isArray(item.performers) && item.performers.length >= 1 && item.performers.length <= 32); + const performers = item.performers.map(raw => {const performer = fields(raw, ['member_id', 'role'], []); + choice(performer.role, ['administrator', 'adult', 'restricted', 'viewer']); return identifier(performer.member_id);}); + check(new Set(performers).size === performers.length); + return Object.freeze({cycleId: identifier(item.cycle_id), verification: choice(item.verification, ['none', 'pending', 'approved', 'rejected']), + performedAt: taskTimestamp(item.performed_at), performerIds: Object.freeze(performers)}); +} +const actions = ['claim', 'complete', 'approve', 'reject', 'reopen', 'assign', 'dependencies.update', + ...TASK_STATES.map(state => 'transition:' + state)]; +export function taskDetail(value: unknown, occurrenceId: string, householdId: string): TaskDetail { + const item = fields(value, ['work', 'rules', 'task_policy_revision', 'requirements', 'cycle', 'allowed_actions', 'consumer_delivery', + 'materials', 'dependency_reviews', 'dependency_review_required'], []); + check(item.consumer_delivery === 'unconfirmed'); + const work = fields(item.work, ['occurrence_id', 'household_id', 'definition_snapshot', 'created_at', 'state', 'revision', + 'assignee_ids', 'prerequisite_ids', 'required_subtask_ids', 'due_at', 'current_cycle_id', 'current_cycle_revision', 'state_reason'], []); + check(identifier(work.occurrence_id) === occurrenceId && identifier(work.household_id) === householdId); + const definition = fields(work.definition_snapshot, ['definition_id', 'household_id', 'content', 'created_at', 'overrides', 'state', 'revision', 'materials'], []); + check(identifier(definition.household_id) === householdId); + const content = fields(definition.content, ['title', 'description', 'priority', 'tags'], []); + const rules = fields(item.rules, ['claim_allowed', 'eligible_roles', 'verification', 'adult_performer_may_approve', + 'task_scoped_material_consumption', 'material_recording', 'point_value_minor', 'dependency_gate'], []); + const base = summary({occurrence_id: work.occurrence_id, definition_id: definition.definition_id, definition_revision: definition.revision, + title: content.title, state: work.state, revision: work.revision, assignee_ids: work.assignee_ids, due_at: work.due_at, current_cycle_id: work.current_cycle_id}); + check(Array.isArray(item.allowed_actions) && item.allowed_actions.length <= actions.length); + const allowedActions = item.allowed_actions.map(raw => choice(raw, actions)); check(new Set(allowedActions).size === allowedActions.length); + check(Array.isArray(definition.materials) && definition.materials.length <= 32); + const materials = definition.materials.map(raw => { + const line = fields(raw, ['line_id', 'binding', 'planned_quantity'], []); + const binding = fields(line.binding, ['household_id', 'reference_id', 'product_id', 'unit_id', 'location_id', 'revision', 'precision', 'label'], []); + check(identifier(binding.household_id) === householdId); + let quantity: string; try {quantity = taskQuantity(line.planned_quantity);} catch {return invalid();} + const precision = responseInteger(binding.precision); check(precision <= 6 && (quantity.split('.')[1]?.length ?? 0) <= precision); + return Object.freeze({lineId: identifier(line.line_id), label: literal(binding.label, 160, false), plannedQuantity: quantity, precision}); + }); + check(new Set(materials.map(material => material.lineId)).size === materials.length); + check(Array.isArray(item.materials) && item.materials.length <= 32); + const materialStates = item.materials.map(raw => { + const material = fields(raw, ['intent_id', 'cycle_id', 'line_id', 'submitted_quantity', 'quantity', 'state', 'pending_reason', 'movement_id', + 'effect_revision', 'revision', 'message', 'action_required', 'confirmed_quantity', 'confirmed_effect_revision', 'posted_at'], []); + const state = choice(material.state, ['pending_recording', 'pending_authorization', 'pending_regularization', 'pending_processing', 'not_used', 'posted', 'corrected', 'dismissed']); + return state; + }); + const pendingMaterials = materialStates.some(state => state.startsWith('pending_')); + const current = item.cycle === null ? null : cycle(item.cycle, occurrenceId, householdId); + check(base.currentCycleId === null || current?.cycleId === base.currentCycleId); + return Object.freeze({...base, description: literal(content.description, 4000, false, true), priority: choice(content.priority, TASK_PRIORITIES), + allowedActions: Object.freeze(allowedActions), cycle: current, materials: Object.freeze(materials), + materialRecording: choice(rules.material_recording, ['review_on_completion', 'separate_recording']), + dependencyReviewRequired: boolean(item.dependency_review_required), pendingMaterials}); +} +export function taskReceipt(value: unknown, request: TaskRequest | TaskOriginal, lookup: boolean): TaskReceipt { + const item = fields(value, ['state', 'archived_by', 'operation_id', 'created_at', 'result', 'message', 'action_required', + ...lookup ? ['action', 'command_key', 'delivery'] : []], []); + text(item.message, 2048); text(item.action_required, 4096); taskTimestamp(item.created_at); + const stage = choice(item.state, ['committed', 'historical']), archivedBy = nullableId(item.archived_by), operationId = identifier(item.operation_id); + check(stage !== 'historical' || archivedBy !== null); + if (lookup) { + check(item.action === request.command.action && item.command_key === request.key); + const known = (request as TaskOriginal).operationId; check(known === null || known === operationId); + const delivery = choice(item.delivery, ['pending', 'delivered', 'suppressed']); + check(stage === 'historical' ? delivery === 'suppressed' : delivery !== 'suppressed'); + } + const result = fields(item.result, ['definition_id', 'definition_revision', 'occurrence_id', 'revision', 'state', 'assignee_ids', 'state_reason', 'prerequisite_ids', 'required_subtask_ids'], + ['cycle', 'consumer_delivery', 'materials', 'compensates_cycle_id', 'dependency_review_required']); + const occurrenceId = identifier(result.occurrence_id), revision = responseRevision(result.revision), state = choice(result.state, TASK_STATES); + identifier(result.definition_id); responseRevision(result.definition_revision); ids(result.assignee_ids); ids(result.prerequisite_ids, 64); ids(result.required_subtask_ids, 64); + const command = request.command; + check(revision === (command.action === 'task.create' ? '1' : nextRevision(command.payload.expected_revision))); + if (command.action !== 'task.create') check(command.occurrenceId === occurrenceId); + const savedCycle = Object.hasOwn(result, 'cycle') ? cycle(result.cycle, occurrenceId) : null; + if (command.action === 'task.complete') { + check(state === 'completed' && savedCycle !== null && savedCycle.performedAt === command.payload.performed_at + && JSON.stringify([...savedCycle.performerIds].sort()) === JSON.stringify([...command.payload.performer_ids].sort())); + } + if (command.action === 'task.verify' || command.action === 'task.reopen') { + check(savedCycle?.cycleId === command.payload.cycle_id); + if (command.action === 'task.verify') check(savedCycle.verification === command.payload.decision); + else check(result.compensates_cycle_id === command.payload.cycle_id && result.dependency_review_required === true); + } + return Object.freeze({kind: 'receipt', stage, action: command.action, operationId, occurrenceId, revision, state, cycle: savedCycle, archivedBy}); +} +/** Only validated decimal lexemes bypass JSON's string quoting; arbitrary text always uses JSON.stringify. */ +export function taskCommandBody(value: TaskCommand): string { + const command = taskCommand(value); check(command !== undefined); + if (command.action === 'task.complete') { + const {expected_revision, performed_at, ...rest} = command.payload; + return '{"expected_revision":' + expected_revision + ',"performed_at":' + performed_at + ',' + JSON.stringify(rest).slice(1); + } + const {expected_revision, ...rest} = command.payload; + return '{"expected_revision":' + expected_revision + (Object.keys(rest).length ? ',' + JSON.stringify(rest).slice(1) : '}'); +} +export function taskRequest(value: unknown, lookup: false): TaskRequest; +export function taskRequest(value: unknown, lookup: true): TaskOriginal; +export function taskRequest(value: unknown, lookup: boolean): TaskRequest | TaskOriginal { + const item = fields(value, lookup ? ['key', 'command', 'operationId'] : ['key', 'command'], []); + const key = identifier(item.key), command = taskCommand(item.command); check(command !== undefined); + if (!lookup) return Object.freeze({key, command}); + return Object.freeze({key, command, operationId: item.operationId === null ? null : taskIdentifier(item.operationId)}); +} diff --git a/frontend/src/application/household-controller.ts b/frontend/src/application/household-controller.ts index e064a63..b628e10 100644 --- a/frontend/src/application/household-controller.ts +++ b/frontend/src/application/household-controller.ts @@ -1,6 +1,7 @@ /** Coordinate verified application views; concrete document resources and command authority stay with their owners. */ import type {EntryController, EntryNotice, EntryView} from './entry-controller'; import type {AdministrationController, AdministrationView} from './administration-controller'; +import type {TaskController, TaskView} from './task-controller'; import type {JournalContext} from '../domain/journal'; function sameContext(left: JournalContext, right: JournalContext): boolean { @@ -13,28 +14,45 @@ function sameGrants(left: readonly string[], right: readonly string[]): boolean export class HouseholdController { private current: AdministrationController | null = null; private currentContext: JournalContext | null = null; + private taskCurrent: TaskController | null = null; private visible = false; private running = false; private closed = false; private generation = 0; private notice: EntryNotice | null = null; - constructor(private readonly entry: EntryController, private readonly administrationFactory: () => AdministrationController) {} + constructor(private readonly entry: EntryController, private readonly administrationFactory: () => AdministrationController, + private readonly taskFactory?: () => TaskController) {} get view(): EntryView { const view = this.entry.view; - return Object.freeze({...view, busy: this.running || view.busy || this.current?.view.busy === true, + return Object.freeze({...view, busy: this.running || view.busy || this.current?.view.busy === true || this.taskCurrent?.view.busy === true, notice: this.notice ?? view.notice}); } get administration(): AdministrationView | null {return this.visible ? this.current!.view : null;} + get tasks(): TaskView | null { + if (!this.visible || !this.eligible() || this.taskCurrent === null) return null; + const view = this.taskCurrent.view, entry = this.entry.view, administration = this.current!.view; + if (view.session === null) return view; + if (!sameContext(view.session.context, entry.session!.context) || view.session.access !== entry.session!.access) return null; + if (view.access !== null && administration.access?.member != null + && (view.access.memberId !== administration.access.member.memberId)) return null; + const home = administration.capabilities?.householdId ?? entry.household?.householdId; + if (view.access !== null && home !== undefined && home !== view.access.householdId) return null; + return view; + } /** Scheduling needs only absolute deadlines, never a command payload or a renewed proof. */ get deadlines(): readonly number[] { - return Object.freeze([this.entry.view, this.current?.view].flatMap(view => view === undefined ? [] : [ + const task = this.taskCurrent?.view; + return Object.freeze([...task?.session ? [task.session.expiresAt] : [], + ...task?.records?.map(record => record.expiresAt) ?? [], + ...[this.entry.view, this.current?.view].flatMap(view => view === undefined ? [] : [ ...view.session === null ? [] : [view.session.expiresAt], ...view.journal?.records.map(record => record.expiresAt) ?? [], - ])); + ])]); } private live(generation: number): boolean {return !this.closed && generation === this.generation;} private retire(): void { this.visible = false; this.current?.close(); this.current = null; this.currentContext = null; + this.taskCurrent?.close(); this.taskCurrent = null; } private eligible(): boolean { const view = this.entry.view; @@ -57,10 +75,11 @@ export class HouseholdController { /** No tick obtains new proof, extends a lease, sends a command or queries an original operation. */ tick(): void { if (this.closed) return; - this.entry.tick(); this.current?.tick(); + this.entry.tick(); this.current?.tick(); this.taskCurrent?.tick(); const entry = this.entry.view, administration = this.current?.view; if (entry.decision?.state === 'authentication_required' - || (administration?.session === null && administration.notice === 'authentication_required')) { + || (administration?.session === null && administration.notice === 'authentication_required') + || this.taskCurrent?.view.notice === 'authentication_required') { this.generation++; this.notice = null; this.entry.expire(); this.retire(); return; } if (entry.phase === 'ready' && entry.decision?.state !== 'active') {this.notice = null; this.retire(); return;} @@ -87,11 +106,19 @@ export class HouseholdController { if (this.matched()) this.visible = true; else {this.retire(); this.notice = 'context_changed';} } - private async perform(work: (generation: number) => Promise): Promise { + private async perform(work: (generation: number) => Promise, refreshTasks = true): Promise { if (this.closed || this.view.busy) return; this.running = true; this.visible = false; this.notice = null; const generation = ++this.generation; - try {await work(generation);} finally {this.running = false;} + try { + await work(generation); + if (refreshTasks && this.live(generation) && this.visible && this.eligible() && this.taskFactory !== undefined) { + this.taskCurrent ??= this.taskFactory(); + await this.taskCurrent.refresh(); + if (this.live(generation) && this.tasks === null) {this.visible = false; this.notice = 'context_changed';} + this.tick(); + } + } finally {this.running = false;} } refresh(): Promise { return this.perform(async generation => { @@ -126,6 +153,19 @@ export class HouseholdController { submitAdministration(value: unknown): Promise {return this.administrationWork(controller => controller.submit(value));} recoverAdministration(key: string): Promise {return this.administrationWork(controller => controller.recover(key));} dismissAdministration(key: string): Promise {return this.administrationWork(controller => controller.dismiss(key));} + taskWork(work: (controller: TaskController) => Promise): Promise { + const current = this.taskCurrent; + if (current === null || this.tasks === null) return Promise.resolve(); + return this.perform(async generation => { + await work(current); + if (!this.live(generation)) return; + await Promise.all([this.entry.refresh(), this.current!.reconcile()]); + await this.synchronize(generation, true); + if (this.live(generation) && this.taskCurrent !== null && this.tasks === null) { + this.visible = false; this.notice = 'context_changed'; + } + }, false); + } async logout(): Promise { if (this.closed || this.view.busy) return; this.closed = true; this.generation++; this.notice = null; this.retire(); diff --git a/frontend/src/application/task-controller.ts b/frontend/src/application/task-controller.ts new file mode 100644 index 0000000..6cd15c1 --- /dev/null +++ b/frontend/src/application/task-controller.ts @@ -0,0 +1,213 @@ +/** Task workflows coordinate current identity, durable original intents and server-owned actions. */ +import {JournalError} from '../domain/journal'; +import {taskCommand, taskIdentifier} from '../domain/tasks'; +import type {TaskAccess, TaskCommand, TaskDetail, TaskMaterialUsage, TaskPage, TaskPriority} from '../domain/tasks'; +import {EntryReadError} from './ports'; +import type {Deployment} from '../domain/entry'; +import type {EntryClock, EntryEntropy, EntryRead, EntryServer, VerifiedEntrySession} from './ports'; +import type {TaskCommandResult, TaskJournalPort, TaskJournalScope, TaskRecord, TaskServer} from './task-ports'; + +export type TaskNotice = 'authentication_required' | 'forbidden' | 'unavailable' | 'invalid_response' + | 'invalid_input' | 'conflict' | 'read_only' | 'journal_unavailable' | 'context_changed' + | 'journal_capacity' | 'previous_request_unresolved' | 'action_not_allowed' | 'request_changed' | 'request_unavailable'; +interface State { + readonly phase: 'idle' | 'loading' | 'ready' | 'closed'; + readonly session: VerifiedEntrySession | null; readonly access: TaskAccess | null; + readonly scopeKey: string | null; readonly readOnly: boolean; + readonly page: TaskPage | null; readonly detail: TaskDetail | null; + readonly records: readonly TaskRecord[] | null; readonly outcome: TaskCommandResult | null; readonly notice: TaskNotice | null; +} +export type TaskView = Readonly; +function blank(phase: State['phase']): State { + return Object.freeze({phase, session: null, access: null, scopeKey: null, readOnly: true, + page: null, detail: null, records: null, outcome: null, notice: null}); +} +function scope(session: VerifiedEntrySession, access: TaskAccess): TaskJournalScope { + return Object.freeze({context: session.context, householdId: access.householdId, memberId: access.memberId}); +} +function scopeKey(selected: TaskJournalScope): string { + const {context} = selected; + return JSON.stringify([context.installationId, context.channel, context.subject, selected.householdId, selected.memberId]); +} +const retired = Symbol('retired task request'); +export class TaskController { + private state: State = blank('idle'); + private generation = 0; + private running = false; + private closed = false; + private readonly reads = new Set>(); + constructor(private readonly deployment: Deployment, private readonly identity: Pick, + private readonly server: TaskServer, private readonly journal: TaskJournalPort, + private readonly clock: EntryClock, private readonly entropy: EntryEntropy) {} + get view(): TaskView {return Object.freeze({...this.state, busy: this.running});} + private update(patch: Partial): void {this.state = Object.freeze({...this.state, ...patch});} + private invalidate(): number { + this.generation++; const owned = [...this.reads]; this.reads.clear(); + for (const read of owned) {try {read.cancel();} catch {/* Retired callbacks remain fenced. */}} + this.journal.detach(); return this.generation; + } + private async read(operation: EntryRead, generation: number): Promise { + this.reads.add(operation); + try { + const value = await operation.result; + if (this.closed || generation !== this.generation) throw retired; + if (this.state.session !== null && this.clock.now() >= this.state.session.expiresAt) + throw new EntryReadError('authentication_required'); + return value; + } finally {this.reads.delete(operation);} + } + private async fresh(generation: number): Promise { + const source = await this.read(this.identity.session(), generation); + if (source === null) throw new EntryReadError('authentication_required'); + if (source.deployment !== this.deployment) throw new EntryReadError('invalid_response'); + this.clock.anchor(source.serverTime); + if (this.clock.now() >= source.expiresAt) throw new EntryReadError('authentication_required'); + if (!source.householdApiAvailable) throw new EntryReadError('unavailable'); + const session = Object.freeze({...source, context: Object.freeze({...source.context})}); + this.update({session, readOnly: source.access !== 'full'}); + const current = await this.read(this.server.access(session.context), generation); + const access = Object.freeze({...current}), selected = scope(session, access); + let records: readonly TaskRecord[] | null = null; + try {records = this.journal.open(selected, this.clock.now());} catch {/* Reads remain available; writes require confirmed storage. */} + this.update({access, scopeKey: scopeKey(selected), records, notice: records === null ? 'journal_unavailable' : null}); + } + private async run(work: (prior: State, generation: number) => Promise): Promise { + if (this.closed || this.running) return; + const prior = this.state, generation = this.invalidate(); this.running = true; this.state = blank('loading'); + try {await this.fresh(generation); await work(prior, generation);} + catch (error) { + if (generation !== this.generation || this.closed || error === retired) return; + const notice = error instanceof EntryReadError ? error.code : 'unavailable'; + if (notice === 'authentication_required' || notice === 'forbidden') this.state = Object.freeze({...blank('ready'), notice}); + else this.update({notice}); + } finally { + if (generation === this.generation && !this.closed) {this.running = false; this.update({phase: 'ready'});} + } + } + private async facts(generation: number, selected: string | null, cursor: string | null = null): Promise { + const session = this.state.session!, access = this.state.access!; + const page = await this.read(this.server.list(session.context, cursor), generation); this.update({page}); + if (selected !== null) this.update({detail: await this.read(this.server.detail(selected, access.householdId, session.context), generation)}); + } + refresh(): Promise { + return this.run(async (prior, generation) => { + const same = this.state.scopeKey === prior.scopeKey; + if (same) this.update({outcome: prior.outcome}); + await this.facts(generation, same ? prior.detail?.occurrenceId ?? null : null); + }); + } + more(): Promise { + const cursor = this.state.page?.nextCursor; + if (!cursor) return Promise.resolve(); + return this.run(async (prior, generation) => { + if (this.state.scopeKey !== prior.scopeKey) {this.update({notice: 'context_changed'}); return;} + await this.facts(generation, null, cursor); + }); + } + select(value: unknown): Promise { + let id: string; try {id = taskIdentifier(value);} catch {this.update({notice: 'invalid_input'}); return Promise.resolve();} + return this.run(async (prior, generation) => { + if (this.state.scopeKey !== prior.scopeKey) {this.update({notice: 'context_changed'}); return;} + const detail = await this.read(this.server.detail(id, this.state.access!.householdId, this.state.session!.context), generation); + this.update({page: prior.page, detail, outcome: prior.outcome}); + }); + } + private accepted(command: TaskCommand): boolean { + if (command.action === 'task.create') return this.state.access!.canCreate; + const detail = this.state.detail!; + if (detail.occurrenceId !== command.occurrenceId) return false; + if (detail.revision !== command.payload.expected_revision) {this.update({notice: 'conflict'}); return false;} + const action = command.action === 'task.verify' ? command.payload.decision === 'approved' ? 'approve' : 'reject' : command.action.slice(5); + return detail.allowedActions.includes(action); + } + private save(key: string, outcome: TaskCommandResult): void { + const stage = outcome.kind === 'receipt' ? outcome.stage : outcome.kind; + try {this.update({records: this.journal.record(key, stage, outcome.kind === 'receipt' ? outcome.operationId : null, this.clock.now())});} + catch {this.update({notice: 'journal_unavailable'});} + this.update({outcome}); + } + private execute(value: unknown): Promise { + if (this.closed || this.running || this.state.access === null) return Promise.resolve(); + const command = taskCommand(value); + if (command === undefined) {this.update({notice: 'invalid_input'}); return Promise.resolve();} + return this.run(async (prior, generation) => { + if (this.state.scopeKey !== prior.scopeKey) {this.update({notice: 'context_changed'}); return;} + this.update({page: prior.page}); + if (this.state.readOnly) {this.update({notice: 'read_only'}); return;} + if (command.action !== 'task.create') this.update({detail: await this.read(this.server.detail(command.occurrenceId, this.state.access!.householdId, this.state.session!.context), generation)}); + if (!this.accepted(command)) {if (this.state.notice !== 'conflict') this.update({notice: 'action_not_allowed'}); return;} + if (this.state.records === null) {this.update({notice: 'journal_unavailable'}); return;} + if (this.state.records.some(item => item.stage === 'prepared' || item.stage === 'uncertain')) {this.update({notice: 'previous_request_unresolved'}); return;} + let key: string; + try {key = this.entropy.requestKey(); this.update({records: this.journal.prepare(key, command, this.clock.now())});} + catch (error) {this.update({notice: error instanceof JournalError && (error.code === 'journal_capacity' || error.code === 'journal_too_large') + ? 'journal_capacity' : 'journal_unavailable'}); return;} + const outcome = await this.server.send({key, command}, this.state.session!.context); + if (this.closed || generation !== this.generation) return; + this.save(key, outcome); + if (outcome.kind === 'rejected' && (outcome.reason === 'authentication_required' || outcome.reason === 'forbidden')) { + this.state = Object.freeze({...blank('ready'), notice: outcome.reason}); return; + } + if (this.clock.now() >= this.state.session!.expiresAt) throw new EntryReadError('authentication_required'); + if (outcome.kind === 'receipt') await this.facts(generation, outcome.occurrenceId); + }); + } + create(title: unknown, description: unknown, priority: TaskPriority): Promise { + return this.execute({action: 'task.create', payload: {expected_revision: 0, title, description, priority}}); + } + claim(): Promise { + const work = this.state.detail; + return work === null ? Promise.resolve() : this.execute({action: 'task.claim', occurrenceId: work.occurrenceId, payload: {expected_revision: work.revision}}); + } + complete(materialUsages: readonly TaskMaterialUsage[] | null): Promise { + const work = this.state.detail, member = this.state.access?.memberId; + if (work === null || member === undefined) return Promise.resolve(); + let performedAt: string; try {performedAt = String(this.clock.now());} catch {this.update({notice: 'unavailable'}); return Promise.resolve();} + return this.execute({action: 'task.complete', occurrenceId: work.occurrenceId, payload: {expected_revision: work.revision, + performer_ids: [member], performed_at: performedAt, material_usages: materialUsages}}); + } + verify(decision: 'approved' | 'rejected', reason: unknown): Promise { + const work = this.state.detail; + return work?.cycle == null ? Promise.resolve() : this.execute({action: 'task.verify', occurrenceId: work.occurrenceId, + payload: {expected_revision: work.revision, cycle_id: work.cycle.cycleId, decision, reason}}); + } + reopen(reason: unknown): Promise { + const work = this.state.detail; + return work?.cycle == null ? Promise.resolve() : this.execute({action: 'task.reopen', occurrenceId: work.occurrenceId, + payload: {expected_revision: work.revision, cycle_id: work.cycle.cycleId, reason}}); + } + recover(key: string): Promise { + const original = this.state.records?.find(item => item.key === key); + if (!original) return Promise.resolve(); + return this.run(async (prior, generation) => { + if (this.state.scopeKey !== prior.scopeKey) {this.update({notice: 'context_changed'}); return;} + const current = this.state.records?.find(item => item.key === key); + if (current === undefined || JSON.stringify(current) !== JSON.stringify(original)) {this.update({notice: 'request_changed'}); return;} + this.update({page: prior.page}); + const outcome = await this.read(this.server.original({key, command: current.command, operationId: current.operationId}, this.state.session!.context), generation); + this.save(key, outcome); await this.facts(generation, outcome.occurrenceId); + }); + } + dismiss(key: string): Promise { + return this.run(async prior => { + if (this.state.scopeKey !== prior.scopeKey) {this.update({notice: 'context_changed'}); return;} + try {this.update({records: this.journal.forget(key, this.clock.now()), page: prior.page, detail: prior.detail});} + catch {this.update({notice: 'request_unavailable'});} + }); + } + tick(): void { + if (this.closed || this.state.session === null) return; + try { + const now = this.clock.now(); + if (now < this.state.session.expiresAt) { + if (this.state.records?.some(record => now >= record.expiresAt)) { + try {this.update({records: this.journal.open(scope(this.state.session, this.state.access!), now)});} + catch {this.journal.detach(); this.update({records: null, notice: 'journal_unavailable'});} + } + return; + } + } catch {/* Unknown time cannot extend the lease. */} + this.invalidate(); this.running = false; this.state = Object.freeze({...blank('ready'), notice: 'authentication_required'}); + } + close(): void {this.closed = true; this.invalidate(); this.running = false; this.state = blank('closed');} +} diff --git a/frontend/src/application/task-ports.ts b/frontend/src/application/task-ports.ts new file mode 100644 index 0000000..4e3468a --- /dev/null +++ b/frontend/src/application/task-ports.ts @@ -0,0 +1,30 @@ +import type {TaskAccess, TaskCommand, TaskDetail, TaskPage, TaskReceipt} from '../domain/tasks'; +import type {JournalContext} from '../domain/journal'; +import type {CommandResult, EntryRead} from './ports'; + +export interface TaskRequest {readonly key: string; readonly command: TaskCommand;} +export interface TaskOriginal extends TaskRequest {readonly operationId: string | null;} +export type TaskCommandResult = TaskReceipt | Exclude; +export interface TaskServer { + access(expected: JournalContext): EntryRead; + list(expected: JournalContext, cursor: string | null): EntryRead; + detail(occurrenceId: string, householdId: string, expected: JournalContext): EntryRead; + original(request: TaskOriginal, expected: JournalContext): EntryRead; + send(request: TaskRequest, expected: JournalContext): Promise; +} + +export interface TaskJournalScope { + readonly context: JournalContext; readonly householdId: string; readonly memberId: string; +} +export interface TaskRecord extends TaskRequest { + readonly scope: TaskJournalScope; readonly createdAt: number; readonly expiresAt: number; + readonly stage: 'prepared' | 'uncertain' | 'committed' | 'historical' | 'rejected'; + readonly operationId: string | null; +} +export interface TaskJournalPort { + open(scope: TaskJournalScope, now: number): readonly TaskRecord[]; + prepare(key: string, command: TaskCommand, now: number): readonly TaskRecord[]; + record(key: string, stage: Exclude, operationId: string | null, now: number): readonly TaskRecord[]; + forget(key: string, now: number): readonly TaskRecord[]; + detach(): void; +} diff --git a/frontend/src/composition/entry-page.ts b/frontend/src/composition/entry-page.ts index 217245e..dbcd7ed 100644 --- a/frontend/src/composition/entry-page.ts +++ b/frontend/src/composition/entry-page.ts @@ -6,6 +6,9 @@ import {EntryRuntime} from './entry-runtime'; import type {EntryBrowser} from './entry-runtime'; import {language, message} from '../presentation/messages'; import type {DomusApp, EntryIntent} from '../presentation/domus-app'; +import type {TaskIntent} from '../presentation/domus-tasks'; +import {taskQuantity, TASK_PRIORITIES} from '../domain/tasks'; +import type {TaskPriority} from '../domain/tasks'; import type {AdministrationIntent} from '../presentation/domus-administration'; import {administrationCommand} from '../domain/administration-commands'; import '../presentation/domus-app'; @@ -56,6 +59,38 @@ export function decodeAdministrationIntent(value:unknown):AdministrationIntent|n }catch{return null;} } +/** Task events contain business inputs only, never a principal, URL, context or raw transport request. */ +export function decodeTaskIntent(value: unknown): TaskIntent | null { + try { + const item = fields(value, ['type'], ['title', 'description', 'priority', 'occurrenceId', 'materialUsages', 'decision', 'reason', 'key']); + const type = item.type; + if (type === 'create') { + fields(value, ['type', 'title', 'description', 'priority'], []); + check(typeof item.title === 'string' && typeof item.description === 'string' && typeof item.priority === 'string' + && TASK_PRIORITIES.includes(item.priority as TaskPriority)); + return {type, title: item.title, description: item.description, priority: item.priority as TaskPriority}; + } + if (type === 'complete') { + fields(value, ['type', 'materialUsages'], []); + check(item.materialUsages === null || Array.isArray(item.materialUsages) && item.materialUsages.length <= 32); + const materialUsages = item.materialUsages === null ? null : Object.freeze((item.materialUsages as unknown[]).map(raw => { + const usage = fields(raw, ['line_id', 'quantity'], []); + return Object.freeze({line_id: identifier(usage.line_id), quantity: taskQuantity(usage.quantity)}); + })); + return {type, materialUsages}; + } + if (type === 'verify' || type === 'reopen') { + fields(value, type === 'verify' ? ['type', 'decision', 'reason'] : ['type', 'reason'], []); + check(typeof item.reason === 'string'); + if (type === 'reopen') return {type, reason: item.reason}; + check(item.decision === 'approved' || item.decision === 'rejected'); return {type, decision: item.decision, reason: item.reason}; + } + if (type === 'select') {fields(value, ['type', 'occurrenceId'], []); return {type, occurrenceId: identifier(item.occurrenceId)};} + if (type === 'recover' || type === 'dismiss') {fields(value, ['type', 'key'], []); return {type, key: identifier(item.key)};} + fields(value, ['type'], []); check(type === 'refresh' || type === 'more' || type === 'claim'); return {type}; + } catch {return null;} +} + /** Wire only this document's owned nodes/capabilities; never inspect the HA parent or private storage. */ export function mountEntry(page: EntryPage): PageMount { const doc = page.document; mounts.get(doc)?.(); @@ -93,12 +128,14 @@ export function mountEntry(page: EntryPage): PageMount { const element = doc.createElement('domus-app'); app = element; element.language = language(null, page.navigator.language); element.deployment = source.target.deployment; doc.documentElement.lang = element.language; + const section = () => {element.section = new URL(page.location.href).hash === '#household' ? 'household' : 'tasks';}; + section(); page.addEventListener('hashchange', section, {signal: widget.signal}); const browser: EntryBrowser = {transport: (path, init) => page.fetch(path, init), storage: () => page.sessionStorage, elapsed: () => page.performance.now(), random: bytes => page.crypto.getRandomValues(bytes), navigate: path => page.location.assign(path), events: page, visibility: doc, schedule: (tick, delay) => {const id = page.setTimeout(tick, delay); return () => page.clearTimeout(id);}}; - const active = new EntryRuntime(source.target, browser, (view,administration) => { - element.view=view;element.administration=administration; + const active = new EntryRuntime(source.target, browser, (view,administration,tasks) => { + element.view=view;element.administration=administration;element.tasks=tasks; const context=administration?.session?.context,member=administration?.access?.member; const contextKey=context?JSON.stringify([context.installationId,context.channel,context.subject]):null; const owner=contextKey!==null&&administration?.access?.state==='active'&&member?JSON.stringify([contextKey,member.memberId]):null; @@ -162,6 +199,24 @@ export function mountEntry(page: EntryPage): PageMount { } }catch{fail();} },{signal:widget.signal}); + element.addEventListener('domus-task-intent', async event => { + if (event.target !== element) return; + try { + const intent = decodeTaskIntent((event as CustomEvent).detail); if (intent === null) return; + switch (intent.type) { + case 'refresh': await active.refresh(); break; + case 'more': await active.taskWork(controller => controller.more()); break; + case 'select': await active.taskWork(controller => controller.select(intent.occurrenceId)); break; + case 'create': await active.taskWork(controller => controller.create(intent.title, intent.description, intent.priority)); break; + case 'claim': await active.taskWork(controller => controller.claim()); break; + case 'complete': await active.taskWork(controller => controller.complete(intent.materialUsages)); break; + case 'verify': await active.taskWork(controller => controller.verify(intent.decision, intent.reason)); break; + case 'reopen': await active.taskWork(controller => controller.reopen(intent.reason)); break; + case 'recover': await active.taskWork(controller => controller.recover(intent.key)); break; + case 'dismiss': await active.taskWork(controller => controller.dismiss(intent.key)); break; + } + } catch {fail();} + }, {signal: widget.signal}); roots[0].replaceChildren(element); ready = active.start().catch(() => {fail();}); } catch {fail(); ready = Promise.resolve();} diff --git a/frontend/src/composition/entry-runtime.ts b/frontend/src/composition/entry-runtime.ts index 011a5bd..f259ee1 100644 --- a/frontend/src/composition/entry-runtime.ts +++ b/frontend/src/composition/entry-runtime.ts @@ -1,5 +1,9 @@ import {EntryController} from '../application/entry-controller'; import type {EntryView} from '../application/entry-controller'; +import {TaskController} from '../application/task-controller'; +import type {TaskView} from '../application/task-controller'; +import {TaskSessionJournal} from '../adapters/task-journal'; +import {HttpTaskServer} from '../adapters/task-http'; import {HouseholdController} from '../application/household-controller'; import {AdministrationController} from '../application/administration-controller'; import type {AdministrationView} from '../application/administration-controller'; @@ -39,7 +43,7 @@ export class EntryRuntime { private timerEpoch = 0; private cancelTimer: (() => void) | null = null; constructor(target: EntryEndpoint, private readonly browser: EntryBrowser, - private readonly changed: (view: EntryView, administration: AdministrationView | null) => void, + private readonly changed: (view: EntryView, administration: AdministrationView | null, tasks: TaskView | null) => void, private readonly failed: () => void = () => {}) { this.target = endpoint(target); this.navigation = new BrowserNavigation(this.target, path => browser.navigate(path)); this.current = this.compose(); @@ -53,12 +57,15 @@ export class EntryRuntime { const entropy = new BrowserEntropy(bytes => this.browser.random(bytes)); const entry = new EntryController(this.target.deployment, server, new SessionJournal(storage), clock, entropy); return {clock, controller: new HouseholdController(entry, () => new AdministrationController(this.target.deployment, - server, new HttpAdministrationServer(this.target, this.browser.transport), new AdministrationSessionJournal(storage), clock, entropy))}; + server, new HttpAdministrationServer(this.target, this.browser.transport), new AdministrationSessionJournal(storage), clock, entropy), () => new TaskController(this.target.deployment, + server, new HttpTaskServer(this.target, this.browser.transport), new TaskSessionJournal(storage), clock, entropy))}; } get view(): EntryView {return this.current.controller.view;} get administration(): AdministrationView | null {return this.current.controller.administration;} + get tasks(): TaskView | null {return this.current.controller.tasks;} + taskWork(work: (controller: TaskController) => Promise): Promise {return this.perform(controller => controller.taskWork(work));} private publish(): void { - try {this.changed(this.view, this.administration);} catch {/* A renderer failure cannot change authority or interrupt resource cleanup. */} + try {this.changed(this.view, this.administration, this.tasks);} catch {/* A renderer failure cannot change authority or interrupt resource cleanup. */} } private stopTimer(): void { this.timerEpoch++; const cancel = this.cancelTimer; this.cancelTimer = null; diff --git a/frontend/src/domain/tasks.ts b/frontend/src/domain/tasks.ts new file mode 100644 index 0000000..31d97a0 --- /dev/null +++ b/frontend/src/domain/tasks.ts @@ -0,0 +1,126 @@ +/** Pure task intents preserve original user data; validation never grants authority. */ +import {revisionValue} from './administration'; +import type {Revision} from './administration'; + +export const TASK_TEXT_LIMITS = Object.freeze({title: 160, description: 4000, reason: 1000}); + +export class TaskInputError extends Error { + constructor() {super('invalid_task_input'); this.name = 'TaskInputError';} +} +function requireValue(condition: unknown): asserts condition {if (!condition) throw new TaskInputError();} +export function taskIdentifier(value: unknown): string { + requireValue(typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/u.test(value)); return value; +} +export function taskText(value: unknown, maximum: number, required: boolean, multiline = false): string { + requireValue(typeof value === 'string' && Array.from(value).length <= maximum + && (!required || !/^\p{White_Space}*$/u.test(value)) + && !/[\uD800-\uDFFF\u007F]/u.test(value) + && !(multiline ? /[\u0000-\u0008\u000B\u000C\u000E-\u001F]/u : /[\u0000-\u001F]/u).test(value)); + return value; +} +export function taskTime(value: unknown): string { + requireValue(typeof value === 'string' && value.length <= 19 && /^(?:0|[1-9][0-9]*)$/u.test(value) + && BigInt(value) <= 9_223_372_036_854_775_807n); return value; +} +export function taskQuantity(value: unknown): string { + requireValue(typeof value === 'string' && /^(?:0|[1-9][0-9]{0,11})(?:\.[0-9]{0,5}[1-9])?$/u.test(value)); return value; +} +export interface TaskMaterialUsage {readonly line_id: string; readonly quantity: string;} +export const TASK_STATES = ['draft', 'available', 'assigned', 'in_progress', 'blocked', 'completed', 'cancelled', 'skipped'] as const; +export type TaskState = typeof TASK_STATES[number]; +export const TASK_PRIORITIES = ['low', 'normal', 'high', 'urgent'] as const; +export type TaskPriority = typeof TASK_PRIORITIES[number]; +export type TaskCommand = + Readonly<{action: 'task.create'; payload: Readonly<{expected_revision: 0; title: string; description: string; priority: TaskPriority}>}> + | Readonly<{action: 'task.claim'; occurrenceId: string; payload: Readonly<{expected_revision: Revision}>}> + | Readonly<{action: 'task.complete'; occurrenceId: string; payload: Readonly<{expected_revision: Revision; + performer_ids: readonly string[]; performed_at: string; material_usages: readonly TaskMaterialUsage[] | null}>}> + | Readonly<{action: 'task.verify'; occurrenceId: string; payload: Readonly<{expected_revision: Revision; + cycle_id: string; decision: 'approved' | 'rejected'; reason: string}>}> + | Readonly<{action: 'task.reopen'; occurrenceId: string; payload: Readonly<{expected_revision: Revision; cycle_id: string; reason: string}>}>; +type Fields = Record; +function fields(value: unknown, keys: readonly string[]): Fields { + requireValue(typeof value === 'object' && value !== null && !Array.isArray(value) + && keys.every(key => Object.hasOwn(value, key)) && Object.keys(value).length === keys.length); + return value as Fields; +} +function choice(value: unknown, options: readonly T[]): T { + requireValue(typeof value === 'string' && options.includes(value as T)); return value as T; +} +function usages(value: unknown): readonly TaskMaterialUsage[] | null { + if (value === null) return null; + requireValue(Array.isArray(value) && value.length <= 32); + const seen = new Set(); + return Object.freeze(value.map(raw => { + const item = fields(raw, ['line_id', 'quantity']), line_id = taskIdentifier(item.line_id); + requireValue(!seen.has(line_id)); seen.add(line_id); + return Object.freeze({line_id, quantity: taskQuantity(item.quantity)}); + })); +} +/** Invalid or open-ended data cannot become a network command. No normalization replaces the original. */ +export function taskCommand(value: unknown): TaskCommand | undefined { + try { + requireValue(typeof value === 'object' && value !== null && !Array.isArray(value)); + const action = (value as Fields).action; + const item = fields(value, action === 'task.create' ? ['action', 'payload'] : ['action', 'occurrenceId', 'payload']); + if (action === 'task.create') { + const body = fields(item.payload, ['expected_revision', 'title', 'description', 'priority']); + requireValue(body.expected_revision === 0); + return Object.freeze({action, payload: Object.freeze({expected_revision: 0, + title: taskText(body.title, TASK_TEXT_LIMITS.title, true), description: taskText(body.description, TASK_TEXT_LIMITS.description, false, true), + priority: choice(body.priority, TASK_PRIORITIES)})}); + } + const occurrenceId = taskIdentifier(item.occurrenceId); + if (action === 'task.claim') { + const body = fields(item.payload, ['expected_revision']); + return Object.freeze({action, occurrenceId, payload: Object.freeze({expected_revision: revisionValue(body.expected_revision)})}); + } + if (action === 'task.complete') { + const body = fields(item.payload, ['expected_revision', 'performer_ids', 'performed_at', 'material_usages']); + requireValue(Array.isArray(body.performer_ids) && body.performer_ids.length >= 1 && body.performer_ids.length <= 32); + const performers = body.performer_ids.map(taskIdentifier); + requireValue(new Set(performers).size === performers.length); + return Object.freeze({action, occurrenceId, payload: Object.freeze({expected_revision: revisionValue(body.expected_revision), + performer_ids: Object.freeze(performers), performed_at: taskTime(body.performed_at), material_usages: usages(body.material_usages)})}); + } + if (action === 'task.verify' || action === 'task.reopen') { + const body = fields(item.payload, action === 'task.verify' + ? ['expected_revision', 'cycle_id', 'decision', 'reason'] : ['expected_revision', 'cycle_id', 'reason']); + const payload = {expected_revision: revisionValue(body.expected_revision), cycle_id: taskIdentifier(body.cycle_id), + reason: taskText(body.reason, TASK_TEXT_LIMITS.reason, action === 'task.reopen' || body.decision === 'rejected', true)}; + return action === 'task.verify' ? Object.freeze({action, occurrenceId, + payload: Object.freeze({...payload, decision: choice(body.decision, ['approved', 'rejected'])})}) + : Object.freeze({action, occurrenceId, payload: Object.freeze(payload)}); + } + throw new TaskInputError(); + } catch {return undefined;} +} + + +export interface TaskSummary { + readonly occurrenceId: string; readonly definitionId: string; readonly definitionRevision: Revision; + readonly title: string; readonly state: TaskState; readonly revision: Revision; + readonly assigneeIds: readonly string[]; readonly dueAt: string | null; readonly currentCycleId: string | null; +} +export interface TaskPage {readonly items: readonly TaskSummary[]; readonly nextCursor: string | null;} +export interface TaskMaterial {readonly lineId: string; readonly label: string; readonly plannedQuantity: string; readonly precision: number;} +export interface TaskCycleSummary { + readonly cycleId: string; readonly verification: 'none' | 'pending' | 'approved' | 'rejected'; + readonly performedAt: string; readonly performerIds: readonly string[]; +} +export interface TaskDetail extends TaskSummary { + readonly description: string; readonly priority: TaskPriority; readonly allowedActions: readonly string[]; + readonly cycle: TaskCycleSummary | null; readonly materials: readonly TaskMaterial[]; + readonly materialRecording: 'review_on_completion' | 'separate_recording'; + readonly dependencyReviewRequired: boolean; + readonly pendingMaterials: boolean; +} +export interface TaskAccess { + readonly householdId: string; readonly memberId: string; readonly canCreate: boolean; +} +export interface TaskReceipt { + readonly kind: 'receipt'; readonly stage: 'committed' | 'historical'; readonly operationId: string; + readonly action: TaskCommand['action']; readonly occurrenceId: string; readonly revision: Revision; + readonly state: TaskState; readonly cycle: TaskCycleSummary | null; + readonly archivedBy: string | null; +} diff --git a/frontend/src/presentation/domus-app.ts b/frontend/src/presentation/domus-app.ts index 03c469c..5c00988 100644 --- a/frontend/src/presentation/domus-app.ts +++ b/frontend/src/presentation/domus-app.ts @@ -1,7 +1,9 @@ -import {LitElement, html, nothing} from 'lit'; +import {LitElement, css, html, nothing} from 'lit'; import {live} from 'lit/directives/live.js'; import type {PropertyValues} from 'lit'; import type {EntryView} from '../application/entry-controller'; +import type {TaskView} from '../application/task-controller'; +import './domus-tasks'; import type {AdministrationView} from '../application/administration-controller'; import './domus-administration'; import type {Deployment} from '../domain/entry'; @@ -17,17 +19,20 @@ export type EntryIntent = Readonly<{type: 'refresh' | 'request_membership' | 'lo /** Owned rendering only; intents cross a semantic boundary and never grant command authority. */ export class DomusApp extends LitElement { - static properties = {view: {attribute: false}, administration: {attribute:false}, deployment: {attribute: false}, language: {attribute: false}, + static properties = {tasks: {attribute: false}, section: {attribute: false}, view: {attribute: false}, administration: {attribute:false}, deployment: {attribute: false}, language: {attribute: false}, nameDraft: {state: true}, zoneDraft: {state: true}}; - static styles = styles; + static styles = [styles, css`.workspace-nav {display:flex;gap:12px;margin:20px 0}.workspace-nav a {display:inline-flex;align-items:center;min-height:44px;padding:8px 20px; + border:1px solid var(--line);border-radius:999px;text-decoration:none;color:var(--accent);font-weight:650}.workspace-nav a[aria-current=page] {background:var(--accent);color:var(--surface);border-color:var(--accent)}`]; declare view: EntryView | null; declare administration:AdministrationView|null; + declare tasks: TaskView | null; + declare section: 'tasks' | 'household'; declare deployment: Deployment; declare language: Language; private declare nameDraft: string; private declare zoneDraft: string; private draftScope: string | null = null; - constructor() {super(); this.view = null; this.administration=null; this.deployment = 'standalone'; this.language = 'en'; this.nameDraft = ''; this.zoneDraft = '';} + constructor() {super(); this.view = null; this.administration=null; this.tasks = null; this.section = 'tasks'; this.deployment = 'standalone'; this.language = 'en'; this.nameDraft = ''; this.zoneDraft = '';} disconnectedCallback(): void {super.disconnectedCallback(); this.dispatchEvent(new Event('domus-detached'));} private text(key: MessageKey): string {return message(key, this.language);} private intent(detail: EntryIntent): void { @@ -93,6 +98,8 @@ export class DomusApp extends LitElement { const view = this.view, closed = view?.phase === 'closed'; const loading = view === null || view.phase === 'idle' || view.phase === 'loading' || view.phase === 'sending'; const decision = view?.decision, state = decision?.state ?? 'unavailable'; + const taskWorkspace = this.section === 'tasks' && this.tasks?.phase === 'ready' && this.tasks.access !== null; + const unresolved = view?.journal?.records.some(record => record.stage === 'prepared' || record.stage === 'uncertain') === true; return html`
DOMUS
${this.text('tagline')}
${view?.session ? this.text(view.session.context.channel === 'stable' ? 'stable' : 'edge') : 'Domus'}
-

${this.text(closed ? 'closed' : loading ? 'loading' : `state_${state}`)}

-

${this.text(closed ? 'closedBody' : loading ? 'loadingBody' : `body_${state}`)}

- ${view?.session ? html`${this.text('verified')}${this.text(view.session.access === 'read_only' ? 'readOnly' : 'full')}` : nothing} +

${taskWorkspace && !closed && !loading && view?.household ? view.household.name : this.text(closed ? 'closed' : loading ? 'loading' : `state_${state}`)}

+ ${!taskWorkspace || closed || loading ? html`

${this.text(closed ? 'closedBody' : loading ? 'loadingBody' : `body_${state}`)}

` : nothing} + ${view?.session && !taskWorkspace ? html`${this.text('verified')}${this.text(view.session.access === 'read_only' ? 'readOnly' : 'full')}` : nothing} ${decision?.readOnly ? html`

${this.text('readOnly')}

${this.text('grace')}

${this.text('deadline')}: ${instant(view!.session!.expiresAt, this.language)}

` : nothing} ${view?.notice ? html`

${this.text(`notice_${view.notice}`)}

` : nothing} ${view?.cleanup === 'unconfirmed' ? html`` : nothing} - ${this.outcome()} + ${taskWorkspace && view?.outcome?.kind === 'receipt' && view.outcome.stage === 'committed' + ? html`
${this.text('outcome_committed')}${this.outcome()}
` : this.outcome()} ${!loading && !closed && decision?.action === 'create_household' ? this.setup() : nothing} - ${!loading && !closed && view?.household ? html`

${this.text('state_active')}

+ ${!loading && !closed && !taskWorkspace && view?.household ? html`

${this.text('state_active')}

${this.text('householdName')}
${view.household.name}
${this.text('householdZone')}
${view.household.timeZone}
${this.text('revision')}
${view.household.revision}
` : nothing}
${closed ? this.button(this.deployment === 'native' ? 'reopen' : 'signIn', this.deployment === 'native' ? 'reopen' : 'sign_in', true) : !loading && decision?.action === 'sign_in' ? this.button('signIn', 'sign_in', true) : !loading && decision?.action === 'reopen_home_assistant' ? this.button('reopen', 'reopen', true) : !loading ? html`${decision?.action === 'request_membership' ? this.button('join', 'request_membership', true) : nothing} - ${this.button('refresh', 'refresh', decision?.action === 'refresh')}` : nothing}
+ ${!taskWorkspace ? this.button('refresh', 'refresh', decision?.action === 'refresh') : nothing}` : nothing} ${!loading && decision?.action === 'contact_administrator' ? html`

${this.text('contact')}

` : nothing} ${!closed && view?.busy ? html`

${this.text('busy')}

` : nothing}
- + ${this.text('navigationTasks')} + ${this.text('navigationHousehold')} + ` : nothing} +
+ +
+
- ${view?.journal?.records.length ? html`

${this.text('journalTitle')}

-

${this.text('journalBody')}

    ${view.journal.records.map(record => this.request(record))}
` : nothing} + .retired=${closed||view?.notice==='context_changed'||(view?.phase==='ready'&&decision?.state!=='active')}>
+ ${view?.journal?.records.length ? taskWorkspace && !unresolved + ? html`
${this.text('journalTitle')}

${this.text('journalBody')}

    ${view.journal.records.map(record => this.request(record))}
` + : html`

${this.text('journalTitle')}

+

${this.text('journalBody')}

    ${view.journal.records.map(record => this.request(record))}
` : nothing} ${view?.session ? html`
${this.text('details')}
${this.text('status')}
${this.text(`state_${state}`)}
${this.text('access')}
${this.text(view.session.access === 'read_only' ? 'readOnly' : 'full')}

${this.text('permissions')}

${view.authorization?.permissions.length ? html`
    ${view.authorization.permissions.map(permission => html`
  • ${permission}
  • `)}
` diff --git a/frontend/src/presentation/domus-tasks.ts b/frontend/src/presentation/domus-tasks.ts new file mode 100644 index 0000000..d26659b --- /dev/null +++ b/frontend/src/presentation/domus-tasks.ts @@ -0,0 +1,198 @@ +import {LitElement, css, html, nothing} from 'lit'; +import {live} from 'lit/directives/live.js'; +import type {PropertyValues} from 'lit'; +import type {TaskView} from '../application/task-controller'; +import type {TaskRecord} from '../application/task-ports'; +import {taskQuantity, TASK_PRIORITIES, TASK_TEXT_LIMITS} from '../domain/tasks'; +import type {TaskMaterialUsage, TaskPriority} from '../domain/tasks'; +import {language} from './messages'; +import type {Language} from './messages'; +import {taskInstant, taskMessage} from './task-messages'; +import type {TaskMessageKey} from './task-messages'; +import {styles} from './styles'; +export type TaskIntent = Readonly<{type: 'refresh' | 'more' | 'claim'}> + | Readonly<{type: 'select'; occurrenceId: string}> + | Readonly<{type: 'create'; title: string; description: string; priority: TaskPriority}> + | Readonly<{type: 'complete'; materialUsages: readonly TaskMaterialUsage[] | null}> + | Readonly<{type: 'verify'; decision: 'approved' | 'rejected'; reason: string}> + | Readonly<{type: 'reopen'; reason: string}> + | Readonly<{type: 'recover' | 'dismiss'; key: string}>; +function quantityInput(value: string, precision: number): string | null { + const trimmed = value.trim(); + if (trimmed.length > 64 || !/^[0-9]+(?:[.,][0-9]+)?$/u.test(trimmed)) return null; + const [whole, fraction = ''] = trimmed.replace(',', '.').split('.'); + const integer = whole.replace(/^0+(?=[0-9])/u, ''), decimal = fraction.replace(/0+$/u, ''); + if (decimal.length > precision) return null; + try {return taskQuantity(integer + (decimal ? '.' + decimal : ''));} catch {return null;} +} +/** Render only verified task facts; emitted intents never select an actor or grant a permission. */ +export class DomusTasks extends LitElement { + static properties = {view: {attribute: false}, retired: {attribute: false}, language: {attribute: false}, titleDraft: {state: true}, descriptionDraft: {state: true}, + priorityDraft: {state: true}, reasonDraft: {state: true}, quantities: {state: true}, quantityError: {state: true}, createOpen: {state: true}}; + static styles = [styles, css`:host {min-height:0;background:transparent;overflow-wrap:anywhere}.task-header {display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap} + .task-header h2 {font-size:1.6rem;margin:0} .task-layout {display:grid;gap:20px}.task-layout .card {margin:0} + .task-layout>div {display:grid;gap:20px;align-content:start} .new-task summary h3 {display:inline} .new-task form {margin-top:20px} + h3 {font-size:1.1rem;margin:0 0 16px} textarea {font:inherit;resize:vertical;width:100%;min-height:90px;border:1px solid var(--line);border-radius:10px; + padding:10px 12px;background:var(--surface);color:var(--text)} .task-list {list-style:none;padding:0;margin:0}.task-list li {padding:14px 0;border-bottom:1px solid var(--line)} + .task-link {border:0;border-radius:8px;padding:4px 0;text-align:start;font-weight:700}.task-meta {display:flex;flex-wrap:wrap;gap:8px;color:var(--muted);font-size:.875rem} + .description {white-space:pre-wrap}.task-fields {margin-top:24px}.material {display:grid;gap:6px;margin-bottom:12px}.request {margin-top:16px} + @media(min-width:800px) {.task-layout {grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.task-layout.single {grid-template-columns:1fr}}`]; + declare view: TaskView | null; + declare retired: boolean; + declare language: Language; + private declare titleDraft: string; + private declare createOpen: boolean; + private declare descriptionDraft: string; + private declare priorityDraft: TaskPriority; + private declare reasonDraft: string; + private declare quantities: Readonly>; + private declare quantityError: string | null; + private draftScope: string | null = null; + private detailKey: string | null = null; + private submittedDraft: string | null = null; + private lastForm: 'create' | 'review' = 'create'; + private focusDetail = false; + constructor() {super(); this.view = null; this.retired = false; this.language = 'en'; this.titleDraft = ''; this.descriptionDraft = ''; + this.priorityDraft = 'normal'; this.createOpen = false; this.reasonDraft = ''; this.quantities = {}; this.quantityError = null;} + private text(key: TaskMessageKey): string {return taskMessage(key, this.language);} + private get blocked(): boolean {return this.view?.busy !== false || this.view.readOnly || this.view.records === null + || this.view.records.some(record => record.stage === 'prepared' || record.stage === 'uncertain');} + private reset(): void {this.createOpen = false; this.titleDraft = ''; this.descriptionDraft = ''; this.priorityDraft = 'normal'; this.reasonDraft = ''; + this.quantities = {}; this.quantityError = null; this.detailKey = null; this.focusDetail = false; this.submittedDraft = null; this.draftScope = null;} + protected willUpdate(changed: PropertyValues): void { + if (changed.has('retired') && this.retired) this.reset(); + if (!changed.has('view')) return; + if (this.view?.notice === 'invalid_input' && this.lastForm === 'create') this.createOpen = true; + const next = this.view?.scopeKey; + if (this.view?.phase === 'closed' || this.view?.notice === 'authentication_required' || this.view?.notice === 'forbidden' + || next != null && this.draftScope !== null && next !== this.draftScope) this.reset(); + if (next != null) this.draftScope = next; + const detail = this.view?.detail, key = detail ? JSON.stringify([next, detail.occurrenceId, detail.revision]) : null; + if (key !== null && key !== this.detailKey) {this.focusDetail = this.view?.phase === 'ready'; this.detailKey = key; this.reasonDraft = ''; this.quantityError = null; + this.quantities = Object.fromEntries(detail!.materials.map(item => [item.lineId, item.plannedQuantity]));} + if (this.view?.outcome?.kind === 'receipt' && this.view.outcome.stage === 'committed' && this.view.outcome.action === 'task.create' + && this.submittedDraft === JSON.stringify([this.titleDraft, this.descriptionDraft, this.priorityDraft])) { + this.createOpen = false; this.titleDraft = ''; this.descriptionDraft = ''; this.priorityDraft = 'normal'; this.submittedDraft = null; + } + } + protected updated(changed: PropertyValues): void { + this.lang = language(this.language, 'en'); + if (this.focusDetail) {this.focusDetail = false; this.shadowRoot?.querySelector('#selected-task-title')?.focus();} + if (changed.has('view') && this.view?.notice === 'invalid_input') + this.shadowRoot?.querySelector(this.lastForm === 'create' ? '#task-title' : '#task-reason')?.focus(); + if (changed.has('quantityError') && this.quantityError) this.shadowRoot?.querySelector('[aria-invalid="true"]')?.focus(); + } + disconnectedCallback(): void {super.disconnectedCallback(); this.reset();} + private intent(detail: TaskIntent): void { + if (this.retired || this.view?.phase !== 'ready' || this.view.busy) return; + if (['create', 'claim', 'complete', 'verify', 'reopen'].includes(detail.type) && this.blocked) return; + this.dispatchEvent(new CustomEvent('domus-task-intent', {detail: Object.freeze(detail), bubbles: true, composed: true})); + } + private createForm() { + // Native maxlength counts UTF-16 units; the form enforces the shared code-point limits. + const titleInvalid = Array.from(this.titleDraft).length > TASK_TEXT_LIMITS.title; + const descriptionInvalid = Array.from(this.descriptionDraft).length > TASK_TEXT_LIMITS.description; + return html`
{const target = event.target as HTMLDetailsElement; if (target.isConnected) this.createOpen = target.open;}}>

${this.text('newTask')}

+
{event.preventDefault(); this.lastForm = 'create'; + if (titleInvalid || descriptionInvalid) { + this.shadowRoot?.querySelector(titleInvalid ? '#task-title' : '#task-description')?.focus(); return; + } + this.submittedDraft = JSON.stringify([this.titleDraft, this.descriptionDraft, this.priorityDraft]); + this.intent({type: 'create', title: this.titleDraft, description: this.descriptionDraft, priority: this.priorityDraft});}}> +
{this.titleDraft = (event.target as HTMLInputElement).value;}}>${titleInvalid ? this.text('textTooLong') + ': ' + TASK_TEXT_LIMITS.title : this.text('titleHelp')}
+
${descriptionInvalid ? this.text('textTooLong') + ': ' + TASK_TEXT_LIMITS.description : this.text('descriptionHelp')}
+
+ +
`; + } + private complete(): void { + const work = this.view!.detail!; let materialUsages: readonly TaskMaterialUsage[] | null = null; + if (work.materialRecording === 'review_on_completion' && work.materials.length > 0) { + const usages: TaskMaterialUsage[] = []; + for (const material of work.materials) { + const quantity = quantityInput(this.quantities[material.lineId] ?? '', material.precision); + if (quantity === null) {this.quantityError = material.lineId; return;} + usages.push(Object.freeze({line_id: material.lineId, quantity})); + } + materialUsages = Object.freeze(usages); + } + this.quantityError = null; this.intent({type: 'complete', materialUsages}); + } + private workDetail() { + const work = this.view!.detail!; + return html`

${work.title}

+

${this.text(`state_${work.state}`)}${this.text(`priority_${work.priority}`)}

+ ${work.description ? html`

${work.description}

` : nothing} +

${this.text('due')}: ${taskInstant(work.dueAt, this.language)}

+ ${work.cycle ? html`

${this.text(`verification_${work.cycle.verification}`)}
+ ${this.text('performed')}: ${taskInstant(work.cycle.performedAt, this.language)}

` : nothing} + ${work.dependencyReviewRequired ? html`

${this.text('dependencyReview')}

` : nothing} + ${work.pendingMaterials ? html`

${this.text('pendingMaterials')}

` : nothing} + ${work.allowedActions.includes('claim') ? html`
` : nothing} + ${work.allowedActions.includes('complete') ? html`
{event.preventDefault(); this.complete();}}> +

${this.text('completionHelp')}

${work.materials.length ? work.materialRecording === 'review_on_completion' ? html`
${this.text('materials')} +

${this.text('quantityHelp')}

${work.materials.map((material, index) => html`
+ {this.quantities = {...this.quantities, [material.lineId]: (event.target as HTMLInputElement).value}; this.quantityError = null;}}> + ${material.precision === 0 ? this.text('wholeQuantity') : this.text('decimalPlaces') + ': ' + material.precision}
`)} + ${this.text('quantityHelp')}${this.quantityError ? html`` : nothing}
` + : html`

${this.text('separateMaterials')}

` : nothing} +
` : nothing} + ${work.allowedActions.some(action => ['approve', 'reject', 'reopen'].includes(action)) ? html`

${this.text('review')}

+
+ ${Array.from(this.reasonDraft).length > TASK_TEXT_LIMITS.reason ? this.text('textTooLong') + ': ' + TASK_TEXT_LIMITS.reason : this.text('reasonHelp')}
+ ${(['approve', 'reject', 'reopen'] as const).filter(action => work.allowedActions.includes(action)).map(action => html``)} +
` : nothing} +
${this.text('technical')}
${this.text('taskId')}
${work.occurrenceId}
+
${this.text('revision')}
${work.revision}
`; + } + private request(record: TaskRecord) { + return html`
${this.text(`action_${record.command.action.slice(5)}` as TaskMessageKey)} +
${record.stage !== 'rejected' ? html`` : nothing} + ${record.stage !== 'prepared' && record.stage !== 'uncertain' ? html`` : nothing}
`; + } + private outcome() { + const result = this.view!.outcome; if (result === null) return nothing; + const kind = result.kind === 'receipt' ? result.stage === 'committed' ? 'saved' : 'historical' : result.kind; + return html`

${this.text(kind)}

${this.text(`${kind}Body`)}

+ ${result.kind === 'rejected' ? html`

${this.text(`notice_${result.reason}`)}

` : nothing} + ${result.kind === 'receipt' ? html`
${this.text('technical')}

${this.text('operation')}: ${result.operationId}

` : nothing}
`; + } + protected render() { + const view = this.retired ? null : this.view, ready = view?.phase === 'ready' && view.access !== null; + const unresolved = view?.records?.filter(item => item.stage === 'prepared' || item.stage === 'uncertain') ?? []; + const resolved = view?.records?.filter(item => item.stage !== 'prepared' && item.stage !== 'uncertain') ?? []; + return html`

${this.text('title')}

+
+

${this.text('intro')}

+ ${view === null || view.phase === 'idle' || view.phase === 'loading' ? html`

${this.text('loading')}

` : view.phase === 'closed' ? html`

${this.text('closed')}

` : nothing} + ${view?.notice ? html`` : nothing} + ${ready ? html`${view.readOnly ? html`

${this.text('readOnly')}

` : nothing}${this.outcome()} + ${unresolved.length ? html`

${this.text('uncertainBody')}

${unresolved.map(record => this.request(record))}
` : nothing} +
+
${view.access!.canCreate ? this.createForm() : nothing} +

${this.text('list')}

+ ${view.page?.items.length ? html`
    ${view.page.items.map(item => html`
  • +
    ${this.text(`state_${item.state}`)}${taskInstant(item.dueAt, this.language)}
  • `)}
` + : view.page !== null ? html`

${this.text('empty')}

` : nothing} + ${view.page?.nextCursor ? html`
` : nothing}
+
${view.detail ? this.workDetail() : nothing}
+
+ ${resolved.length ? html`
${this.text('requests')}${resolved.map(record => this.request(record))}
` : nothing}` : nothing} +
`; + } +} +customElements.define('domus-tasks', DomusTasks); +declare global {interface HTMLElementTagNameMap {'domus-tasks': DomusTasks;}} diff --git a/frontend/src/presentation/messages.ts b/frontend/src/presentation/messages.ts index df852cc..22550dd 100644 --- a/frontend/src/presentation/messages.ts +++ b/frontend/src/presentation/messages.ts @@ -1,4 +1,5 @@ const english = Object.freeze({ + navigationTasks: 'Tasks', navigationHousehold: 'Household', tagline: 'Your household, together', language: 'Language', english: 'English', spanish: 'Español', loading: 'Checking your access', loadingBody: 'Domus is reading your current session and permissions. No household changes are requested.', closed: 'This view is closed', closedBody: 'Household records and server history are retained. Reopen Domus to verify access again.', @@ -56,6 +57,7 @@ const english = Object.freeze({ export type MessageKey = keyof typeof english; export type Language = 'en' | 'es'; const spanish: Readonly> = Object.freeze({ + navigationTasks: 'Tareas', navigationHousehold: 'Hogar', tagline: 'Tu hogar, en equipo', language: 'Idioma', english: 'English', spanish: 'Español', loading: 'Comprobando tu acceso', loadingBody: 'Domus está consultando tu sesión y permisos actuales. No se solicita ningún cambio en el hogar.', closed: 'Esta vista está cerrada', closedBody: 'Los registros del hogar y el historial del servidor se conservan. Vuelve a abrir Domus para verificar el acceso.', diff --git a/frontend/src/presentation/task-messages.ts b/frontend/src/presentation/task-messages.ts new file mode 100644 index 0000000..599e7f6 --- /dev/null +++ b/frontend/src/presentation/task-messages.ts @@ -0,0 +1,89 @@ +import {instant, language, message} from './messages'; +const english = Object.freeze({ + title: 'Tasks', intro: 'Plan household work, share it and keep track of what is done.', + refresh: 'Refresh tasks', loading: 'Loading your tasks', closed: 'Reopen Domus to view tasks.', + newTask: 'New task', taskTitle: 'Title', description: 'Description', priority: 'Priority', create: 'Create task', + textTooLong: 'Shorten this text. Maximum characters', titleHelp: 'What needs to be done? Up to 160 characters.', descriptionHelp: 'Optional instructions for whoever does this task.', + list: 'Household tasks', empty: 'No tasks on this page', more: 'Next page', details: 'Task details', + claim: 'Claim task', complete: 'Mark done now', completionHelp: 'Record this task as performed by you now.', + materials: 'Materials used', quantity: 'Actual quantity', quantityHelp: 'Adjust the quantities used, including zero for anything unused.', + wholeQuantity: 'Use whole quantities for this material.', decimalPlaces: 'Maximum decimal places', + quantityInvalid: 'Enter a nonnegative quantity using the precision shown for this material.', + separateMaterials: 'Material quantities are recorded separately for this task.', + review: 'Review this task', reason: 'Reason', reasonHelp: 'A reason is required when rejecting or reopening a task.', + approve: 'Approve', reject: 'Reject', reopen: 'Reopen task', due: 'Due', noDue: 'No due date', performed: 'Performed', + pendingApproval: 'Pending approval', dependencyReview: 'A prerequisite was reopened. A task manager needs to review it.', + pendingMaterials: 'Materials need review. The recorded work is retained.', + readOnly: 'Read-only access. You can view local tasks, but changes are blocked.', + technical: 'Technical details', revision: 'Revision', taskId: 'Task ID', operation: 'Operation', + saved: 'Task change saved', savedBody: 'The change is recorded. The task status below shows any approval still required.', + historical: 'Previously processed change', historicalBody: 'This operation was processed before restoration. Its effect is absent from current state. Review the current task before requesting new work.', + uncertain: 'Result not confirmed', uncertainBody: 'Check the saved request before trying again. Domus will not send the change again automatically.', + rejected: 'Change not accepted', rejectedBody: 'Review the current task and your permissions before trying again.', + requests: 'Saved requests', check: 'Check saved request', dismiss: 'Dismiss saved request', + action_create: 'Create task', action_claim: 'Claim task', action_complete: 'Complete task', action_verify: 'Review task', action_reopen: 'Reopen task', + state_draft: 'Draft', state_available: 'Available', state_assigned: 'Assigned', state_in_progress: 'In progress', + state_blocked: 'Blocked', state_completed: 'Done', state_cancelled: 'Cancelled', state_skipped: 'Skipped', + verification_none: 'No approval required', verification_pending: 'Pending approval', verification_approved: 'Approved', verification_rejected: 'Rejected', + priority_low: 'Low', priority_normal: 'Normal', priority_high: 'High', priority_urgent: 'Urgent', + notice_authentication_required: 'Verify your session again to view tasks.', notice_forbidden: 'Your current permissions do not allow this task action.', + notice_unavailable: 'Tasks could not be loaded. Your saved work is retained. Refresh when the service is reachable.', + notice_invalid_response: 'The response could not be verified. Refresh to check the current task.', + notice_invalid_input: 'Review the title, reason and quantities. Required fields must not be empty.', + notice_conflict: 'This task changed. Review its current details before submitting again.', notice_read_only: 'Changes are blocked during read-only access.', + notice_journal_unavailable: 'Browser recovery storage could not be confirmed. Task changes are blocked. Refresh in a supported browser.', + notice_context_changed: 'Your account or household changed. Review the current tasks before making a change.', + notice_journal_capacity: 'Review saved requests and dismiss resolved ones to make room. Unconfirmed requests must be checked first.', + notice_previous_request_unresolved: 'Check the earlier saved request before starting another change.', + notice_action_not_allowed: 'This action is not currently available. Refresh the task.', + notice_request_changed: 'The saved request changed or expired. Review the current task before requesting new work.', + notice_request_unavailable: 'The saved request could not be checked. Review the current task before requesting new work.', +}); +export type TaskMessageKey = keyof typeof english; +const spanish: Readonly> = Object.freeze({ + title: 'Tareas', intro: 'Organiza el trabajo de casa, compártelo y consulta qué está hecho.', + refresh: 'Actualizar tareas', loading: 'Cargando tus tareas', closed: 'Vuelve a abrir Domus para ver las tareas.', + newTask: 'Nueva tarea', taskTitle: 'Título', description: 'Descripción', priority: 'Prioridad', create: 'Crear tarea', + textTooLong: 'Acorta este texto. Máximo de caracteres', titleHelp: '¿Qué hay que hacer? Hasta 160 caracteres.', descriptionHelp: 'Instrucciones opcionales para quien realice la tarea.', + list: 'Tareas del hogar', empty: 'No hay tareas en esta página', more: 'Página siguiente', details: 'Detalle de la tarea', + claim: 'Quedarme esta tarea', complete: 'Marcar como hecha ahora', completionHelp: 'Registrar que tú has realizado esta tarea ahora.', + materials: 'Materiales utilizados', quantity: 'Cantidad real', quantityHelp: 'Ajusta las cantidades utilizadas; indica cero si no has usado algo.', + wholeQuantity: 'Usa cantidades enteras para este material.', decimalPlaces: 'Máximo de decimales', + quantityInvalid: 'Introduce una cantidad no negativa con la precisión indicada para este material.', + separateMaterials: 'Las cantidades de materiales se registran por separado en esta tarea.', + review: 'Revisar esta tarea', reason: 'Motivo', reasonHelp: 'El motivo es obligatorio para rechazar o reabrir una tarea.', + approve: 'Aprobar', reject: 'Rechazar', reopen: 'Reabrir tarea', due: 'Vencimiento', noDue: 'Sin fecha de vencimiento', performed: 'Realización', + pendingApproval: 'Pendiente de aprobación', dependencyReview: 'Se ha reabierto una tarea previa. Un gestor debe revisarlo.', + pendingMaterials: 'Los materiales necesitan revisión. El trabajo realizado se conserva.', + readOnly: 'Acceso de solo lectura. Puedes consultar tareas locales, pero los cambios están bloqueados.', + technical: 'Detalles técnicos', revision: 'Revisión', taskId: 'ID de tarea', operation: 'Operación', + saved: 'Cambio de tarea guardado', savedBody: 'El cambio está registrado. El estado de la tarea indica si aún necesita aprobación.', + historical: 'Cambio procesado anteriormente', historicalBody: 'Esta operación se procesó antes de la restauración. Su efecto no está en el estado actual. Revisa la tarea antes de solicitar nuevo trabajo.', + uncertain: 'Resultado sin confirmar', uncertainBody: 'Comprueba la solicitud guardada antes de volver a intentarlo. Domus no reenviará el cambio automáticamente.', + rejected: 'Cambio no aceptado', rejectedBody: 'Revisa la tarea actual y tus permisos antes de volver a intentarlo.', + requests: 'Solicitudes guardadas', check: 'Comprobar solicitud guardada', dismiss: 'Descartar solicitud guardada', + action_create: 'Crear tarea', action_claim: 'Reclamar tarea', action_complete: 'Completar tarea', action_verify: 'Revisar tarea', action_reopen: 'Reabrir tarea', + state_draft: 'Borrador', state_available: 'Disponible', state_assigned: 'Asignada', state_in_progress: 'En curso', + state_blocked: 'Bloqueada', state_completed: 'Hecha', state_cancelled: 'Cancelada', state_skipped: 'Omitida', + verification_none: 'No necesita aprobación', verification_pending: 'Pendiente de aprobación', verification_approved: 'Aprobada', verification_rejected: 'Rechazada', + priority_low: 'Baja', priority_normal: 'Normal', priority_high: 'Alta', priority_urgent: 'Urgente', + notice_authentication_required: 'Vuelve a verificar tu sesión para ver las tareas.', notice_forbidden: 'Tus permisos actuales no permiten esta acción sobre la tarea.', + notice_unavailable: 'No se han podido cargar las tareas. Tu trabajo guardado se conserva. Actualiza cuando el servicio esté disponible.', + notice_invalid_response: 'No se ha podido verificar la respuesta. Actualiza para comprobar la tarea actual.', + notice_invalid_input: 'Revisa el título, el motivo y las cantidades. Los campos obligatorios no pueden estar vacíos.', + notice_conflict: 'Esta tarea ha cambiado. Revisa su estado actual antes de volver a enviar el cambio.', notice_read_only: 'Los cambios están bloqueados durante el acceso de solo lectura.', + notice_journal_unavailable: 'No se ha podido confirmar el almacenamiento de recuperación del navegador. Los cambios están bloqueados. Actualiza en un navegador compatible.', + notice_context_changed: 'Tu cuenta o tu hogar han cambiado. Revisa las tareas actuales antes de modificarlas.', + notice_journal_capacity: 'Revisa las solicitudes guardadas y descarta las resueltas para liberar espacio. Primero hay que comprobar las que no están confirmadas.', + notice_previous_request_unresolved: 'Comprueba la solicitud anterior guardada antes de iniciar otro cambio.', + notice_action_not_allowed: 'Esta acción no está disponible ahora. Actualiza la tarea.', + notice_request_changed: 'La solicitud guardada ha cambiado o ha caducado. Revisa la tarea actual antes de solicitar nuevo trabajo.', + notice_request_unavailable: 'No se ha podido comprobar la solicitud guardada. Revisa la tarea actual antes de solicitar nuevo trabajo.', +}); +export function taskMessage(key: TaskMessageKey, locale: unknown): string {return language(locale, 'en') === 'es' ? spanish[key] : english[key];} +export function taskInstant(value: string | null, locale: unknown): string { + if (value === null) return taskMessage('noDue', locale); + const number = Number(value); + return Number.isSafeInteger(number) && number >= 0 && number <= 8_640_000_000_000_000 + ? instant(number, language(locale, 'en')) : message('unavailableTime', language(locale, 'en')); +} diff --git a/frontend/stories/tasks.stories.ts b/frontend/stories/tasks.stories.ts new file mode 100644 index 0000000..94e9c1f --- /dev/null +++ b/frontend/stories/tasks.stories.ts @@ -0,0 +1,33 @@ +import {html} from 'lit'; +import type {Meta, StoryObj} from '@storybook/web-components-vite'; +import type {TaskView} from '../src/application/task-controller'; +import {revisionValue} from '../src/domain/administration'; +import type {Language} from '../src/presentation/messages'; +import '../src/presentation/domus-tasks'; +const context = {installationId: '01234567-89ab-cdef-0123-456789abcdef', channel: 'edge' as const, subject: 'a'.repeat(32)}; +const revision = revisionValue('1'); +type Example = 'loading' | 'empty' | 'available' | 'pending' | 'read_only'; +type Arguments = {language: Language; example: Example}; +function fixture(example: Example): TaskView | null { + if (example === 'loading') return null; + const work = {occurrenceId: 'fictional-work', definitionId: 'fictional-definition', definitionRevision: revision, title: 'Clean the kitchen', + description: 'Wipe the table and put away the dishes.', priority: 'normal' as const, revision, state: example === 'pending' ? 'completed' as const : 'available' as const, + assigneeIds: [], dueAt: null, currentCycleId: null, allowedActions: example === 'pending' ? ['approve', 'reject', 'reopen'] : ['claim', 'complete'], + cycle: example === 'pending' ? {cycleId: 'fictional-cycle', verification: 'pending' as const, performedAt: '1800000000000', performerIds: ['fictional-member']} : null, + materials: [], materialRecording: 'review_on_completion' as const, dependencyReviewRequired: false, pendingMaterials: false}; + return {phase: 'ready', busy: false, session: {context, deployment: 'native', access: example === 'read_only' ? 'read_only' : 'full', serverTime: 1800000000000, expiresAt: 1800000900000, householdApiAvailable: true}, + access: {householdId: 'fictional-home', memberId: 'fictional-member', canCreate: true}, scopeKey: 'fictional-verified-scope', readOnly: example === 'read_only', + page: {items: example === 'empty' ? [] : [work], nextCursor: null}, detail: example === 'empty' ? null : work, records: [], outcome: null, notice: null}; +} +const meta: Meta = {title: 'Domus/Tasks', args: {language: 'en', example: 'available'}, + argTypes: {language: {control: 'inline-radio', options: ['en', 'es']}, example: {control: false}}, + render: args => html`

Fictional interface state. No server is connected; actions emit intents only.

+
`}; +export default meta; +type Story = StoryObj; +export const Available: Story = {}; +export const Loading: Story = {args: {example: 'loading'}}; +export const Empty: Story = {args: {example: 'empty'}}; +export const PendingApproval: Story = {args: {example: 'pending'}}; +export const ReadOnly: Story = {args: {example: 'read_only'}}; +export const Spanish: Story = {args: {language: 'es'}}; diff --git a/frontend/tests/component.test.ts b/frontend/tests/component.test.ts index 574030b..bfa051b 100644 --- a/frontend/tests/component.test.ts +++ b/frontend/tests/component.test.ts @@ -1,3 +1,4 @@ +import type {TaskView} from '../src/application/task-controller'; import {revisionValue} from '../src/domain/administration'; import {afterEach, expect, test} from 'vitest'; import {DomusApp} from '../src/presentation/domus-app'; @@ -234,3 +235,21 @@ test('a narrow real layout wraps long inert facts without horizontal main overfl expect(main.getBoundingClientRect().width).toBe(360); expect(main.scrollWidth).toBeLessThanOrEqual(360); } }); + + +test('the default task workspace keeps household metadata and resolved setup recovery in secondary details', async () => { + const current = view('active', {household: {householdId: 'home', name: 'Maple House', timeZone: 'Europe/Madrid', revision: revisionValue('1')}, + outcome: receipt, journal: {...view().journal!, records: [record('committed')]}}); + const tasks: TaskView = {phase: 'ready', busy: false, session: current.session, access: {householdId: 'home', memberId: 'member-1', canCreate: true}, + scopeKey: 'current-scope', readOnly: false, page: {items: [], nextCursor: null}, detail: null, records: [], outcome: null, notice: null}; + const f = await mount(current); f.element.tasks = tasks; await f.element.updateComplete; + expect(f.root.querySelector('h1')!.textContent).toBe('Maple House'); expect(f.button('Refresh status')).toBeUndefined(); + const links = [...f.root.querySelectorAll('nav a')]; expect(links.map(link => link.getAttribute('href'))).toEqual(['#tasks', '#household']); + expect(links[0].getAttribute('aria-current')).toBe('page'); + expect([...f.root.querySelectorAll('details')].some(item => item.textContent?.includes('Previous requests in this tab'))).toBe(true); + f.element.section = 'household'; await f.element.updateComplete; expect(f.root.querySelector('h1')!.textContent).toBe('Your household'); + expect(f.button('Refresh status')).toBeDefined(); expect(f.root.querySelector('a[href="#household"]')!.getAttribute('aria-current')).toBe('page'); + f.element.section = 'tasks'; f.element.view = {...current, outcome: {kind: 'uncertain'}, journal: {...current.journal!, records: [record('uncertain')]}}; await f.element.updateComplete; + expect(f.root.querySelector('#requests-title')).not.toBeNull(); expect(f.text()).toContain('Result not confirmed'); + f.element.view = {...current, household: null}; await f.element.updateComplete; expect(f.root.querySelector('h1')!.textContent).toBe('Your household'); +}); diff --git a/frontend/tests/e2e/administration.spec.ts b/frontend/tests/e2e/administration.spec.ts index 50df0af..670084d 100644 --- a/frontend/tests/e2e/administration.spec.ts +++ b/frontend/tests/e2e/administration.spec.ts @@ -12,11 +12,12 @@ async function signIn(page:Page,mode:Mode) { if(mode==='standalone') { await page.getByRole('button',{name:'Sign in with Home Assistant',exact:true}).click(); await page.getByRole('link',{name:'Continue as fixture user',exact:true}).click(); + await page.goto(page.url().split('#')[0]+'#household'); } } async function create(page:Page,context:BrowserContext,mode:Mode) { if(mode==='native')await context.addCookies([{name:'ingress_session',value:'f'.repeat(128),domain:'127.0.0.1',path:'/',httpOnly:true,sameSite:'Strict'}]); - await page.goto(base(mode)+'/');await signIn(page,mode); + await page.goto(base(mode)+'/#household');await signIn(page,mode); await page.getByLabel('Household name',{exact:true}).fill('Maple House');await page.getByLabel('Timezone',{exact:true}).fill('UTC'); await page.getByRole('button',{name:'Create household',exact:true}).click(); const widget=page.locator('domus-administration');await expect(widget.getByRole('heading',{name:'Household administration',exact:true})).toBeVisible(); @@ -92,7 +93,7 @@ for(const mode of ['native','standalone'] as const)test.describe('administration try{ if(mode==='native')await peer.addCookies([{name:'ingress_session',value:(actors==='same_account'?'d':'e').repeat(128),domain:'127.0.0.1',path:'/',httpOnly:true,sameSite:'Strict'}]); else if(actors==='different_accounts')expect((await request.post(origin(mode)+'/__fixture__/identity',{data:{subject:'c'.repeat(32)}})).ok()).toBe(true); - await other.goto(base(mode)+'/');await signIn(other,mode); + await other.goto(base(mode)+'/#household');await signIn(other,mode); // A different verified HA administrator must explicitly join before the mapping can apply. if(actors==='different_accounts')await other.getByRole('button',{name:'Request membership',exact:true}).click(); const widgets=[widget,other.locator('domus-administration')]; @@ -157,7 +158,7 @@ for(const mode of ['native','standalone'] as const)test.describe('administration await widget.getByRole('button',{name:'Close editor',exact:true}).click(); if(mode==='native')await peer.addCookies([{name:'ingress_session',value:'e'.repeat(128),domain:'127.0.0.1',path:'/',httpOnly:true,sameSite:'Strict'}]); else expect((await request.post(origin(mode)+'/__fixture__/identity',{data:{subject:subject.repeat(32)}})).ok()).toBe(true); - await other.goto(base(mode)+'/');await signIn(other,mode); + await other.goto(base(mode)+'/#household');await signIn(other,mode); await other.getByRole('button',{name:'Request membership',exact:true}).click(); await expect(other.getByRole('heading',{name:'Membership pending',exact:true})).toBeVisible(); if(mode==='standalone')expect((await request.post(origin(mode)+'/__fixture__/identity',{data:{subject:'a'.repeat(32)}})).ok()).toBe(true); @@ -207,7 +208,7 @@ for(const mode of ['native','standalone'] as const)test.describe('administration try { if(mode==='native')await peer.addCookies([{name:'ingress_session',value:'e'.repeat(128),domain:'127.0.0.1',path:'/',httpOnly:true,sameSite:'Strict'}]); else expect((await request.post(origin(mode)+'/__fixture__/identity',{data:{subject:subject.repeat(32)}})).ok()).toBe(true); - await other.goto(base(mode)+'/');await signIn(other,mode);await other.getByRole('button',{name:'Request membership',exact:true}).click(); + await other.goto(base(mode)+'/#household');await signIn(other,mode);await other.getByRole('button',{name:'Request membership',exact:true}).click(); await expect(other.getByRole('heading',{name:'Membership pending',exact:true})).toBeVisible(); if(mode==='standalone')await request.post(origin(mode)+'/__fixture__/identity',{data:{subject:'a'.repeat(32)}}); await widget.getByRole('button',{name:'Refresh administration',exact:true}).click(); diff --git a/frontend/tests/e2e/catalog.spec.ts b/frontend/tests/e2e/catalog.spec.ts index 16043ca..7090cca 100644 --- a/frontend/tests/e2e/catalog.spec.ts +++ b/frontend/tests/e2e/catalog.spec.ts @@ -257,3 +257,33 @@ test(`household-admin-C57: maximum Unicode roster remains readable and keeps its {type:'submit',command:{action:'member.presentation.update',memberId,payload:{expected_revision:'11',alias:replacement}}}]); expect(errors).toEqual([]);expect(foreign).toEqual([]);expect(mutations).toEqual([]); }); + + +for (const example of ['available', 'loading', 'empty', 'pending-approval', 'read-only', 'spanish']) { + test(`built task ${example} supports both languages without contacting an API`, async ({page}) => { + const errors: string[] = [], foreign: string[] = [], api: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + await page.route('**/*', async route => { + const address = new URL(route.request().url()); if (address.pathname.includes('/api/')) api.push(address.href); + if (address.origin !== origin) {foreign.push(address.href); await route.abort();} else await route.continue(); + }); + for (const locale of ['en', 'es']) { + await page.goto(origin + '/iframe.html?id=domus-tasks--' + example + '&viewMode=story&args=' + encodeURIComponent('language:' + locale)); + const widget = page.locator('domus-tasks'); await expect(widget.getByRole('heading', {name: locale === 'es' ? 'Tareas' : 'Tasks', exact: true})).toBeVisible(); + await expect(widget).toHaveAttribute('lang', locale); + if (example === 'pending-approval') await expect(widget.getByText(locale === 'es' ? 'Pendiente de aprobación' : 'Pending approval', {exact: true})).toBeVisible(); + if (example === 'read-only') {await widget.locator('.new-task > summary').click(); + await expect(widget.getByRole('button', {name: locale === 'es' ? 'Crear tarea' : 'Create task', exact: true})).toBeDisabled();} + } + expect(errors).toEqual([]); expect(foreign).toEqual([]); expect(api).toEqual([]); + }); +} +test('the built task workspace fits a narrow viewport in both color schemes', async ({page}) => { + await page.setViewportSize({width: 360, height: 800}); + for (const colorScheme of ['light', 'dark'] as const) { + await page.emulateMedia({colorScheme, reducedMotion: 'reduce'}); + await page.goto(origin + '/iframe.html?id=domus-tasks--pending-approval&viewMode=story&args=language:es'); + const widget = page.locator('domus-tasks'); await expect(widget.getByRole('button', {name: 'Aprobar', exact: true})).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(360); + } +}); diff --git a/frontend/tests/e2e/entry.spec.ts b/frontend/tests/e2e/entry.spec.ts index 1738551..87b9164 100644 --- a/frontend/tests/e2e/entry.spec.ts +++ b/frontend/tests/e2e/entry.spec.ts @@ -12,12 +12,13 @@ const stats = async (request: APIRequestContext, mode: Mode) => async function open(page: Page, context: BrowserContext, mode: Mode, signIn = true, ingressCookie = 'f'.repeat(128)) { if (mode === 'native') await context.addCookies([{name: 'ingress_session', value: ingressCookie, domain: '127.0.0.1', path: '/', httpOnly: true, sameSite: 'Strict'}]); - const response = await page.goto(base(mode) + '/'); + const response = await page.goto(base(mode) + (signIn ? '/#household' : '/')); expect(response?.status()).toBe(200); if (mode === 'standalone' && signIn) { await page.getByRole('button', {name: 'Sign in with Home Assistant', exact: true}).click(); await expect(page.getByRole('heading', {name: 'Fictional Home Assistant'})).toBeVisible(); await page.getByRole('link', {name: 'Continue as fixture user'}).click(); + await page.goto(page.url().split('#')[0] + '#household'); } if (signIn) await expect(page.getByRole('heading', {name: 'Set up your household', exact: true})).toBeVisible(); return response!; @@ -139,7 +140,7 @@ for (const mode of modes) test.describe(mode, () => { if (mode === 'native') await context.addCookies([{name: 'ingress_session', value: 'f'.repeat(128), domain: '127.0.0.1', path: '/', httpOnly: true, sameSite: 'Strict'}]); await page.route('**/assets/*.js', route => route.abort('failed')); - await page.goto(base(mode) + '/'); + await page.goto(base(mode) + '/#household'); await expect(page.getByText('Loading Domus. If this view does not open, reload it from your Domus installation.', {exact: true})).toBeVisible(); await expect(page.getByRole('heading', {name: 'Set up your household', exact: true})).toHaveCount(0); expect((await stats(request, mode)).counts.households).toBe(0); diff --git a/frontend/tests/e2e/tasks.spec.ts b/frontend/tests/e2e/tasks.spec.ts new file mode 100644 index 0000000..5e76789 --- /dev/null +++ b/frontend/tests/e2e/tasks.spec.ts @@ -0,0 +1,181 @@ +import {expect, test} from '@playwright/test'; +import type {BrowserContext, Locator, Page} from '@playwright/test'; +const prefix = '/api/hassio_ingress/fixture_domus_supervisor_1234567890'; +type Mode = 'native' | 'standalone'; +const origin = (mode: Mode) => `http://127.0.0.1:${mode === 'native' ? 56174 : 56175}`; +const base = (mode: Mode) => origin(mode) + (mode === 'native' ? prefix : ''); +async function open(page: Page, context: BrowserContext, mode: Mode) { + if (mode === 'native') await context.addCookies([{name: 'ingress_session', value: 'f'.repeat(128), domain: '127.0.0.1', path: '/', httpOnly: true, sameSite: 'Strict'}]); + await page.goto(base(mode) + '/'); + if (mode === 'standalone') { + await page.getByRole('button', {name: 'Sign in with Home Assistant', exact: true}).click(); + await page.getByRole('link', {name: 'Continue as fixture user', exact: true}).click(); + } + await page.getByLabel('Household name', {exact: true}).fill('Maple House'); + await page.getByLabel('Timezone', {exact: true}).fill('Europe/Madrid'); + await page.getByRole('button', {name: 'Create household', exact: true}).click(); + const tasks = page.locator('domus-tasks'); + await expect(tasks.getByRole('button', {name: 'Create task', exact: true})).toBeEnabled(); + return tasks; +} +async function saveAdministration(widget: Locator) { + await widget.getByRole('button', {name: 'Review changes', exact: true}).click(); + await widget.getByRole('button', {name: 'Save changes', exact: true}).click(); + await expect(widget.getByRole('heading', {name: 'Original request processed', exact: true})).toBeVisible(); + await widget.getByRole('button', {name: 'Close editor', exact: true}).click(); +} +for (const mode of ['native', 'standalone'] as const) test.describe('tasks ' + mode, () => { + test.beforeEach(async ({request}) => { + if (process.env.DOMUS_BROWSER_IMAGE) { + const image = await request.get(origin(mode) + '/__fixture__/image'); + expect(image.status()).toBe(200); + expect(await image.json()).toEqual({source_commit: process.env.DOMUS_TEST_SOURCE_COMMIT, architecture: process.env.DOMUS_TEST_ARCH, + image_id: expect.stringMatching(/^sha256:[a-f0-9]{64}$/u), mode}); + } + expect((await request.post(origin(mode) + '/__fixture__/reset')).ok()).toBe(true); + }); + for (const locale of ['en', 'es'] as const) test('actual UI creates, claims, completes and reloads SQLite work in ' + locale, async ({page, context}) => { + const tasks = await open(page, context, mode), spanish = locale === 'es'; + if (spanish) await page.getByLabel('Language', {exact: true}).selectOption('es'); + const title = spanish ? 'Limpiar la mesa 🧹 ' : 'Clean the table 🧹 '; + await tasks.getByLabel(spanish ? 'Título' : 'Title', {exact: true}).fill(title); + await tasks.getByLabel(spanish ? 'Descripción' : 'Description', {exact: true}).fill('Wipe then dry.'); + await tasks.getByRole('button', {name: spanish ? 'Crear tarea' : 'Create task', exact: true}).click(); + await expect(tasks.getByRole('heading', {name: title, exact: true})).toBeVisible(); + await expect(tasks.locator('script,img')).toHaveCount(0); + await tasks.getByRole('button', {name: spanish ? 'Quedarme esta tarea' : 'Claim task', exact: true}).click(); + await expect(tasks.getByRole('button', {name: spanish ? 'Marcar como hecha ahora' : 'Mark done now', exact: true})).toBeEnabled(); + await tasks.getByRole('button', {name: spanish ? 'Marcar como hecha ahora' : 'Mark done now', exact: true}).click(); + await expect(tasks.getByText(spanish ? 'No necesita aprobación' : 'No approval required', {exact: true})).toBeVisible(); + const pageOfWork = await (await page.request.get(base(mode) + '/api/v1/tasks/occurrences')).json(); + expect(pageOfWork.items).toHaveLength(1); expect(pageOfWork.items[0]).toMatchObject({title, state: 'completed', revision: 3}); + const actual = await (await page.request.get(base(mode) + '/api/v1/tasks/occurrences/' + pageOfWork.items[0].occurrence_id)).json(); + expect(actual.cycle.performers).toHaveLength(1); expect(actual.cycle.verification).toBe('none'); expect(actual.consumer_delivery).toBe('unconfirmed'); + await page.reload(); await expect(tasks.getByRole('button', {name: title, exact: true})).toBeVisible(); + await tasks.getByRole('button', {name: title, exact: true}).click(); + await expect(tasks.getByText('No approval required', {exact: true})).toBeVisible(); + const stats = await (await page.request.get(origin(mode) + '/__fixture__/stats')).json(); expect(stats.counts.receipts).toBe(4); + if (mode === 'native' && !spanish) await page.screenshot({path: 'test-results/tasks-native-completed.png', fullPage: true}); + }); + test('a restricted member claims and performs work; an adult approves and reopens its retained cycle', async ({page, context, browser, request}) => { + const tasks = await open(page, context, mode), title = 'Kitchen responsibility'; + await tasks.getByLabel('Title', {exact: true}).fill(title); await tasks.getByRole('button', {name: 'Create task', exact: true}).click(); + await expect(tasks.getByRole('heading', {name: title, exact: true})).toBeVisible(); + await page.getByRole('link', {name: 'Household', exact: true}).click(); const administration = page.locator('domus-administration'); + await administration.getByRole('button', {name: 'Edit access rules', exact: true}).click(); + await administration.getByLabel('HA administrator mapping', {exact: true}).selectOption('selected_ha_user'); + await administration.getByLabel('Target Home Assistant account', {exact: true}).selectOption('a'.repeat(32)); await saveAdministration(administration); + const peer = await browser.newContext(), participant = await peer.newPage(), subject = mode === 'native' ? 'c' : 'b'; + try { + if (mode === 'native') await peer.addCookies([{name: 'ingress_session', value: 'e'.repeat(128), domain: '127.0.0.1', path: '/', httpOnly: true, sameSite: 'Strict'}]); + else expect((await request.post(origin(mode) + '/__fixture__/identity', {data: {subject: subject.repeat(32)}})).ok()).toBe(true); + await participant.goto(base(mode) + '/'); + if (mode === 'standalone') { + await participant.getByRole('button', {name: 'Sign in with Home Assistant', exact: true}).click(); + await participant.getByRole('link', {name: 'Continue as fixture user', exact: true}).click(); + } + await participant.getByRole('button', {name: 'Request membership', exact: true}).click(); + await expect(participant.getByRole('heading', {name: 'Membership pending', exact: true})).toBeVisible(); + if (mode === 'standalone') await request.post(origin(mode) + '/__fixture__/identity', {data: {subject: 'a'.repeat(32)}}); + await administration.getByRole('button', {name: 'Refresh administration', exact: true}).click(); + await administration.getByRole('button', {name: 'Manage participant: Fictional account ' + subject, exact: true}).click(); + await expect(administration.getByLabel('Household profile', {exact: true})).toHaveValue('restricted'); + await administration.getByLabel('Membership status', {exact: true}).selectOption('active'); + await administration.getByRole('button', {name: 'Review changes', exact: true}).click(); + await administration.getByRole('checkbox', {name: 'Approve household access', exact: true}).check(); + await administration.getByRole('button', {name: 'Review changes', exact: true}).click(); + await administration.getByRole('button', {name: 'Save changes', exact: true}).click(); + await expect(administration.getByRole('heading', {name: 'Original request processed', exact: true})).toBeVisible(); + await administration.getByRole('button', {name: 'Close editor', exact: true}).click(); + await participant.getByRole('button', {name: 'Refresh status', exact: true}).click(); + const memberTasks = participant.locator('domus-tasks'); await memberTasks.getByRole('button', {name: title, exact: true}).click(); + await expect(memberTasks.getByRole('button', {name: 'Create task', exact: true})).toHaveCount(0); + await memberTasks.getByRole('button', {name: 'Claim task', exact: true}).click(); + await memberTasks.locator('summary').filter({hasText: 'Saved requests'}).click(); + await memberTasks.getByRole('button', {name: 'Dismiss saved request', exact: true}).click(); + let completionWrites = 0; + await participant.route(base(mode) + '/api/v1/tasks/occurrences/*/complete', async route => { + completionWrites++; const upstream = await route.fetch({maxRetries: 0, maxRedirects: 0}); expect(upstream.status()).toBe(200); + await route.abort('connectionreset'); + }); + await memberTasks.getByRole('button', {name: 'Mark done now', exact: true}).click(); + await expect(memberTasks.getByRole('heading', {name: 'Result not confirmed', exact: true})).toBeVisible(); + await participant.reload(); await memberTasks.getByRole('button', {name: 'Check saved request', exact: true}).click(); + await expect(memberTasks.getByRole('heading', {name: 'Task change saved', exact: true})).toBeVisible(); + expect(completionWrites).toBe(1); + await expect(memberTasks.getByText('Pending approval', {exact: true})).toBeVisible(); + await expect(memberTasks.getByRole('button', {name: 'Approve', exact: true})).toHaveCount(0); + await page.getByRole('link', {name: 'Tasks', exact: true}).click(); await tasks.getByRole('button', {name: 'Refresh tasks', exact: true}).click(); + await tasks.getByRole('button', {name: title, exact: true}).click(); + await expect(tasks.getByRole('button', {name: 'Approve', exact: true})).toBeEnabled(); + await expect(tasks.getByRole('button', {name: 'Reject', exact: true})).toBeDisabled(); + await expect(tasks.getByRole('button', {name: 'Reopen task', exact: true})).toBeDisabled(); + await tasks.getByLabel('Reason', {exact: true}).fill(' \t\n '); + await expect(tasks.getByRole('button', {name: 'Reject', exact: true})).toBeDisabled(); + await tasks.getByRole('button', {name: 'Approve', exact: true}).click(); + await expect(tasks.getByText('Approved', {exact: true})).toBeVisible(); + await memberTasks.getByRole('button', {name: 'Refresh tasks', exact: true}).click(); await expect(memberTasks.getByText('Approved', {exact: true})).toBeVisible(); + const items = await (await page.request.get(base(mode) + '/api/v1/tasks/occurrences')).json(), id = items.items[0].occurrence_id; + const approved = await (await page.request.get(base(mode) + '/api/v1/tasks/occurrences/' + id)).json(); + expect(approved.cycle.performers[0].role).toBe('restricted'); expect(approved.cycle.verified_by.role).toBe('administrator'); + await expect(tasks.getByRole('button', {name: 'Reopen task', exact: true})).toBeDisabled(); + await tasks.getByLabel('Reason', {exact: true}).fill('Repeat after review'); + await expect(tasks.getByRole('button', {name: 'Reopen task', exact: true})).toBeEnabled(); + // Retain real upstream bytes before delivery; avoid depending on Chromium's + // response-body cache for the original-result assertion. + const reopeningResults: {result: {cycle: {closed_at: number | null; performers: unknown}}}[] = []; + let reopeningWrites = 0; + await page.route(base(mode) + '/api/v1/tasks/occurrences/' + id + '/reopen', async route => { + expect(route.request().method()).toBe('POST'); reopeningWrites++; + const upstream = await route.fetch({maxRetries: 0, maxRedirects: 0}); expect(upstream.status()).toBe(200); + const body = await upstream.body(); reopeningResults.push(JSON.parse(body.toString('utf8'))); + await route.fulfill({response: upstream, body}); + }); + const currentLocation = page.url(); + await tasks.getByRole('button', {name: 'Reopen task', exact: true}).click(); + await expect(tasks.getByRole('button', {name: 'Mark done now', exact: true})).toBeVisible(); + expect(page.url()).toBe(currentLocation); expect(reopeningWrites).toBe(1); expect(reopeningResults).toHaveLength(1); + const [original] = reopeningResults; + expect(original.result.cycle.closed_at).not.toBeNull(); expect(original.result.cycle.performers).toEqual(approved.cycle.performers); + const reopened = await (await page.request.get(base(mode) + '/api/v1/tasks/occurrences/' + id)).json(); + expect(reopened.work.state).toBe('assigned'); expect(reopened.work.revision).toBe(5); expect(reopened.cycle).toBeNull(); + expect((await (await page.request.get(base(mode) + '/api/v1/tasks/occurrences')).json()).items).toHaveLength(1); + } finally {await peer.close();} + }); + test('Unicode field limits retain invalid input and accept exact maximum code-point values', async ({page, context}) => { + const tasks = await open(page, context, mode); let writes = 0; + page.on('request', request => {if (request.method() === 'POST' && request.url().endsWith('/api/v1/tasks/definitions')) writes++;}); + await tasks.getByLabel('Title', {exact: true}).fill('a'.repeat(161)); + await expect(tasks.getByLabel('Title', {exact: true})).toHaveAttribute('aria-invalid', 'true'); + await expect(tasks.getByRole('button', {name: 'Create task', exact: true})).toBeDisabled(); + await expect(tasks.getByLabel('Title', {exact: true})).toHaveValue('a'.repeat(161)); expect(writes).toBe(0); + const title = '🧹'.repeat(160), description = '🧼'.repeat(4000); + await tasks.getByLabel('Title', {exact: true}).fill(title); + await tasks.getByLabel('Description', {exact: true}).fill('a'.repeat(4001)); + await expect(tasks.getByLabel('Description', {exact: true})).toHaveAttribute('aria-invalid', 'true'); + await expect(tasks.getByRole('button', {name: 'Create task', exact: true})).toBeDisabled(); expect(writes).toBe(0); + await tasks.getByLabel('Description', {exact: true}).fill(description); + await tasks.getByRole('button', {name: 'Create task', exact: true}).click(); + await expect(tasks.getByRole('heading', {name: title, exact: true})).toBeVisible(); expect(writes).toBe(1); + const list = await (await page.request.get(base(mode) + '/api/v1/tasks/occurrences')).json(); + const work = await (await page.request.get(base(mode) + '/api/v1/tasks/occurrences/' + list.items[0].occurrence_id)).json(); + expect(work.work.definition_snapshot.content).toMatchObject({title, description}); + }); + test('lost committed response survives reload and recovers by GET without a duplicate creation', async ({page, context}) => { + const tasks = await open(page, context, mode); let writes = 0, reads = 0; + page.on('request', request => {if (request.url().includes('/tasks/requests/')) reads++;}); + await page.route(base(mode) + '/api/v1/tasks/definitions', async route => { + if (route.request().method() !== 'POST') return route.continue(); + writes++; const upstream = await route.fetch({maxRetries: 0, maxRedirects: 0}); expect(upstream.status()).toBe(200); + await route.abort('connectionreset'); + }); + await tasks.getByLabel('Title', {exact: true}).fill('One original task'); + await tasks.getByRole('button', {name: 'Create task', exact: true}).click(); + await expect(tasks.getByRole('heading', {name: 'Result not confirmed', exact: true})).toBeVisible(); + await page.reload(); await tasks.getByRole('button', {name: 'Check saved request', exact: true}).click(); + await expect(tasks.getByRole('heading', {name: 'Task change saved', exact: true})).toBeVisible(); + expect(writes).toBe(1); expect(reads).toBe(1); + const actual = await (await page.request.get(base(mode) + '/api/v1/tasks/occurrences')).json(); expect(actual.items).toHaveLength(1); + expect(actual.items[0].title).toBe('One original task'); + }); +}); diff --git a/frontend/tests/household-controller.test.ts b/frontend/tests/household-controller.test.ts index ab50728..4a317d4 100644 --- a/frontend/tests/household-controller.test.ts +++ b/frontend/tests/household-controller.test.ts @@ -1,3 +1,7 @@ +import {TaskController} from '../src/application/task-controller'; +import {TaskSessionJournal} from '../src/adapters/task-journal'; +import type {TaskServer} from '../src/application/task-ports'; +import type {TaskDetail, TaskReceipt} from '../src/domain/tasks'; import {expect, test, vi} from 'vitest'; import {HouseholdController} from '../src/application/household-controller'; import {EntryController} from '../src/application/entry-controller'; @@ -457,3 +461,72 @@ test('household-browser-C04: the scheduling projection contains only immutable a expect(Object.isFrozen(deadlines)).toBe(true); expect(JSON.stringify(deadlines)).not.toContain('locale'); f.controller.close(); expect(f.controller.deadlines).toEqual([]); expect(deadlines).toHaveLength(3); }); + + +function taskWorkspace(mode: 'native' | 'standalone' = 'standalone') { + const f = fixture(mode), proof = {...f.proof}, identity = {session: vi.fn(() => read(proof))}; + const work: TaskDetail = {occurrenceId: 'work-1', definitionId: 'def-1', definitionRevision: revisionValue('1'), title: 'Clean', description: '', priority: 'normal', + state: 'available', revision: revisionValue('1'), assigneeIds: [], dueAt: null, currentCycleId: null, allowedActions: ['complete'], cycle: null, materials: [], + materialRecording: 'review_on_completion', dependencyReviewRequired: false, pendingMaterials: false}; + const result: TaskReceipt = {kind: 'receipt', stage: 'committed', action: 'task.create', operationId: 'task-operation', occurrenceId: 'work-1', + revision: revisionValue('1'), state: 'available', cycle: null, archivedBy: null}; + const server = {access: vi.fn(() => read({householdId: 'home', memberId: member.memberId, canCreate: true})), + list: vi.fn(() => read({items: [work], nextCursor: null})), detail: vi.fn(() => read(work)), original: vi.fn(() => read(result)), + send: vi.fn(async () => result)}; + const controllers: TaskController[] = [], factory = vi.fn(() => {const value = new TaskController(mode, identity, server, new TaskSessionJournal(f.storage), f.clock, f.entropy); + controllers.push(value); return value;}); + return {...f, taskIdentity: identity, taskProof: proof, taskServer: server, taskFactory: factory, taskControllers: controllers, + workspace: new HouseholdController(f.entry, f.factory, factory)}; +} +test.each(['native', 'standalone'] as const)('%s: the household owns actual task controllers, mutations, deadlines and closure', async mode => { + const f = taskWorkspace(mode); expect(f.workspace.tasks).toBeNull(); await f.workspace.taskWork(controller => controller.refresh()); expect(f.taskFactory).not.toHaveBeenCalled(); + await f.workspace.refresh(); expect(f.workspace.tasks?.page?.items[0].title).toBe('Clean'); + await f.workspace.taskWork(controller => controller.create('New', '', 'normal')); expect(f.taskServer.send).toHaveBeenCalledOnce(); + expect(f.workspace.tasks?.outcome).toMatchObject({kind: 'receipt', operationId: 'task-operation'}); expect(f.workspace.deadlines).toContain(3601000); + f.workspace.close(); expect(f.taskControllers[0].view.phase).toBe('closed'); expect(f.workspace.tasks).toBeNull(); +}); +test.each(['subject', 'access', 'member', 'household'])( + 'task %s drift cannot be combined with the currently verified household view', async field => { + const f = taskWorkspace(); + if (field === 'subject') f.taskIdentity.session.mockReturnValue(read({...f.taskProof, context: {...context, subject: 'b'.repeat(32)}})); + if (field === 'access') f.taskIdentity.session.mockReturnValue(read({...f.taskProof, access: 'read_only'})); + if (field === 'member') f.taskServer.access.mockReturnValue(read({householdId: 'home', memberId: 'other', canCreate: true})); + if (field === 'household') f.taskServer.access.mockReturnValue(read({householdId: 'other', memberId: member.memberId, canCreate: true})); + await f.workspace.refresh(); expect(f.workspace.tasks).toBeNull(); expect(f.workspace.administration).toBeNull(); expect(f.workspace.view.notice).toBe('context_changed'); + f.workspace.close(); + }); +test('an empty task permission failure is displayed without inventing an empty task list', async () => { + const f = taskWorkspace(); f.taskServer.access.mockReturnValue({result: Promise.reject(new EntryReadError('forbidden')), cancel: vi.fn()}); + await f.workspace.refresh(); expect(f.workspace.tasks?.notice).toBe('forbidden'); expect(f.workspace.tasks?.page).toBeNull(); expect(f.workspace.administration).not.toBeNull(); f.workspace.close(); +}); +test('a shorter verified task lease expires every private household workspace without renewing proof', async () => { + const f = taskWorkspace(); f.taskProof.expiresAt = 2000; await f.workspace.refresh(); const calls = f.taskIdentity.session.mock.calls.length; + f.setNow(2000); f.workspace.tick(); expect(f.workspace.tasks).toBeNull(); expect(f.workspace.administration).toBeNull(); + expect(f.workspace.view.decision?.state).toBe('authentication_required'); expect(f.taskIdentity.session).toHaveBeenCalledTimes(calls); f.workspace.close(); +}); +test('parent closure fences a task refresh delivered after its owned lifetime', async () => { + const f = taskWorkspace(), pending = deferred(); f.taskIdentity.session.mockReturnValue(pending); + const opening = f.workspace.refresh(); await vi.waitFor(() => expect(f.taskFactory).toHaveBeenCalledOnce()); + f.workspace.close(); pending.resolve(f.taskProof); await opening; expect(f.workspace.tasks).toBeNull(); expect(f.workspace.view.phase).toBe('closed'); +}); +test('a changed verified household after a task action hides its old result', async () => { + const f = taskWorkspace(); await f.workspace.refresh(); + await f.workspace.taskWork(async () => {f.taskIdentity.session.mockReturnValue(read({...f.taskProof, context: {...context, subject: 'b'.repeat(32)}})); + await f.taskControllers[0].refresh();}); + expect(f.workspace.tasks).toBeNull(); expect(f.workspace.view.notice).toBe('context_changed'); f.workspace.close(); +}); +test('closure during an in-flight task command retains only its original journal, without reviving a view', async () => { + const f = taskWorkspace(); await f.workspace.refresh(); const pending = deferred(); + f.taskServer.send.mockReturnValueOnce(pending.result); + const sending = f.workspace.taskWork(controller => controller.create('New', '', 'normal')); + await vi.waitFor(() => expect(f.taskServer.send).toHaveBeenCalledOnce()); f.workspace.close(); pending.resolve({kind: 'receipt', stage: 'committed', action: 'task.create', + operationId: 'task-operation', occurrenceId: 'work-1', revision: revisionValue('1'), state: 'available', cycle: null, archivedBy: null}); + await sending; expect(f.workspace.tasks).toBeNull(); expect(f.workspace.view.phase).toBe('closed'); +}); + + +test('unavailable administration metadata does not turn independently protected task reads into an empty list', async () => { + const f = taskWorkspace(); f.server.capabilities.mockReturnValue({result: Promise.reject(new EntryReadError('unavailable')), cancel: vi.fn()}); + await f.workspace.refresh(); expect(f.workspace.tasks?.page?.items[0].title).toBe('Clean'); expect(f.workspace.administration?.notice).toBe('unavailable'); + f.workspace.close(); +}); diff --git a/frontend/tests/page.test.ts b/frontend/tests/page.test.ts index 7ac1c07..943f25c 100644 --- a/frontend/tests/page.test.ts +++ b/frontend/tests/page.test.ts @@ -1,5 +1,6 @@ import {afterEach, expect, test, vi} from 'vitest'; -import {decodeIntent, decodeAdministrationIntent, mountEntry} from '../src/composition/entry-page'; +import {decodeIntent, decodeAdministrationIntent, decodeTaskIntent, mountEntry} from '../src/composition/entry-page'; +import {TaskController} from '../src/application/task-controller'; import type {EntryPage} from '../src/composition/entry-page'; import type {DomusApp} from '../src/presentation/domus-app'; import type {DomusAdministration} from '../src/presentation/domus-administration'; @@ -45,6 +46,8 @@ function fixture(deployment: 'native' | 'standalone' = 'standalone', locale = 'e else if (route==='administration/capabilities') raw=JSON.stringify({state:'available',contract_version:2,household_id:'home',exact_revisions:'json-integer-int64', actions:['household.update','member.update','policy.update','member.presentation.update','member.preferences.update'], journal:{namespace:'administration',version:1,maximum_records:8,maximum_bytes:65536,lifetime_ms:3600000}}); + else if (route==='tasks/access') raw=JSON.stringify({household_id:'home',member_id:memberId,can_create:true}); + else if (route.startsWith('tasks/occurrences?')) raw=JSON.stringify({items:[],next_cursor:null}); else if (route==='administration/preferences') raw=JSON.stringify(preferences()); else if (route==='administration/roster') raw=JSON.stringify({directory_state:'available',members:[{ member:{...originalAccess.member,member_id:memberId},presentation:presentation(),metadata:{state:'available',name:'Verified owner',avatar_state:'unavailable'}}]}); @@ -262,3 +265,59 @@ test('household-browser-C18: a signed-out language choice has no identity or mut const f=fixture();const {app}=await f.open();intent(app,{type:'logout'});await vi.waitFor(()=>expect(app.view?.notice).toBe('signed_out')); const count=f.fetch.mock.calls.length;intent(app,{type:'language',language:'es'});expect(app.language).toBe('es');expect(f.fetch).toHaveBeenCalledTimes(count);expect(f.putRequests()).toBe(0); }); + + +test.each([null, {}, [], {type: 'future'}, {type: 'refresh', actor: 'forged'}, + {type: 'create', title: 'Task', description: '', priority: 'normal', householdId: 'forged'}, + {type: 'create', title: 1, description: '', priority: 'normal'}, {type: 'create', title: 'Task', description: '', priority: 'future'}, + {type: 'complete', materialUsages: true}, {type: 'complete', materialUsages: [{line_id: 'line', quantity: '1.0'}]}, + {type: 'verify', decision: 'pending', reason: 'A'}, {type: 'reopen', reason: 1}, {type: 'select', occurrenceId: '../other'}, + {type: 'recover', key: ''}])('task event %# cannot select a principal, route or arbitrary command', value => { + expect(decodeTaskIntent(value)).toBeNull(); +}); +test('closed task events preserve semantic user inputs and exact material quantities', () => { + const inputs = [{type: 'refresh'}, {type: 'more'}, {type: 'claim'}, {type: 'select', occurrenceId: 'work-1'}, + {type: 'create', title: ' Clean 🧹 ', description: '', priority: 'high'}, {type: 'complete', materialUsages: null}, + {type: 'complete', materialUsages: [{line_id: 'line-1', quantity: '3.333333'}]}, + {type: 'verify', decision: 'approved', reason: ''}, {type: 'verify', decision: 'rejected', reason: 'Not done'}, + {type: 'reopen', reason: 'Repeat'}, {type: 'recover', key: 'original'}, {type: 'dismiss', key: 'original'}]; + for (const value of inputs) expect(decodeTaskIntent(value)).toEqual(value); +}); +test('public fragment navigation chooses Tasks by default and preserves Household across a reload', async () => { + const f = fixture(); f.phase('active'); const {app, mounted} = await f.open(); expect(app.section).toBe('tasks'); + f.page.location.href += '#household'; f.page.dispatchEvent(new Event('hashchange')); await app.updateComplete; expect(app.section).toBe('household'); + const next = await f.open(); expect(next.app.section).toBe('household'); mounted.dispose(); + f.page.location.href = f.page.location.href.replace('#household', '#unknown'); f.page.dispatchEvent(new Event('hashchange')); await next.app.updateComplete; + expect(next.app.section).toBe('tasks'); expect(f.putRequests()).toBe(0); expect(f.postRequests()).toBe(0); +}); +test('the mounted task surface routes only owned semantic intents to its application controller', async () => { + const f = fixture(); f.phase('active'); const {app} = await f.open(); expect(app.tasks?.access?.memberId).toBe('identity-1'); + const spies = {create: vi.spyOn(TaskController.prototype, 'create').mockResolvedValue(undefined), + more: vi.spyOn(TaskController.prototype, 'more').mockResolvedValue(undefined), select: vi.spyOn(TaskController.prototype, 'select').mockResolvedValue(undefined), + claim: vi.spyOn(TaskController.prototype, 'claim').mockResolvedValue(undefined), complete: vi.spyOn(TaskController.prototype, 'complete').mockResolvedValue(undefined), + verify: vi.spyOn(TaskController.prototype, 'verify').mockResolvedValue(undefined), reopen: vi.spyOn(TaskController.prototype, 'reopen').mockResolvedValue(undefined), + recover: vi.spyOn(TaskController.prototype, 'recover').mockResolvedValue(undefined), dismiss: vi.spyOn(TaskController.prototype, 'dismiss').mockResolvedValue(undefined), + refresh: vi.spyOn(TaskController.prototype, 'refresh').mockResolvedValue(undefined)}; + const send = (detail: unknown, target: EventTarget = app) => target.dispatchEvent(new CustomEvent('domus-task-intent', {detail, bubbles: true, composed: true})); + try { + const child = document.createElement('span'); app.append(child); send({type: 'create', title: 'Other target', description: '', priority: 'normal'}, child); + send({type: 'create', title: 'Forged actor', description: '', priority: 'normal', actor: 'other'}); await Promise.resolve(); expect(spies.create).not.toHaveBeenCalled(); + const cases: readonly [unknown, keyof typeof spies, readonly unknown[]][] = [ + [{type: 'create', title: 'Clean', description: 'Wipe', priority: 'high'}, 'create', ['Clean', 'Wipe', 'high']], + [{type: 'more'}, 'more', []], [{type: 'select', occurrenceId: 'work-1'}, 'select', ['work-1']], [{type: 'claim'}, 'claim', []], + [{type: 'complete', materialUsages: null}, 'complete', [null]], [{type: 'verify', decision: 'approved', reason: ''}, 'verify', ['approved', '']], + [{type: 'reopen', reason: 'Repeat'}, 'reopen', ['Repeat']], [{type: 'recover', key: 'original'}, 'recover', ['original']], + [{type: 'dismiss', key: 'original'}, 'dismiss', ['original']], [{type: 'refresh'}, 'refresh', []], + ]; + for (const [detail, method, args] of cases) {send(detail); await vi.waitFor(() => expect(app.view?.busy).toBe(false)); expect(spies[method]).toHaveBeenCalledWith(...args);} + expect(f.putRequests()).toBe(0); expect(f.postRequests()).toBe(0); + } finally {for (const spy of Object.values(spies)) spy.mockRestore();} +}); +test('an unexpected task delegation failure closes the runtime and shows the bounded failure view', async () => { + const f = fixture(); f.phase('active'); const {app} = await f.open(); + const spy = vi.spyOn(TaskController.prototype, 'create').mockRejectedValue(Error('private delegation fault')); + try {app.dispatchEvent(new CustomEvent('domus-task-intent', {detail: {type: 'create', title: 'Clean', description: '', priority: 'normal'}})); + await vi.waitFor(() => expect(document.querySelector('#domus-entry-error')).not.toBeNull()); + expect(document.querySelector('#domus-entry-error')!.textContent).not.toContain('private delegation fault'); + } finally {spy.mockRestore();} +}); diff --git a/frontend/tests/task-component.test.ts b/frontend/tests/task-component.test.ts new file mode 100644 index 0000000..f6af2f3 --- /dev/null +++ b/frontend/tests/task-component.test.ts @@ -0,0 +1,219 @@ +import {afterEach, expect, test} from 'vitest'; +import {taskInstant, taskMessage} from '../src/presentation/task-messages'; +import type {TaskRecord} from '../src/application/task-ports'; +import type {TaskView} from '../src/application/task-controller'; +import {revisionValue} from '../src/domain/administration'; +import type {DomusTasks, TaskIntent} from '../src/presentation/domus-tasks'; +import '../src/presentation/domus-tasks'; +const revision = revisionValue('1'); +const work = {occurrenceId: 'work-1', definitionId: 'def-1', definitionRevision: revision, title: 'Clean 🧹', description: 'Wipe then dry', + priority: 'normal' as const, state: 'available' as const, revision, assigneeIds: [], dueAt: null, currentCycleId: null, + allowedActions: ['claim', 'complete'], cycle: null, materials: [], materialRecording: 'review_on_completion' as const, dependencyReviewRequired: false, pendingMaterials: false}; +const context = {installationId: '01234567-89ab-cdef-0123-456789abcdef', channel: 'edge' as const, subject: 'a'.repeat(32)}; +function view(patch: Partial = {}): TaskView { + return {phase: 'ready', busy: false, session: {context, deployment: 'native', access: 'full', serverTime: 1000, expiresAt: 100000, householdApiAvailable: true}, + access: {householdId: 'private-home-id', memberId: 'private-member-id', canCreate: true}, scopeKey: 'same-verified-scope', + readOnly: false, page: {items: [work], nextCursor: null}, detail: null, records: [], outcome: null, notice: null, ...patch}; +} +const mounted: DomusTasks[] = []; +afterEach(() => {for (const element of mounted.splice(0)) element.remove();}); +async function mount(value: TaskView | null = view(), locale: 'en' | 'es' = 'en') { + const element = document.createElement('domus-tasks'); element.view = value; element.language = locale; + const intents: TaskIntent[] = []; element.addEventListener('domus-task-intent', event => intents.push((event as CustomEvent).detail)); + document.body.append(element); mounted.push(element); await element.updateComplete; + const root = element.shadowRoot!, button = (label: string) => [...root.querySelectorAll('button')].find(value => value.textContent?.trim() === label)!; + const input = async (id: string, value: string) => {const field = root.querySelector('#' + id)!; + field.value = value; field.dispatchEvent(new InputEvent('input', {bubbles: true})); await element.updateComplete;}; + return {element, root, intents, button, input, text: () => root.textContent!}; +} +test('usable task creation preserves literal input and emits one semantic intent', async () => { + const f = await mount(); expect(f.root.querySelector('#task-priority')!.value).toBe('normal'); + await f.input('task-title', ' Clean 🧹 '); await f.input('task-description', 'Wipe then dry'); + f.root.querySelector('form')!.dispatchEvent(new SubmitEvent('submit', {bubbles: true, cancelable: true})); + expect(f.intents).toEqual([{type: 'create', title: ' Clean 🧹 ', description: 'Wipe then dry', priority: 'normal'}]); + expect(f.text()).not.toContain('private-home-id'); expect(f.text()).not.toContain('private-member-id'); +}); +test('list content is inert, and selecting work opens its actual task rather than an invented route', async () => { + const f = await mount(); expect(f.root.querySelector('b,script,img')).toBeNull(); + f.button('Clean 🧹').click(); expect(f.intents).toEqual([{type: 'select', occurrenceId: 'work-1'}]); + expect(f.root.querySelector('a')).toBeNull(); +}); +test('completion and claiming use only the current server-derived affordances', async () => { + const f = await mount(view({detail: work})); f.button('Claim task').click(); + f.root.querySelector('#complete-form')!.dispatchEvent(new SubmitEvent('submit', {cancelable: true})); + expect(f.intents).toEqual([{type: 'claim'}, {type: 'complete', materialUsages: null}]); + f.element.view = view({detail: {...work, allowedActions: []}}); await f.element.updateComplete; + expect(f.button('Claim task')).toBeUndefined(); expect(f.button('Mark done now')).toBeUndefined(); +}); +test('restricted completion pending adult approval is explicit and review actions stay separate', async () => { + const f = await mount(view({detail: {...work, state: 'completed', allowedActions: ['approve', 'reject', 'reopen'], + cycle: {cycleId: 'cycle-1', verification: 'pending', performedAt: '1000', performerIds: ['member-1']}, dependencyReviewRequired: true, pendingMaterials: true}}), 'es'); + expect(f.text()).toContain('Pendiente de aprobación'); expect(f.text()).toContain('Los materiales necesitan revisión'); + await f.input('task-reason', 'Revisado'); f.button('Aprobar').click(); f.button('Rechazar').click(); f.button('Reabrir tarea').click(); + expect(f.intents).toEqual([{type: 'verify', decision: 'approved', reason: 'Revisado'}, {type: 'verify', decision: 'rejected', reason: 'Revisado'}, {type: 'reopen', reason: 'Revisado'}]); +}); +test('read-only access and unconfirmed storage block mutations while task reads remain visible', async () => { + for (const patch of [{readOnly: true}, {records: null}, {busy: true}]) { + const f = await mount(view({...patch, detail: work})); + expect(f.button('Create task').disabled).toBe(true); expect(f.button('Claim task').disabled).toBe(true); + expect(f.text()).toContain('Clean 🧹'); + } +}); +test('unverified/loading/closed states never render retained task content', async () => { + for (const value of [null, view({phase: 'loading'}), view({phase: 'closed'}), view({access: null, notice: 'forbidden'})]) { + const f = await mount(value); expect(f.text()).not.toContain('Clean 🧹'); expect(f.root.querySelector('input')).toBeNull(); + } +}); +test('a same-owner recheck retains a draft; another verified scope and closure retire it', async () => { + const f = await mount(); await f.input('task-title', 'Private draft'); f.element.view = view({phase: 'loading', scopeKey: null}); await f.element.updateComplete; + f.element.view = view(); await f.element.updateComplete; expect(f.root.querySelector('#task-title')!.value).toBe('Private draft'); + f.element.view = view({scopeKey: 'other-verified-scope'}); await f.element.updateComplete; expect(f.root.querySelector('#task-title')!.value).toBe(''); +}); +test('pagination and refresh emit bounded read intents', async () => { + const f = await mount(view({page: {items: [], nextCursor: 'opaque'}})); + expect(f.text()).toContain('No tasks on this page'); f.button('Next page').click(); f.button('Refresh tasks').click(); + expect(f.intents).toEqual([{type: 'more'}, {type: 'refresh'}]); +}); + + +test('task date formatting respects exact bounds and uses English fallback without inventing missing dates', () => { + expect(taskMessage('title', 'fr')).toBe('Tasks'); expect(taskMessage('title', 'es-MX')).toBe('Tareas'); + expect(taskInstant(null, 'es')).toBe('Sin fecha de vencimiento'); expect(taskInstant('0', 'en')).toContain('UTC'); + for (const value of ['9223372036854775807', '-1', 'not-a-time']) expect(taskInstant(value, 'en')).toBe('Time unavailable'); +}); +test('choosing a priority retains the visible value across language changes', async () => { + const f = await mount(); const select = f.root.querySelector('#task-priority')!; + select.value = 'urgent'; select.dispatchEvent(new Event('change', {bubbles: true})); await f.element.updateComplete; + f.element.language = 'es'; await f.element.updateComplete; expect(select.value).toBe('urgent'); + f.root.querySelector('form')!.dispatchEvent(new SubmitEvent('submit', {cancelable: true})); expect(f.intents[0]).toMatchObject({priority: 'urgent'}); +}); +test('a saved creation clears only the unchanged draft that was submitted', async () => { + const f = await mount(); await f.input('task-title', 'Submitted'); + f.root.querySelector('form')!.dispatchEvent(new SubmitEvent('submit', {cancelable: true})); + const saved = {kind: 'receipt' as const, stage: 'committed' as const, action: 'task.create' as const, operationId: 'op-1', occurrenceId: 'work-1', + revision, state: 'available' as const, cycle: null, archivedBy: null}; + f.element.view = view({outcome: saved}); await f.element.updateComplete; expect(f.root.querySelector('#task-title')!.value).toBe(''); + await f.input('task-title', 'Newer draft'); f.element.view = view({outcome: {...saved, operationId: 'old-recovery'}}); await f.element.updateComplete; + expect(f.root.querySelector('#task-title')!.value).toBe('Newer draft'); + f.element.view = view({outcome: {...saved, stage: 'historical', archivedBy: 'restore-1'}}); await f.element.updateComplete; + expect(f.text()).toContain('Its effect is absent from current state'); expect(f.root.querySelector('#task-title')!.value).toBe('Newer draft'); +}); +test('task material review accepts exact decimal input and zero without floating-point rounding', async () => { + const f = await mount(view({detail: {...work, materials: [{lineId: 'cleaner', label: 'Cleaner', plannedQuantity: '1', precision: 6}, {lineId: 'cloth', label: '', plannedQuantity: '2', precision: 6}]}}), 'es'); + expect(f.root.activeElement?.id).toBe('selected-task-title'); + await f.input('material-0', '0003,3300'); await f.input('material-1', '0.000'); + f.root.querySelector('#complete-form')!.dispatchEvent(new SubmitEvent('submit', {cancelable: true})); + expect(f.intents).toEqual([{type: 'complete', materialUsages: [{line_id: 'cleaner', quantity: '3.33'}, {line_id: 'cloth', quantity: '0'}]}]); +}); +test.each(['-1', '1e3', '', '1000000000000', '0.0000001', '1'.repeat(65)])( + 'unsupported material quantity %j focuses the actual invalid line and sends no command', async value => { + const f = await mount(view({detail: {...work, materials: [{lineId: 'first', label: 'First', plannedQuantity: '1', precision: 6}, {lineId: 'second', label: 'Second', plannedQuantity: '1', precision: 6}]}})); + await f.input('material-1', value); f.root.querySelector('#complete-form')!.dispatchEvent(new SubmitEvent('submit', {cancelable: true})); + await f.element.updateComplete; expect(f.intents).toEqual([]); expect(f.root.activeElement?.id).toBe('material-1'); + expect(f.root.querySelector('#material-0')!.getAttribute('aria-invalid')).toBe('false'); + expect(f.root.querySelector('#quantity-error')!.getAttribute('role')).toBe('alert'); + }); +test('separately recorded materials are not silently submitted as planned consumption', async () => { + const f = await mount(view({detail: {...work, materialRecording: 'separate_recording', materials: [{lineId: 'first', label: 'Cleaner', plannedQuantity: '1', precision: 6}]}})); + expect(f.text()).toContain('recorded separately'); expect(f.root.querySelector('[inputmode=decimal]')).toBeNull(); + f.root.querySelector('#complete-form')!.dispatchEvent(new SubmitEvent('submit', {cancelable: true})); expect(f.intents).toEqual([{type: 'complete', materialUsages: null}]); +}); +function savedRecord(stage: TaskRecord['stage']): TaskRecord { + return {key: 'private-original-key', scope: {context, householdId: 'private-home', memberId: 'private-member'}, createdAt: 100, expiresAt: 3600100, + command: {action: 'task.create', payload: {expected_revision: 0, title: 'Private original title', description: '', priority: 'normal'}}, stage, + operationId: stage === 'committed' || stage === 'historical' ? 'op-1' : null}; +} +test.each(['prepared', 'uncertain', 'committed', 'historical', 'rejected'] as const)( + 'saved %s request has explicit recovery/dismissal actions and no raw private payload', async stage => { + const f = await mount(view({records: [savedRecord(stage)]})); + expect(f.text()).not.toContain('private-original-key'); expect(f.text()).not.toContain('Private original title'); + expect(f.text()).not.toContain('private-home'); expect(f.text()).not.toContain(context.subject); + if (stage !== 'rejected') {f.button('Check saved request').click(); expect(f.intents.at(-1)).toEqual({type: 'recover', key: 'private-original-key'});} + if (stage !== 'prepared' && stage !== 'uncertain') {f.button('Dismiss saved request').click(); expect(f.intents.at(-1)).toEqual({type: 'dismiss', key: 'private-original-key'});} + else expect(f.button('Create task').disabled).toBe(true); + }); +test.each(['uncertain', 'rejected'] as const)('visible %s outcomes explain impact without claiming success', async kind => { + const f = await mount(view({outcome: kind === 'uncertain' ? {kind} : {kind, reason: 'conflict', correlationId: 'cor_private'}})); + expect(f.root.querySelector('[role=status]')).not.toBeNull(); expect(f.text()).toContain(kind === 'uncertain' ? 'Result not confirmed' : 'Change not accepted'); + expect(f.text()).not.toContain('cor_private'); +}); +test('an invalid creation or review focuses its corresponding field; retired views erase draft text', async () => { + const f = await mount(); f.element.view = view({notice: 'invalid_input'}); await f.element.updateComplete; expect(f.root.activeElement?.id).toBe('task-title'); + f.element.view = view({detail: {...work, allowedActions: ['reopen']}}); await f.element.updateComplete; await f.input('task-reason', 'Private draft'); + f.button('Reopen task').click(); f.element.view = view({detail: {...work, allowedActions: ['reopen']}, notice: 'invalid_input'}); await f.element.updateComplete; + expect(f.root.activeElement?.id).toBe('task-reason'); f.element.retired = true; await f.element.updateComplete; + expect(f.root.querySelector('input,textarea')).toBeNull(); f.element.retired = false; f.element.view = view(); await f.element.updateComplete; + expect(f.root.querySelector('#task-title')!.value).toBe(''); +}); +test('a pending event and a manually dispatched disabled form cannot request a mutation', async () => { + const f = await mount(view({busy: true, detail: work})); + f.root.querySelector('form')!.dispatchEvent(new SubmitEvent('submit', {cancelable: true})); expect(f.intents).toEqual([]); + f.element.view = view({records: null, detail: work}); await f.element.updateComplete; + f.root.querySelector('form')!.dispatchEvent(new SubmitEvent('submit', {cancelable: true})); expect(f.intents).toEqual([]); + f.element.view = view({phase: 'closed'}); await f.element.updateComplete; + f.root.querySelector('button')!.dispatchEvent(new MouseEvent('click')); expect(f.intents).toEqual([]); +}); + + +test('whole-unit materials refuse fractional quantities before completion', async () => { + const f = await mount(view({detail: {...work, materials: [{lineId: 'unit', label: 'Cloth', plannedQuantity: '1', precision: 0}]}})); + expect(f.text()).toContain('Use whole quantities'); await f.input('material-0', '0.5'); + f.root.querySelector('#complete-form')!.dispatchEvent(new SubmitEvent('submit', {cancelable: true})); await f.element.updateComplete; + expect(f.intents).toEqual([]); expect(f.root.activeElement?.id).toBe('material-0'); +}); +test('an unexpected unreviewed material line never substitutes its planned amount as actual usage', async () => { + const f = await mount(view({detail: {...work, description: ''}})); + f.element.view = view({detail: {...work, description: '', materials: [{lineId: 'new-line', label: 'New material', plannedQuantity: '2', precision: 6}]}}); + await f.element.updateComplete; expect(f.root.querySelector('#material-0')!.value).toBe(''); + f.root.querySelector('#complete-form')!.dispatchEvent(new SubmitEvent('submit', {cancelable: true})); await f.element.updateComplete; + expect(f.intents).toEqual([]); expect(f.root.querySelector('.description')).toBeNull(); +}); +test('a member without creation permission sees task reads; an unavailable list never claims an empty household', async () => { + const f = await mount(view({access: {householdId: 'home', memberId: 'member-1', canCreate: false}, page: null, notice: 'unavailable'})); + expect(f.root.querySelector('form')).toBeNull(); expect(f.text()).not.toContain('No tasks on this page'); expect(f.text()).toContain('Tasks could not be loaded'); +}); + +test.each(['a', '🧹'])('creation enforces Unicode code-point bounds without losing the draft (%s)', async character => { + const f = await mount(); f.root.querySelector('details.new-task')!.open = true; const form = f.root.querySelector('form')!; + await f.input('task-title', character.repeat(161)); + expect(f.root.querySelector('#task-title')!.getAttribute('aria-invalid')).toBe('true'); + expect(f.button('Create task').disabled).toBe(true); + form.dispatchEvent(new SubmitEvent('submit', {cancelable: true})); + expect(f.intents).toEqual([]); expect(f.root.activeElement?.id).toBe('task-title'); + expect(f.root.querySelector('#task-title')!.value).toBe(character.repeat(161)); + await f.input('task-title', character.repeat(160)); await f.input('task-description', character.repeat(4001)); + expect(f.root.querySelector('#task-description')!.getAttribute('aria-invalid')).toBe('true'); + form.dispatchEvent(new SubmitEvent('submit', {cancelable: true})); + expect(f.intents).toEqual([]); expect(f.root.activeElement?.id).toBe('task-description'); + await f.input('task-description', character.repeat(4000)); expect(f.button('Create task').disabled).toBe(false); + form.dispatchEvent(new SubmitEvent('submit', {cancelable: true})); + expect(f.intents).toEqual([{type: 'create', title: character.repeat(160), description: character.repeat(4000), priority: 'normal'}]); +}); +test.each(['a', '🧹'])('review enforces the same 1,000-code-point reason bound (%s)', async character => { + const f = await mount(view({detail: {...work, state: 'completed', allowedActions: ['approve', 'reject', 'reopen'], + cycle: {cycleId: 'cycle-1', verification: 'pending', performedAt: '1000', performerIds: ['member-1']}}})); + await f.input('task-reason', character.repeat(1001)); + expect(f.root.querySelector('#task-reason')!.getAttribute('aria-invalid')).toBe('true'); + for (const label of ['Approve', 'Reject', 'Reopen task']) expect(f.button(label).disabled).toBe(true); + await f.input('task-reason', character.repeat(1000)); + expect(f.button('Reject').disabled).toBe(false); f.button('Reject').click(); + expect(f.intents).toEqual([{type: 'verify', decision: 'rejected', reason: character.repeat(1000)}]); +}); + + +test.each(['en', 'es'] as const)('required review reasons prevent impossible actions in %s', async locale => { + const labels = locale === 'es' ? ['Aprobar', 'Rechazar', 'Reabrir tarea'] : ['Approve', 'Reject', 'Reopen task']; + const f = await mount(view({detail: {...work, state: 'completed', allowedActions: ['approve', 'reject', 'reopen']}}), locale); + for (const reason of ['', ' \t\n ']) { + await f.input('task-reason', reason); + expect(f.button(labels[0]).disabled).toBe(false); + for (const label of labels.slice(1)) {expect(f.button(label).disabled).toBe(true); f.button(label).click();} + expect(f.intents).toEqual([]); + } + f.button(labels[0]).click(); + expect(f.intents).toEqual([{type: 'verify', decision: 'approved', reason: ' \t\n '}]); + await f.input('task-reason', ' Correct the completion '); + for (const label of labels.slice(1)) {expect(f.button(label).disabled).toBe(false); f.button(label).click();} + expect(f.intents.slice(1)).toEqual([{type: 'verify', decision: 'rejected', reason: ' Correct the completion '}, + {type: 'reopen', reason: ' Correct the completion '}]); +}); diff --git a/frontend/tests/task-controller.test.ts b/frontend/tests/task-controller.test.ts new file mode 100644 index 0000000..0abe2d2 --- /dev/null +++ b/frontend/tests/task-controller.test.ts @@ -0,0 +1,238 @@ +import {expect, test, vi} from 'vitest'; +import {TaskController} from '../src/application/task-controller'; +import {TaskSessionJournal} from '../src/adapters/task-journal'; +import {JournalError} from '../src/domain/journal'; +import {revisionValue} from '../src/domain/administration'; +import {EntryReadError} from '../src/application/ports'; +import type {TaskDetail, TaskReceipt} from '../src/domain/tasks'; +import type {VerifiedEntrySession} from '../src/application/ports'; +import type {TaskServer} from '../src/application/task-ports'; +const context = {installationId: '01234567-89ab-cdef-0123-456789abcdef', channel: 'edge' as const, subject: 'a'.repeat(32)}; +const revision = revisionValue('1'); +const detail: TaskDetail = {occurrenceId: 'work-1', definitionId: 'def-1', definitionRevision: revision, title: 'Clean 🧹', + description: '', priority: 'normal', state: 'available', revision, assigneeIds: [], dueAt: null, currentCycleId: null, + allowedActions: ['claim', 'complete'], cycle: null, materials: [], materialRecording: 'review_on_completion', dependencyReviewRequired: false, pendingMaterials: false}; +const saved: TaskReceipt = {kind: 'receipt', stage: 'committed', action: 'task.create', operationId: 'op-1', occurrenceId: 'work-1', revision, state: 'available', cycle: null, archivedBy: null}; +const read = (value: T) => ({result: Promise.resolve(value), cancel: vi.fn()}); +function fixture() { + let now = 1000, sequence = 0; + const storage = new Map(); + const session = {context, deployment: 'native' as const, access: 'full' as 'full' | 'read_only', serverTime: now, expiresAt: 100000, householdApiAvailable: true}; + const identity = {session: vi.fn(() => read(session))}; + const server = {access: vi.fn(() => read({householdId: 'home', memberId: 'member-1', canCreate: true})), + list: vi.fn(() => read({items: [detail], nextCursor: null as string | null})), detail: vi.fn(() => read(detail)), + original: vi.fn(() => read(saved)), send: vi.fn(async () => saved)}; + const journal = new TaskSessionJournal({getItem: key => storage.get(key) ?? null, setItem: (key, value) => {storage.set(key, value);}, removeItem: key => {storage.delete(key);}}); + const clock = {anchor: vi.fn((value: number) => {now = value;}), now: vi.fn(() => now)}; + const entropy = {requestKey: vi.fn(() => 'key-' + ++sequence)}; + const controller = new TaskController('native', identity, server, journal, clock, entropy); + return {controller, server, identity, session, storage, journal, clock, entropy, setNow: (value: number) => {now = value;}, advance: () => {now = 100000;}}; +} +test('a real task workspace loads under fresh identity and creates title-only work with a saved original', async () => { + const f = fixture(); await f.controller.refresh(); expect(f.controller.view.page?.items[0].title).toBe('Clean 🧹'); + await f.controller.create(' New task 🧹 ', '', 'normal'); + expect(f.server.send).toHaveBeenCalledTimes(1); + expect(f.server.send.mock.calls[0]).toEqual([{key: 'key-1', command: {action: 'task.create', payload: {expected_revision: 0, title: ' New task 🧹 ', description: '', priority: 'normal'}}}, context]); + expect(f.controller.view.outcome).toEqual(saved); expect(f.controller.view.records?.[0]).toMatchObject({stage: 'committed', key: 'key-1', operationId: 'op-1'}); +}); +test('lost task replies block a second new key and recover by read without resending', async () => { + const f = fixture(); await f.controller.refresh(); f.server.send.mockResolvedValueOnce({kind: 'uncertain'}); + await f.controller.create('New', '', 'normal'); expect(f.controller.view.records?.[0].stage).toBe('uncertain'); + await f.controller.create('Duplicate', '', 'normal'); expect(f.server.send).toHaveBeenCalledTimes(1); + expect(f.controller.view.notice).toBe('previous_request_unresolved'); + await f.controller.recover('key-1'); expect(f.server.original).toHaveBeenCalledTimes(1); + expect(f.server.send).toHaveBeenCalledTimes(1); expect(f.controller.view.outcome).toEqual(saved); +}); +test('self completion uses the freshly verified member and actual server-anchored time', async () => { + const f = fixture(); await f.controller.refresh(); await f.controller.select('work-1'); + f.server.send.mockResolvedValueOnce({...saved, action: 'task.complete', state: 'completed', revision: revisionValue('2')}); + await f.controller.complete(null); + expect(f.server.send.mock.calls[0][0].command).toEqual({action: 'task.complete', occurrenceId: 'work-1', payload: { + expected_revision: '1', performer_ids: ['member-1'], performed_at: '1000', material_usages: null}}); +}); +test('invalid text, expired sessions and read-only grace cannot create work', async () => { + const f = fixture(); await f.controller.refresh(); await f.controller.create(' ', '', 'normal'); expect(f.server.send).not.toHaveBeenCalled(); + f.session.access = 'read_only'; await f.controller.refresh(); await f.controller.create('New', '', 'normal'); + expect(f.controller.view.notice).toBe('read_only'); expect(f.server.send).not.toHaveBeenCalled(); + f.advance(); f.controller.tick(); expect(f.controller.view.page).toBeNull(); expect(f.controller.view.notice).toBe('authentication_required'); +}); +test('a changed verified account cannot submit the previous account draft', async () => { + const f = fixture(); await f.controller.refresh(); f.identity.session.mockImplementation(() => read({...f.session, context: {...context, subject: 'b'.repeat(32)}})); + await f.controller.create('Private draft', '', 'normal'); expect(f.server.send).not.toHaveBeenCalled(); + expect(f.controller.view.notice).toBe('context_changed'); +}); +test('closing owns cancellation and fences a late callback', async () => { + const f = fixture(); let resolve!: (value: typeof f.session) => void; + const cancel = vi.fn(); f.identity.session.mockReturnValue({result: new Promise(done => {resolve = done;}), cancel}); + const pending = f.controller.refresh(); f.controller.close(); resolve(f.session); await pending; + expect(cancel).toHaveBeenCalledOnce(); expect(f.controller.view.phase).toBe('closed'); expect(f.server.list).not.toHaveBeenCalled(); +}); +test('a current permission denial removes task content and never submits a command', async () => { + const f = fixture(); await f.controller.refresh(); f.server.access.mockImplementation(() => ({result: Promise.reject(new EntryReadError('forbidden')), cancel: vi.fn()})); + await f.controller.create('New', '', 'normal'); expect(f.server.send).not.toHaveBeenCalled(); + expect(f.controller.view.page).toBeNull(); expect(f.controller.view.notice).toBe('forbidden'); +}); + + +test.each(['missing', 'expired', 'wrong_deployment', 'missing_api'])( + '%s identity cannot probe tasks or prepare writes', async kind => { + const f = fixture(); + f.identity.session.mockReturnValueOnce(read(kind === 'missing' ? null : {...f.session, + ...(kind === 'expired' ? {expiresAt: 1000} : {}), ...(kind === 'wrong_deployment' ? {deployment: 'standalone' as const} : {}), + ...(kind === 'missing_api' ? {householdApiAvailable: false} : {})})); + await f.controller.refresh(); expect(f.controller.view.page).toBeNull(); expect(f.server.access).not.toHaveBeenCalled(); + await f.controller.create('New', '', 'normal'); expect(f.server.send).not.toHaveBeenCalled(); + }); +test('unavailable or corrupt recovery storage allows queries and blocks new task effects', async () => { + const f = fixture(); vi.spyOn(f.journal, 'open').mockImplementation(() => {throw Error('private storage fault');}); + await f.controller.refresh(); expect(f.controller.view.page?.items).toHaveLength(1); expect(f.controller.view.records).toBeNull(); + await f.controller.create('New', '', 'normal'); expect(f.server.send).not.toHaveBeenCalled(); expect(f.controller.view.notice).toBe('journal_unavailable'); +}); +test('no task-creation grant and changed current action hints both refuse before posting', async () => { + const f = fixture(); await f.controller.refresh(); + f.server.access.mockReturnValue(read({householdId: 'home', memberId: 'member-1', canCreate: false})); + await f.controller.create('New', '', 'normal'); expect(f.controller.view.notice).toBe('action_not_allowed'); + await f.controller.select('work-1'); f.server.detail.mockReturnValue(read({...detail, allowedActions: []})); + await f.controller.claim(); expect(f.controller.view.notice).toBe('action_not_allowed'); expect(f.server.send).not.toHaveBeenCalled(); +}); +test('a concurrently changed task revision is shown for review without rebasing the submitted intent', async () => { + const f = fixture(); await f.controller.refresh(); await f.controller.select('work-1'); + f.server.detail.mockReturnValue(read({...detail, revision: revisionValue('2')})); + await f.controller.claim(); expect(f.controller.view.notice).toBe('conflict'); expect(f.controller.view.detail?.revision).toBe('2'); + expect(f.server.send).not.toHaveBeenCalled(); +}); +test.each(['household', 'member'])( + 'a changed canonical %s rejects the previous draft even for the same HA identity', async kind => { + const f = fixture(); await f.controller.refresh(); f.server.access.mockReturnValue(read({householdId: kind === 'household' ? 'other' : 'home', + memberId: kind === 'member' ? 'replacement' : 'member-1', canCreate: true})); + await f.controller.create('Private draft', '', 'normal'); expect(f.controller.view.notice).toBe('context_changed'); expect(f.server.send).not.toHaveBeenCalled(); + }); +test('paging uses the captured opaque cursor, and an authority change refuses its continuation', async () => { + const f = fixture(); await f.controller.more(); expect(f.identity.session).not.toHaveBeenCalled(); + f.server.list.mockReturnValue(read({items: [detail], nextCursor: 'opaque/+?'})); await f.controller.refresh(); + await f.controller.more(); expect(f.server.list.mock.calls.at(-1)).toEqual([context, 'opaque/+?']); expect(f.controller.view.detail).toBeNull(); + f.server.access.mockReturnValue(read({householdId: 'other', memberId: 'member-1', canCreate: true})); + const calls = f.server.list.mock.calls.length; await f.controller.more(); expect(f.server.list).toHaveBeenCalledTimes(calls); + expect(f.controller.view.notice).toBe('context_changed'); +}); +test('selection accepts only an identifier and does not carry a task into another verified scope', async () => { + const f = fixture(); await f.controller.select('../work'); expect(f.identity.session).not.toHaveBeenCalled(); + await f.controller.refresh(); f.server.access.mockReturnValue(read({householdId: 'other', memberId: 'member-1', canCreate: true})); + await f.controller.select('work-1'); expect(f.server.detail).not.toHaveBeenCalled(); expect(f.controller.view.notice).toBe('context_changed'); +}); +test('no selected work or cycle means no implicit claim, completion, review or reopening', async () => { + const f = fixture(); await f.controller.claim(); await f.controller.complete(null); await f.controller.verify('approved', ''); await f.controller.reopen('Repeat'); + await f.controller.refresh(); await f.controller.select('work-1'); await f.controller.verify('approved', ''); await f.controller.reopen('Repeat'); + expect(f.server.send).not.toHaveBeenCalled(); +}); +test.each(['approved', 'rejected', 'reopen'] as const)('current cycle %s uses its exact identity and revision', async decision => { + const f = fixture(), cycle = {cycleId: 'cycle-1', verification: 'pending' as const, performedAt: '90', performerIds: ['member-1']}; + f.server.detail.mockReturnValue(read({...detail, cycle, state: 'completed', allowedActions: ['approve', 'reject', 'reopen']})); + await f.controller.refresh(); await f.controller.select('work-1'); + if (decision === 'reopen') await f.controller.reopen('Repeat'); else await f.controller.verify(decision, 'Reviewed'); + expect(f.server.send).toHaveBeenCalledOnce(); const command = f.server.send.mock.calls[0][0].command; + expect(command).toMatchObject({action: decision === 'reopen' ? 'task.reopen' : 'task.verify', occurrenceId: 'work-1', payload: {expected_revision: '1', cycle_id: 'cycle-1'}}); +}); +test('an exhausted or unavailable request key and a failed journal write cannot reach the server', async () => { + const f = fixture(); await f.controller.refresh(); f.entropy.requestKey.mockImplementationOnce(() => {throw Error('entropy unavailable');}); + await f.controller.create('New', '', 'normal'); expect(f.controller.view.notice).toBe('journal_unavailable'); + vi.spyOn(f.journal, 'prepare').mockImplementationOnce(() => {throw Error('storage changed');}); + await f.controller.create('New', '', 'normal'); expect(f.server.send).not.toHaveBeenCalled(); +}); +test('failure to save a returned receipt retains its original prepared key and reports recovery storage failure', async () => { + const f = fixture(); await f.controller.refresh(); vi.spyOn(f.journal, 'record').mockImplementationOnce(() => {throw Error('storage unavailable');}); + await f.controller.create('New', '', 'normal'); expect(f.controller.view.outcome).toEqual(saved); + expect(f.controller.view.notice).toBe('journal_unavailable'); expect(f.controller.view.records?.[0].stage).toBe('prepared'); + await f.controller.recover('key-1'); expect(f.server.original).toHaveBeenCalledOnce(); expect(f.controller.view.records?.[0].stage).toBe('committed'); +}); +test.each(['authentication_required', 'forbidden', 'conflict'] as const)('current write rejection %s is recorded without retry', async reason => { + const f = fixture(); await f.controller.refresh(); f.server.send.mockResolvedValueOnce({kind: 'rejected', reason, correlationId: 'cor_safe'}); + await f.controller.create('New', '', 'normal'); expect(f.server.send).toHaveBeenCalledOnce(); + if (reason === 'conflict') expect(f.controller.view.records?.[0].stage).toBe('rejected'); + else {expect(f.controller.view.notice).toBe(reason); expect(f.controller.view.page).toBeNull();} +}); +test('a slow posted command can finish after closure without repopulating private data or sending twice', async () => { + const f = fixture(); await f.controller.refresh(); let resolve!: (value: TaskReceipt) => void; + f.server.send.mockImplementationOnce(() => new Promise(done => {resolve = done;})); + const pending = f.controller.create('New', '', 'normal'); await vi.waitFor(() => expect(f.server.send).toHaveBeenCalledOnce()); + await f.controller.create('Duplicate', '', 'normal'); f.controller.close(); resolve(saved); await pending; + expect(f.controller.view.phase).toBe('closed'); expect(f.controller.view.outcome).toBeNull(); expect(f.server.send).toHaveBeenCalledOnce(); + await f.controller.refresh(); f.controller.tick(); expect(f.controller.view.phase).toBe('closed'); +}); +test('session expiry during a query or after a posted result hides facts and keeps original evidence', async () => { + const f = fixture(); let resolve!: (value: {items: TaskDetail[]; nextCursor: null}) => void; + f.server.list.mockReturnValueOnce({result: new Promise(done => {resolve = done;}), cancel: vi.fn()}); + const pending = f.controller.refresh(); await vi.waitFor(() => expect(f.server.list).toHaveBeenCalledOnce()); + f.advance(); resolve({items: [detail], nextCursor: null}); await pending; + expect(f.controller.view.notice).toBe('authentication_required'); expect(f.controller.view.page).toBeNull(); + await f.controller.refresh(); f.server.send.mockImplementationOnce(async () => {f.advance(); return saved;}); + await f.controller.create('New', '', 'normal'); expect(f.controller.view.notice).toBe('authentication_required'); +}); +test('a clock fault refuses completion and a later unknown lease retires the view', async () => { + const f = fixture(); await f.controller.refresh(); await f.controller.select('work-1'); + f.clock.now.mockImplementationOnce(() => {throw Error('clock unavailable');}); await f.controller.complete(null); + expect(f.server.send).not.toHaveBeenCalled(); expect(f.controller.view.notice).toBe('unavailable'); + f.clock.now.mockImplementationOnce(() => {throw Error('clock unavailable');}); f.controller.tick(); expect(f.controller.view.page).toBeNull(); +}); +test('a missing or expired original is not looked up or replaced, and unresolved dismissal fails safely', async () => { + const f = fixture(); await f.controller.recover('missing'); expect(f.identity.session).not.toHaveBeenCalled(); + await f.controller.refresh(); f.server.send.mockResolvedValueOnce({kind: 'uncertain'}); await f.controller.create('New', '', 'normal'); + await f.controller.dismiss('key-1'); expect(f.controller.view.notice).toBe('request_unavailable'); + f.storage.clear(); await f.controller.recover('key-1'); expect(f.controller.view.notice).toBe('request_changed'); expect(f.server.original).not.toHaveBeenCalled(); +}); +test('resolved originals can be explicitly dismissed without changing tasks or server history', async () => { + const f = fixture(); await f.controller.refresh(); await f.controller.create('New', '', 'normal'); await f.controller.dismiss('key-1'); + expect(f.controller.view.records).toEqual([]); expect(f.controller.view.page?.items).toHaveLength(1); expect(f.server.send).toHaveBeenCalledOnce(); +}); +test.each(['recover', 'dismiss'] as const)('a changed owner cannot %s an earlier private task request', async method => { + const f = fixture(); await f.controller.refresh(); await f.controller.create('New', '', 'normal'); + f.server.access.mockReturnValue(read({householdId: 'home', memberId: 'replacement', canCreate: true})); + await f.controller[method]('key-1'); expect(f.controller.view.notice).toBe('context_changed'); expect(f.server.original).not.toHaveBeenCalled(); +}); +test('retired cancellation errors and unavailable server reads cannot disclose internal diagnostics', async () => { + const f = fixture(); let resolve!: (value: VerifiedEntrySession | null) => void; + f.identity.session.mockReturnValueOnce({result: new Promise(done => {resolve = done;}), cancel: vi.fn(() => {throw Error('private cancel fault');})}); + const pending = f.controller.refresh(); f.controller.close(); resolve(f.session); await pending; expect(f.controller.view.phase).toBe('closed'); + const next = fixture(); next.identity.session.mockReturnValueOnce({result: Promise.reject(Error('private transport')), cancel: vi.fn()}); + await next.controller.refresh(); expect(next.controller.view.notice).toBe('unavailable'); expect(JSON.stringify(next.controller.view)).not.toContain('private transport'); +}); +test('expired journal entries are pruned without a new proof request or a busy timer loop', async () => { + const f = fixture(); f.session.expiresAt = 10_000_000; await f.controller.refresh(); await f.controller.create('New', '', 'normal'); + const count = f.identity.session.mock.calls.length; f.setNow(3_601_000); f.controller.tick(); + expect(f.controller.view.records).toEqual([]); expect(f.controller.view.page?.items).toHaveLength(1); expect(f.identity.session).toHaveBeenCalledTimes(count); +}); + + +test('full recovery storage asks the user to review saved requests without evicting them or posting', async () => { + const f = fixture(); await f.controller.refresh(); + vi.spyOn(f.journal, 'prepare').mockImplementationOnce(() => {throw new JournalError('journal_capacity');}); + await f.controller.create('New', '', 'normal'); expect(f.controller.view.notice).toBe('journal_capacity'); expect(f.server.send).not.toHaveBeenCalled(); +}); +test('expiry cleanup failure hides recovery records and never renews session proof', async () => { + const f = fixture(); f.session.expiresAt = 10_000_000; await f.controller.refresh(); await f.controller.create('New', '', 'normal'); + vi.spyOn(f.journal, 'open').mockImplementationOnce(() => {throw Error('storage changed');}); f.setNow(3601000); f.controller.tick(); + expect(f.controller.view.records).toBeNull(); expect(f.controller.view.notice).toBe('journal_unavailable'); +}); + + +test('a detail response for a different task cannot authorize a command against the selected one', async () => { + const f = fixture(); await f.controller.refresh(); await f.controller.select('work-1'); f.server.detail.mockReturnValue(read({...detail, occurrenceId: 'other'})); + await f.controller.claim(); expect(f.server.send).not.toHaveBeenCalled(); expect(f.controller.view.notice).toBe('action_not_allowed'); +}); +test.each(['journal_too_large', 'duplicate_request'] as const)('journal %s refuses without posting or inventing a new key', async code => { + const f = fixture(); await f.controller.refresh(); vi.spyOn(f.journal, 'prepare').mockImplementationOnce(() => {throw new JournalError(code);}); + await f.controller.create('New', '', 'normal'); expect(f.controller.view.notice).toBe(code === 'journal_too_large' ? 'journal_capacity' : 'journal_unavailable'); + expect(f.server.send).not.toHaveBeenCalled(); expect(f.entropy.requestKey).toHaveBeenCalledOnce(); +}); + +test('Next page replaces one bounded page and Refresh tasks returns to the first page with fresh authority', async () => { + const f = fixture(), first = {...detail, occurrenceId: 'work-first'}, next = {...detail, occurrenceId: 'work-next'}; + f.server.list.mockReturnValueOnce(read({items: [first], nextCursor: 'next-page'})); + await f.controller.refresh(); expect(f.controller.view.page?.items.map(item => item.occurrenceId)).toEqual(['work-first']); + f.server.list.mockReturnValueOnce(read({items: [next], nextCursor: null})); + await f.controller.more(); expect(f.controller.view.page?.items.map(item => item.occurrenceId)).toEqual(['work-next']); + expect(f.server.list.mock.calls.at(-1)).toEqual([context, 'next-page']); + f.server.list.mockReturnValueOnce(read({items: [first], nextCursor: 'next-page'})); + await f.controller.refresh(); expect(f.controller.view.page?.items.map(item => item.occurrenceId)).toEqual(['work-first']); + expect(f.server.list.mock.calls.at(-1)).toEqual([context, null]); expect(f.identity.session).toHaveBeenCalledTimes(3); +}); diff --git a/frontend/tests/task-http.test.ts b/frontend/tests/task-http.test.ts new file mode 100644 index 0000000..ba06f1f --- /dev/null +++ b/frontend/tests/task-http.test.ts @@ -0,0 +1,209 @@ +import {afterEach, expect, test, vi} from 'vitest'; +import {HTTP_TIMEOUT_MS} from '../src/adapters/http-transport'; +import {HttpTaskServer} from '../src/adapters/task-http'; +import {taskCommand} from '../src/domain/tasks'; +import type {TaskCommand} from '../src/domain/tasks'; +import type {JournalContext} from '../src/domain/journal'; + +const context: JournalContext = {installationId: '01234567-89ab-cdef-0123-456789abcdef', channel: 'edge', subject: 'a'.repeat(32)}; +const REV = '9007199254740993', NEXT = '9007199254740994'; +const rules = {claim_allowed: true, eligible_roles: ['administrator', 'adult', 'restricted'], verification: 'role_default', + adult_performer_may_approve: true, task_scoped_material_consumption: true, material_recording: 'review_on_completion', point_value_minor: 0, dependency_gate: 'performed'}; +const summary = () => ({occurrence_id: 'work-1', definition_id: 'definition-1', definition_revision: 1, + title: 'Clean 🧹', state: 'available', revision: '$revision', assignee_ids: [], due_at: null, current_cycle_id: null}); +const cycle = () => ({cycle_id: 'cycle-1', household_id: 'home', occurrence_id: 'work-1', recorded_by: 'member-1', + performers: [{member_id: 'member-1', role: 'adult'}], rules, definition_revision: 1, task_policy_revision: 1, + identity_policy_revision: 1, performed_at: '$revision', recorded_at: '$revision', verification: 'none', verified_by: null, + verified_at: null, verification_reason: '', closed_at: null, revision: 1}); +const detail = () => ({work: {occurrence_id: 'work-1', household_id: 'home', definition_snapshot: {definition_id: 'definition-1', household_id: 'home', + content: {title: 'Clean 🧹', description: 'Wipe\nthen dry', priority: 'normal', tags: []}, created_at: 100, overrides: {}, state: 'active', revision: 1, materials: []}, + created_at: 100, state: 'available', revision: '$revision', assignee_ids: [], prerequisite_ids: [], required_subtask_ids: [], due_at: null, + current_cycle_id: null, current_cycle_revision: null, state_reason: ''}, rules, task_policy_revision: 1, requirements: [], cycle: null as null | ReturnType, + allowed_actions: ['claim', 'complete', 'assign', 'dependencies.update', 'transition:in_progress'], consumer_delivery: 'unconfirmed', materials: [], + dependency_reviews: {items: [], next_cursor: null}, dependency_review_required: false}); +const rawCommands = [ + {action: 'task.create', payload: {expected_revision: 0, title: ' Clean 🧹 ', description: '', priority: 'normal'}}, + {action: 'task.claim', occurrenceId: 'work-1', payload: {expected_revision: REV}}, + {action: 'task.complete', occurrenceId: 'work-1', payload: {expected_revision: REV, performer_ids: ['member-1'], performed_at: REV, material_usages: null}}, + {action: 'task.verify', occurrenceId: 'work-1', payload: {expected_revision: REV, cycle_id: 'cycle-1', decision: 'approved', reason: ''}}, + {action: 'task.reopen', occurrenceId: 'work-1', payload: {expected_revision: REV, cycle_id: 'cycle-1', reason: 'Repeat work'}}, +]; +const commands = rawCommands.map(raw => {const value = taskCommand(raw); if (!value) throw Error('invalid test command'); return value;}); +const paths = ['definitions', 'occurrences/work-1/claim', 'occurrences/work-1/complete', 'occurrences/work-1/verify', 'occurrences/work-1/reopen']; +function receipt(index: number, lookup = false) { + return {state: 'committed', archived_by: null, operation_id: 'op-1', created_at: 100, message: 'Saved.', action_required: 'Review.', + result: {definition_id: 'definition-1', definition_revision: 1, occurrence_id: 'work-1', revision: index === 0 ? 1 : '$next', + state: index === 2 || index === 3 ? 'completed' : 'assigned', assignee_ids: ['member-1'], state_reason: '', prerequisite_ids: [], required_subtask_ids: [], + ...(index >= 2 ? {cycle: {...cycle(), verification: index === 3 ? 'approved' : 'none'}, consumer_delivery: 'unconfirmed'} : {}), + ...(index === 4 ? {compensates_cycle_id: 'cycle-1', dependency_review_required: true} : {})}, + ...(lookup ? {action: commands[index].action, command_key: 'original-key', delivery: 'pending'} : {})}; +} +function encode(value: unknown) {return JSON.stringify(value).replaceAll('"$revision"', REV).replaceAll('"$next"', NEXT);} +function reply(value: unknown, status = 200) {return new Response(encode(value), {status, headers: {'Content-Type': 'application/json', 'Cache-Control': 'no-store'}});} +const failure = (status: number) => reply({state: 'blocked', code: 'request_forbidden', message: 'Private server detail.', action_required: 'Review.', correlation_id: 'cor_safe'}, status); +function fixture(mode: 'native' | 'standalone' = 'standalone') { + const target = {deployment: mode, applicationBase: mode === 'native' ? '/api/hassio_ingress/' + 'b'.repeat(43) + '/' : '/', origin: 'https://domus.example'}; + const transport = vi.fn(async (_path: string, _init: RequestInit): Promise => reply({})); + const network = async (path: string, init: RequestInit) => {const value = await transport(path, init); + if (!value.url) Object.defineProperty(value, 'url', {value: target.origin + path}); return value;}; + return {target, transport, server: new HttpTaskServer(target, network)}; +} +afterEach(() => vi.useRealTimers()); + +for (const mode of ['native', 'standalone'] as const) { + test(`${mode}: list/detail project literal text and exact values, with server-owned allowed actions`, async () => { + const f = fixture(mode); f.transport.mockResolvedValueOnce(reply({items: [summary()], next_cursor: 'cursor/+?'})).mockResolvedValueOnce(reply(detail())); + const page = await f.server.list(context, null).result; + expect(page.items[0]).toMatchObject({title: 'Clean 🧹', revision: REV, dueAt: null}); expect(page.nextCursor).toBe('cursor/+?'); + const work = await f.server.detail('work-1', 'home', context).result; + expect(work).toMatchObject({description: 'Wipe\nthen dry', allowedActions: ['claim', 'complete', 'assign', 'dependencies.update', 'transition:in_progress'], cycle: null}); + expect(Object.isFrozen(work)).toBe(true); expect(Object.isFrozen(work.allowedActions)).toBe(true); + for (const [path, init] of f.transport.mock.calls) {expect(path.startsWith(f.target.applicationBase + 'api/v1/tasks/')).toBe(true); + expect(init.method).toBe('GET'); expect(new Headers(init.headers).has('X-CSRF-Token')).toBe(false);} + }); + test.each(commands)(`${mode}: $action sends exactly once and recovers by protected GET`, async command => { + const index = commands.indexOf(command), f = fixture(mode), request = {key: 'original-key', command}; + f.transport.mockResolvedValueOnce(reply({csrf_token: 'c'.repeat(64)})).mockResolvedValueOnce(reply(receipt(index))).mockResolvedValueOnce(reply(receipt(index, true))); + expect(await f.server.send(request, context)).toMatchObject({kind: 'receipt', action: command.action, operationId: 'op-1', revision: index === 0 ? '1' : NEXT}); + const [path, init] = f.transport.mock.calls[1]; expect(path).toBe(f.target.applicationBase + 'api/v1/tasks/' + paths[index]); expect(init.method).toBe('POST'); + expect(new Headers(init.headers).get('Idempotency-Key')).toBe('original-key'); expect(new Headers(init.headers).get('X-CSRF-Token')).toBe('c'.repeat(64)); + expect(String(init.body)).toContain('"expected_revision":' + command.payload.expected_revision); + if (index === 0) expect(JSON.parse(String(init.body)).title).toBe(' Clean 🧹 '); + if (index === 2) expect(String(init.body)).toContain('"performed_at":' + REV); + expect(await f.server.original({...request, operationId: 'op-1'}, context).result).toMatchObject({kind: 'receipt', operationId: 'op-1'}); + expect(f.transport.mock.calls[2][0]).toBe(f.target.applicationBase + 'api/v1/tasks/requests/' + command.action + '/original-key'); + expect(f.transport.mock.calls[2][1].method).toBe('GET'); expect(f.transport).toHaveBeenCalledTimes(3); + }); +} + +test.each([401,403,409,422,503])('known task mutation rejection %s preserves only the safe reason', async status => { + const f = fixture(); f.transport.mockResolvedValueOnce(reply({csrf_token: 'c'.repeat(64)})).mockResolvedValueOnce(failure(status)); + expect(await f.server.send({key: 'key', command: commands[0]}, context)).toMatchObject({kind: 'rejected', correlationId: 'cor_safe'}); + expect(f.transport).toHaveBeenCalledTimes(2); +}); +test.each(['lost', 'malformed', 'wrong_revision', 'wrong_target', 'unexpected_status'])( + 'task write with %s reply remains uncertain and never resends', async kind => { + const f = fixture(), data = receipt(1); f.transport.mockResolvedValueOnce(reply({csrf_token: 'c'.repeat(64)})); + if (kind === 'lost') f.transport.mockRejectedValueOnce(new Error('private transport detail')); + else f.transport.mockResolvedValueOnce(reply(kind === 'malformed' ? {} : {...data, result: {...data.result, + ...(kind === 'wrong_revision' ? {revision: '$revision'} : {}), ...(kind === 'wrong_target' ? {occurrence_id: 'other'} : {})}}, kind === 'unexpected_status' ? 201 : 200)); + expect(await f.server.send({key: 'key', command: commands[1]}, context)).toEqual({kind: 'uncertain'}); expect(f.transport).toHaveBeenCalledTimes(2); + }); +test('unsupported original or command inputs cannot become a URL or get a fresh CSRF proof', async () => { + const f = fixture(); + expect(await f.server.send({key: '../key', command: commands[0]}, context)).toMatchObject({kind: 'rejected', reason: 'invalid_input'}); + expect(await f.server.send({key: 'key', command: {} as TaskCommand}, context)).toMatchObject({kind: 'rejected', reason: 'invalid_input'}); + await expect(f.server.original({key: '../key', command: commands[0], operationId: null}, context).result).rejects.toThrow('invalid_response'); + await expect(f.server.detail('../work', 'home', context).result).rejects.toThrow('invalid_response'); + expect(f.transport).not.toHaveBeenCalled(); +}); +test('original lookup binds action, key, known operation and target without disclosing an unrelated receipt', async () => { + for (const replacement of [{action: 'task.create'}, {command_key: 'other'}, {operation_id: 'other'}, {result: {...receipt(1).result, occurrence_id: 'other'}}]) { + const f = fixture(); f.transport.mockResolvedValueOnce(reply({...receipt(1, true), ...replacement})); + await expect(f.server.original({key: 'original-key', command: commands[1], operationId: 'op-1'}, context).result).rejects.toThrow('invalid_response'); + } +}); +test('detail rejects another household or task before publishing its content', async () => { + for (const work of [{...detail().work, household_id: 'other'}, {...detail().work, occurrence_id: 'other'}]) { + const f = fixture(); f.transport.mockResolvedValueOnce(reply({...detail(), work})); + await expect(f.server.detail('work-1', 'home', context).result).rejects.toThrow('invalid_response'); + } +}); + +for (const mode of ['native', 'standalone'] as const) test(`${mode}: task-specific access uses its own protected contract`, async () => { + const f = fixture(mode); f.transport.mockResolvedValueOnce(reply({household_id: 'home', member_id: 'member-1', can_create: false})); + expect(await f.server.access(context).result).toEqual({householdId: 'home', memberId: 'member-1', canCreate: false}); + expect(f.transport.mock.calls[0][0]).toBe(f.target.applicationBase + 'api/v1/tasks/access'); +}); + + +test('continuation tokens are encoded once and invalid limits never become URLs', async () => { + const f = fixture(); f.transport.mockResolvedValueOnce(reply({items: [], next_cursor: null})); + expect(await f.server.list(context, 'opaque/+?').result).toEqual({items: [], nextCursor: null}); + expect(f.transport.mock.calls[0][0]).toBe('/api/v1/tasks/occurrences?limit=25&cursor=opaque%2F%2B%3F'); + for (const value of ['', 'x'.repeat(513), 1]) await expect(f.server.list(context, value as never).result).rejects.toThrow('invalid_response'); + expect(f.transport).toHaveBeenCalledTimes(1); +}); +test.each(['denied', 'malformed', 'transport'])( + 'CSRF %s cannot send a task command or disclose its raw error', async kind => { + const f = fixture(); + if (kind === 'transport') f.transport.mockRejectedValueOnce(Error('private transport')); + else f.transport.mockResolvedValueOnce(kind === 'denied' ? failure(403) : reply({csrf_token: 'wrong'})); + const result = await f.server.send({key: 'key', command: commands[0]}, context); + expect(result.kind).toBe('rejected'); expect(f.transport).toHaveBeenCalledTimes(1); expect(JSON.stringify(result)).not.toContain('private transport'); + }); +test.each(['csrf', 'post'])( + 'deadline during %s fences the owned request without resending', async phase => { + vi.useFakeTimers(); const f = fixture(); + if (phase === 'post') f.transport.mockResolvedValueOnce(reply({csrf_token: 'c'.repeat(64)})); + f.transport.mockImplementation(() => new Promise(() => {})); + const pending = f.server.send({key: 'original', command: commands[0]}, context); + await vi.advanceTimersByTimeAsync(HTTP_TIMEOUT_MS); + expect(await pending).toMatchObject({kind: phase === 'post' ? 'uncertain' : 'rejected'}); + expect(f.transport).toHaveBeenCalledTimes(phase === 'post' ? 2 : 1); + }); +test('a CSRF deadline at each continuation never starts a task POST with an aborted signal', async () => { + vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout']}); + for (let delay = 0; delay < 20; delay++) { + const f = fixture(); + f.transport.mockImplementationOnce(async () => { + const stream = new ReadableStream({start(controller) { + controller.enqueue(new TextEncoder().encode(JSON.stringify({csrf_token: 'c'.repeat(64)}))); controller.close(); + const later = (remaining: number) => {if (remaining === 0) vi.advanceTimersByTime(HTTP_TIMEOUT_MS); else queueMicrotask(() => later(remaining - 1));}; + queueMicrotask(() => later(delay)); + }}); + return new Response(stream, {headers: {'Content-Type': 'application/json', 'Cache-Control': 'no-store'}}); + }); + f.transport.mockImplementationOnce(async (_path, init) => {expect(init.signal!.aborted).toBe(false); return reply(receipt(0));}); + await f.server.send({key: 'original', command: commands[0]}, context); + for (let index = 0; index < 20; index++) await Promise.resolve(); + expect(vi.getTimerCount()).toBe(0); expect(f.transport.mock.calls.filter(([, init]) => init.method === 'POST').length).toBeLessThanOrEqual(1); + } +}); +test('date instants, cycle identities and household-bound material lines remain exact and independent', async () => { + const f = fixture(), data = detail(); + const binding = {household_id: 'home', reference_id: 'binding-1', product_id: 'product-1', unit_id: 'unit-1', location_id: 'shelf-1', revision: 1, precision: 6, label: 'Cleaner'}; + const modified = {...data, work: {...data.work, due_at: 0, current_cycle_id: 'cycle-1', + definition_snapshot: {...data.work.definition_snapshot, materials: [{line_id: 'line-1', binding, planned_quantity: '3.333333'}]}}, cycle: cycle(), + materials: [{intent_id: 'intent-1', cycle_id: 'cycle-1', line_id: 'line-1', submitted_quantity: '3.333333', quantity: '3.333333', state: 'pending_regularization', + pending_reason: 'insufficient_stock', movement_id: null, effect_revision: 1, revision: 1, message: 'Work is saved.', action_required: 'Review stock.', + confirmed_quantity: null, confirmed_effect_revision: null, posted_at: null}]}; + f.transport.mockResolvedValueOnce(reply(modified)); + expect(await f.server.detail('work-1', 'home', context).result).toMatchObject({dueAt: '0', currentCycleId: 'cycle-1', pendingMaterials: true, + cycle: {cycleId: 'cycle-1', performedAt: REV}, materials: [{lineId: 'line-1', label: 'Cleaner', plannedQuantity: '3.333333'}]}); + f.transport.mockResolvedValueOnce(reply({...modified, materials: [{...modified.materials[0], state: 'not_used'}]})); + expect((await f.server.detail('work-1', 'home', context).result).pendingMaterials).toBe(false); + for (const bad of [{...modified, cycle: {...cycle(), household_id: 'other'}}, + {...modified, work: {...modified.work, current_cycle_id: 'other'}}, + {...modified, work: {...modified.work, definition_snapshot: {...modified.work.definition_snapshot, materials: [{line_id: 'line-1', binding, planned_quantity: '1.0'}]}}}]) { + f.transport.mockResolvedValueOnce(reply(bad)); await expect(f.server.detail('work-1', 'home', context).result).rejects.toThrow('invalid_response'); + } +}); +test('malformed or duplicate list facts do not become partial task content', async () => { + const f = fixture(); + for (const data of [{items: [{...summary(), title: ''}], next_cursor: null}, {items: [summary(), summary()], next_cursor: null}, + {items: [{...summary(), due_at: '$revision', current_cycle_id: 'cycle-1'}], next_cursor: null}]) { + f.transport.mockResolvedValueOnce(reply(data)); + if (data.items[0].title === '' || data.items.length === 2) await expect(f.server.list(context, null).result).rejects.toThrow('invalid_response'); + else expect((await f.server.list(context, null).result).items[0].dueAt).toBe(REV); + } +}); +test('historical originals remain absent effects and cannot claim pending or current delivery', async () => { + const f = fixture(), request = {key: 'original-key', command: commands[0], operationId: null}; + f.transport.mockResolvedValueOnce(reply({...receipt(0, true), state: 'historical', archived_by: 'restore-1', delivery: 'suppressed'})); + expect(await f.server.original(request, context).result).toMatchObject({stage: 'historical', archivedBy: 'restore-1'}); + for (const patch of [{state: 'historical', archived_by: null, delivery: 'suppressed'}, {state: 'historical', archived_by: 'restore-1', delivery: 'pending'}, {delivery: 'suppressed'}]) { + f.transport.mockResolvedValueOnce(reply({...receipt(0, true), ...patch})); await expect(f.server.original(request, context).result).rejects.toThrow('invalid_response'); + } +}); + + +test('material unit precision comes from the server and incompatible planned amounts fail closed', async () => { + const f = fixture(), data = detail(), binding = {household_id: 'home', reference_id: 'ref', product_id: 'product', unit_id: 'unit', location_id: 'shelf', revision: 1, precision: 0, label: 'Cloth'}; + for (const [planned_quantity, precision, valid] of [['2', 0, true], ['0.5', 0, false], ['1', 7, false]] as const) { + f.transport.mockResolvedValueOnce(reply({...data, work: {...data.work, definition_snapshot: {...data.work.definition_snapshot, + materials: [{line_id: 'line-1', binding: {...binding, precision}, planned_quantity}]}}})); + if (valid) expect((await f.server.detail('work-1', 'home', context).result).materials[0]).toMatchObject({precision: 0, plannedQuantity: '2'}); + else await expect(f.server.detail('work-1', 'home', context).result).rejects.toThrow('invalid_response'); + } +}); diff --git a/frontend/tests/task-journal.test.ts b/frontend/tests/task-journal.test.ts new file mode 100644 index 0000000..81c7d2e --- /dev/null +++ b/frontend/tests/task-journal.test.ts @@ -0,0 +1,120 @@ +import {expect, test} from 'vitest'; +import {TaskSessionJournal, TASK_JOURNAL_KEY} from '../src/adapters/task-journal'; +import type {OwnedStorage} from '../src/adapters/session-journal'; +import {taskCommand} from '../src/domain/tasks'; +const context = {installationId: '01234567-89ab-cdef-0123-456789abcdef', channel: 'edge' as const, subject: 'a'.repeat(32)}; +const scope = {context, householdId: 'home', memberId: 'member-1'}; +const command = taskCommand({action: 'task.create', payload: {expected_revision: 0, title: ' Clean 🧹 ', description: '', priority: 'normal'}})!; +function fixture() { + const data = new Map(); + const storage: OwnedStorage = {getItem: key => data.get(key) ?? null, setItem: (key, value) => {data.set(key, value);}, removeItem: key => {data.delete(key);}}; + return {data, storage, journal: new TaskSessionJournal(storage)}; +} +test('task originals survive a reload without changing key, text, scope, stage or expiry', () => { + const f = fixture(); expect(f.journal.open(scope, 100)).toEqual([]); + const original = f.journal.prepare('original', command, 101)[0]; + expect(original.command).toEqual(command); expect(original.expiresAt).toBe(3600101); + expect(Object.isFrozen(original)).toBe(true); + f.journal.record('original', 'uncertain', null, 102); f.journal.detach(); + const reopened = new TaskSessionJournal(f.storage).open(scope, 200)[0]; + expect(reopened).toEqual({...original, stage: 'uncertain'}); + expect([...f.data.keys()]).toEqual([TASK_JOURNAL_KEY]); + expect(f.data.get(TASK_JOURNAL_KEY)).not.toMatch(/csrf|token|cookie/u); +}); +test('each verified installation, channel, account, household and member has a private view', () => { + const f = fixture(); f.journal.open(scope, 100); f.journal.prepare('original', command, 100); + for (const next of [{...scope, householdId: 'other'}, {...scope, memberId: 'other'}, + {...scope, context: {...context, subject: 'b'.repeat(32)}}, {...scope, context: {...context, channel: 'stable' as const}}, + {...scope, context: {...context, installationId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'}}]) { + expect(f.journal.open(next, 101)).toEqual([]); + } + expect(f.journal.open(scope, 102)).toHaveLength(1); +}); +test('unresolved originals cannot be discarded, rebound or evicted to make capacity', () => { + const f = fixture(); f.journal.open(scope, 100); + for (let i = 0; i < 8; i++) f.journal.prepare('key-' + i, command, 100); + expect(() => f.journal.prepare('ninth', command, 100)).toThrow('journal_capacity'); + f.journal.open(scope, 100); + expect(() => f.journal.forget('key-0', 100)).toThrow('unresolved_request'); + f.journal.open(scope, 100); + expect(() => f.journal.prepare('key-0', command, 100)).toThrow(); +}); +test('a recovered operation keeps its identity and may later become historical', () => { + const f = fixture(); f.journal.open(scope, 100); f.journal.prepare('original', command, 100); + f.journal.record('original', 'committed', 'op-1', 101); + expect(f.journal.record('original', 'historical', 'op-1', 102)[0].stage).toBe('historical'); + expect(() => f.journal.record('original', 'committed', 'other', 103)).toThrow('original_operation_changed'); + f.journal.open(scope, 103); expect(f.journal.forget('original', 103)).toEqual([]); +}); +test('expiry does not renew across reopen and clock rollback cannot hide original evidence', () => { + const f = fixture(); f.journal.open(scope, 100); f.journal.prepare('original', command, 100); + expect(() => f.journal.open(scope, 99)).toThrow('invalid_journal_time'); + expect(f.journal.open(scope, 3600100)).toEqual([]); +}); +test('unsupported storage is a visible refusal to prepare a write', () => { + expect(() => new TaskSessionJournal(undefined).open(scope, 100)).toThrow('storage_unavailable'); + const f = fixture(); expect(() => f.journal.prepare('key', command, 100)).toThrow('journal_not_open'); + f.data.set(TASK_JOURNAL_KEY, '{"version":1,"version":2}'); + expect(() => f.journal.open(scope, 100)).toThrow('invalid_journal'); +}); +test('cross-tab changes and unconfirmed writes do not report persistence success', () => { + const f = fixture(); f.journal.open(scope, 100); f.data.set(TASK_JOURNAL_KEY, 'changed'); + expect(() => f.journal.prepare('key', command, 100)).toThrow('storage_changed'); + const discard: OwnedStorage = {getItem: () => null, setItem: () => {}, removeItem: () => {}}; + expect(() => new TaskSessionJournal(discard).open(scope, 100)).toThrow('storage_unconfirmed'); +}); + + +test.each([null, true, {}, [], -1, 1.5, Number.MAX_SAFE_INTEGER + 1])('invalid journal clock %# never rewrites existing data', value => { + const f = fixture(); expect(() => f.journal.open(scope, value as number)).toThrow('invalid_journal_time'); expect(f.data.size).toBe(0); +}); +test('a duplicate key cannot replace the original command before capacity is reached', () => { + const f = fixture(); f.journal.open(scope, 100); f.journal.prepare('original', command, 100); const prior = f.data.get(TASK_JOURNAL_KEY); + expect(() => f.journal.prepare('original', command, 101)).toThrow('duplicate_request'); expect(f.data.get(TASK_JOURNAL_KEY)).toBe(prior); +}); +test.each(['syntax', 'oversized_text', 'oversized_utf8', 'namespace', 'version', 'duplicate_record', 'future_record', 'changed_expiry', + 'invalid_stage', 'prepared_operation', 'missing_known_operation', 'unknown_command'])( + 'corrupt task recovery input %s fails without clearing or repairing its original bytes', kind => { + const f = fixture(); f.journal.open(scope, 100); f.journal.prepare('original', command, 100); + const raw = f.data.get(TASK_JOURNAL_KEY)!, data = JSON.parse(raw); + if (kind === 'namespace') data.namespace = 'administration'; + if (kind === 'version') data.version = 2; + if (kind === 'duplicate_record') data.records.push(data.records[0]); + if (kind === 'future_record') data.records[0].createdAt = 101; + if (kind === 'changed_expiry') data.records[0].expiresAt++; + if (kind === 'invalid_stage') data.records[0].stage = 'future'; + if (kind === 'prepared_operation') data.records[0].operationId = 'forged'; + if (kind === 'missing_known_operation') data.records[0].stage = 'committed'; + if (kind === 'unknown_command') data.records[0].command.action = 'future'; + const corrupted = kind === 'syntax' ? '{' : kind === 'oversized_text' ? ' '.repeat(65537) + : kind === 'oversized_utf8' ? '😀'.repeat(17000) : JSON.stringify(data); + f.data.set(TASK_JOURNAL_KEY, corrupted); expect(() => f.journal.open(scope, 101)).toThrow(); expect(f.data.get(TASK_JOURNAL_KEY)).toBe(corrupted); + }); +test('the journal enforces UTF-8 capacity without evicting earlier resolved or uncertain work', () => { + const f = fixture(), large = taskCommand({action: 'task.create', payload: {expected_revision: 0, title: 'New', description: '😀'.repeat(4000), priority: 'normal'}})!; + f.journal.open(scope, 100); for (let i = 0; i < 3; i++) f.journal.prepare('key-' + i, large, 100); + const prior = f.data.get(TASK_JOURNAL_KEY); expect(() => f.journal.prepare('too-large', large, 100)).toThrow('journal_too_large'); + expect(f.data.get(TASK_JOURNAL_KEY)).toBe(prior); +}); +test('unavailable and malformed providers never establish a confirmed recovery record', () => { + for (const getItem of [() => {throw Error('private fault');}, () => undefined as never]) { + const f = fixture(); expect(() => new TaskSessionJournal({...f.storage, getItem}).open(scope, 100)).toThrow('storage_unavailable'); + } + const f = fixture(); expect(() => new TaskSessionJournal({...f.storage, setItem: () => {throw Error('private fault');}}).open(scope, 100)).toThrow('storage_unavailable'); +}); +test('only an existing resolved request may be dismissed, and outcomes cannot reset to prepared', () => { + const f = fixture(); f.journal.open(scope, 100); expect(() => f.journal.forget('missing', 100)).toThrow('request_not_found'); + f.journal.open(scope, 100); f.journal.prepare('original', command, 100); + expect(() => f.journal.record('original', 'prepared' as never, null, 100)).toThrow('invalid_journal'); + f.journal.open(scope, 100); f.journal.record('original', 'uncertain', null, 100); + expect(() => f.journal.forget('original', 100)).toThrow('unresolved_request'); + f.journal.open(scope, 100); f.journal.record('original', 'rejected', null, 100); expect(f.journal.forget('original', 100)).toEqual([]); +}); + + +test('recovering one request preserves every independent original unchanged', () => { + const f = fixture(); f.journal.open(scope, 100); f.journal.prepare('first', command, 100); + const second = f.journal.prepare('second', command, 101)[1]; + const current = f.journal.record('first', 'committed', 'op-1', 102); + expect(current[0].stage).toBe('committed'); expect(current[1]).toEqual(second); +}); diff --git a/frontend/tests/tasks.test.ts b/frontend/tests/tasks.test.ts new file mode 100644 index 0000000..e125cd1 --- /dev/null +++ b/frontend/tests/tasks.test.ts @@ -0,0 +1,82 @@ +import {expect, test} from 'vitest'; +import {TaskInputError, taskCommand, taskIdentifier, taskText, taskTime, taskQuantity} + from '../src/domain/tasks'; + +const revision = '9007199254740993'; +const complete = {action: 'task.complete', occurrenceId: 'work-1', payload: { + expected_revision: revision, performer_ids: ['member-1'], performed_at: '9223372036854775807', + material_usages: [{line_id: 'line-1', quantity: '3.333333'}]}}; + +test('task creation preserves literal text and supplies no actor, household or permission', () => { + const raw = {action: 'task.create', payload: {expected_revision: 0, + title: ' Clean 🧹 ', description: 'First\nthen\tsecond', priority: 'normal'}}; + const value = taskCommand(raw); + expect(value).toEqual(raw); if (!value) throw Error('missing task'); + expect(Object.isFrozen(value)).toBe(true); + expect(Object.isFrozen(value.payload)).toBe(true); + expect(taskCommand({...raw, householdId: 'other'})).toBeUndefined(); +}); + +test('completion takes an immutable copy of exact time, revision and material quantities', () => { + const value = taskCommand(complete); + expect(value).toEqual(complete); expect(value).not.toBe(complete); + if (value?.action !== 'task.complete') throw Error('missing completion'); + expect(Object.isFrozen(value.payload.performer_ids)).toBe(true); + expect(Object.isFrozen(value.payload.material_usages)).toBe(true); + expect(Object.isFrozen(value.payload.material_usages![0])).toBe(true); + expect(taskCommand({...complete, payload: {...complete.payload, material_usages: null}})).toBeDefined(); +}); + +test.each(['task.claim', 'task.verify', 'task.reopen'] as const)('closed command %s preserves its original revision', action => { + const payload = action === 'task.claim' ? {expected_revision: revision} + : action === 'task.verify' ? {expected_revision: revision, cycle_id: 'cycle-1', decision: 'approved', reason: ''} + : {expected_revision: revision, cycle_id: 'cycle-1', reason: 'Correct the recorded work'}; + expect(taskCommand({action, occurrenceId: 'work-1', payload})).toEqual({action, occurrenceId: 'work-1', payload}); +}); + +test.each([null, undefined, [], {}, {action: 'future', payload: {}}, + {action: 'task.create', payload: {expected_revision: '0', title: 'A', description: '', priority: 'normal'}}, + {...complete, occurrenceId: '../other'}, {...complete, actor: 'other'}, + {...complete, payload: {...complete.payload, expected_revision: 1}}, + {...complete, payload: {...complete.payload, performed_at: 1}}, + {...complete, payload: {...complete.payload, performer_ids: []}}, + {...complete, payload: {...complete.payload, performer_ids: ['a', 'a']}}, + {...complete, payload: {...complete.payload, performer_ids: Array.from({length: 33}, (_, i) => `a${i}`)}}, + {...complete, payload: {...complete.payload, material_usages: [{line_id: 'line-1', quantity: '1.0'}]}}, + {...complete, payload: {...complete.payload, material_usages: [{line_id: 'line-1', quantity: '1'}, {line_id: 'line-1', quantity: '2'}]}}, + {action: 'task.verify', occurrenceId: 'work-1', payload: {expected_revision: '1', cycle_id: 'cycle-1', decision: 'pending', reason: ''}}, + {action: 'task.reopen', occurrenceId: 'work-1', payload: {expected_revision: '1', cycle_id: 'cycle-1', reason: ' '}} +])('unsupported task input %# fails without producing a request', value => { + expect(taskCommand(value)).toBeUndefined(); +}); + +test.each(['A', ' A ', '\uFEFF', '😀'.repeat(160), '\u0085A', 'e\u0301'])( + 'task titles preserve accepted Unicode without trimming %j', value => expect(taskText(value, 160, true)).toBe(value)); +test.each([null, 1, true, '', ' \u0085', '😀'.repeat(161), '\u0000A', 'A\u007f', '\ud800', 'A\nB'])( + 'invalid title %# is rejected', value => expect(() => taskText(value, 160, true)).toThrow(TaskInputError)); +test('optional multiline descriptions accept only the task text control policy', () => { + expect(taskText('', 4000, false, true)).toBe(''); + expect(taskText('A\tB\r\nC', 4000, false, true)).toBe('A\tB\r\nC'); + expect(() => taskText('A\u000bB', 4000, false, true)).toThrow('invalid_task_input'); +}); + +test.each(['0', '1', '9007199254740993', '9223372036854775807'])( + 'task time preserves nonnegative int64 text %s', value => expect(taskTime(value)).toBe(value)); +test.each([1, null, '', '-1', '+1', '01', '1.0', '1e2', '1\n', '9223372036854775808'])( + 'ambiguous task time %# is rejected', value => expect(() => taskTime(value)).toThrow(TaskInputError)); +test.each(['0', '1', '0.1', '3.333333', '999999999999.999999'])( + 'canonical material quantity %s is accepted without rounding', value => expect(taskQuantity(value)).toBe(value)); +test.each([1, null, '', '-1', '01', '.1', '1.0', '0.0000001', '1000000000000', '1\n'])( + 'ambiguous material quantity %# is rejected', value => expect(() => taskQuantity(value)).toThrow(TaskInputError)); +test('task identifiers cannot become paths or caller-selected transport fields', () => { + expect(taskIdentifier('A_b.c:d-1')).toBe('A_b.c:d-1'); + for (const value of [null, 1, '', '../work', 'a/b', 'x?owner=a', 'a'.repeat(129)]) + expect(() => taskIdentifier(value)).toThrow(TaskInputError); +}); + + +test('rejection requires a real reason while an unknown targeted action never becomes a request', () => { + expect(taskCommand({action: 'future', occurrenceId: 'work-1', payload: {}})).toBeUndefined(); + expect(taskCommand({action: 'task.verify', occurrenceId: 'work-1', payload: {expected_revision: '1', cycle_id: 'cycle-1', decision: 'rejected', reason: ''}})).toBeUndefined(); + expect(taskCommand({action: 'task.verify', occurrenceId: 'work-1', payload: {expected_revision: '1', cycle_id: 'cycle-1', decision: 'rejected', reason: 'Not performed'}})).toBeDefined(); +}); diff --git a/specs/CATALOG.md b/specs/CATALOG.md index f2c5ee4..e25a673 100644 --- a/specs/CATALOG.md +++ b/specs/CATALOG.md @@ -13,7 +13,7 @@ debt or convert unknown historic intent into a design decision. | `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) + 4 companion | 222 paths · 2026-11-02 | | `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 | +| `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) + 3 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 | | `shopping-management` | Proposed | Shopping combines manual intent, replenishment and task materials without losing provenance. Buying an item and physically receiving it are separate recorded facts. | [Shared shopping demand, purchases and receipt](./shopping-management.md) | 5 paths · 2026-10-29 | | `household-inventory` | Proposed | The household knows what is available, where it is stored and what needs replenishment through a local product catalog and an immutable stock movement ledger. | [Product catalog, pantry stock and replenishment](./household-inventory.md) | 5 paths · 2026-10-31 | @@ -61,7 +61,7 @@ debt or convert unknown historic intent into a design decision. - Owner: Domus maintainers (review assignment pending) - Last verified: 2026-10-29 -- Specifications: [`specs/recurrence-calendar.md`](./recurrence-calendar.md) · [`specs/recurrence-execution-foundation.md`](./recurrence-execution-foundation.md) · [`specs/recurrence-storage-continuation.md`](./recurrence-storage-continuation.md) +- Specifications: [`specs/recurrence-calendar.md`](./recurrence-calendar.md) · [`specs/recurrence-execution-foundation.md`](./recurrence-execution-foundation.md) · [`specs/recurrence-storage-continuation.md`](./recurrence-storage-continuation.md) · [`specs/recurrence-lifecycle-calendar.md`](./recurrence-lifecycle-calendar.md) - Workflows: Not applicable for this capability. - Entrypoints: Not yet implemented (proposed capability). - Core code: [`backend/src/domus/domain/recurrence_time.py`](../backend/src/domus/domain/recurrence_time.py) · [`backend/src/domus/application/recurrence_ports.py`](../backend/src/domus/application/recurrence_ports.py) · [`backend/src/domus/infrastructure/recurrence_time.py`](../backend/src/domus/infrastructure/recurrence_time.py) · [`backend/src/domus/domain/recurrence_rules.py`](../backend/src/domus/domain/recurrence_rules.py) · [`backend/src/domus/domain/recurrence_rule_text.py`](../backend/src/domus/domain/recurrence_rule_text.py) · [`backend/src/domus/domain/recurrence_periods.py`](../backend/src/domus/domain/recurrence_periods.py) · [`backend/src/domus/domain/nominal_recurrence.py`](../backend/src/domus/domain/nominal_recurrence.py) · [`backend/src/domus/domain/recurrence_planning.py`](../backend/src/domus/domain/recurrence_planning.py) · [`backend/src/domus/application/fixed_recurrence.py`](../backend/src/domus/application/fixed_recurrence.py) diff --git a/specs/catalog.json b/specs/catalog.json index 8960d68..73c725e 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -540,7 +540,8 @@ "specs": [ "specs/recurrence-calendar.md", "specs/recurrence-execution-foundation.md", - "specs/recurrence-storage-continuation.md" + "specs/recurrence-storage-continuation.md", + "specs/recurrence-lifecycle-calendar.md" ], "workflows": [], "entrypoints": [], diff --git a/specs/recurrence-lifecycle-calendar.md b/specs/recurrence-lifecycle-calendar.md new file mode 100644 index 0000000..bbfad71 --- /dev/null +++ b/specs/recurrence-lifecycle-calendar.md @@ -0,0 +1,564 @@ +# Relative recurrence lifecycle and protected calendar + +- Status: Draft +- Date: 2026-11-03 +- Catalog capability ID: recurrence-calendar +- Last verified: not applicable +- Owners: Domus maintainers; engineering and product reviewers +- Scope: Complete relative recurrence lifecycle, shared maintenance, and protected calendar behavior as a companion to the existing recurrence owners. +- Related issues/PRs: https://github.com/vypdev/domus/issues/23; PR #22; PR #24 +- Required review gates: product UX, architecture, testing, documentation, security/operations +- Open decisions blocking readiness: no unanswered product choice specific to this companion. Capability readiness remains draft: the retained catalog blockers for the supported RRULE subset, time-zone/gap/fold library interoperability, and calendar projection review still apply. Proposed engineering bounds require implementation evidence. + +This is a proposed companion at `specs/recurrence-lifecycle-calendar.md`. Preserve `specs/recurrence-calendar.md`, `specs/recurrence-execution-foundation.md`, and `specs/recurrence-storage-continuation.md` verbatim. Their 37, 52, and four required cases remain required (93 counted cases); retained integration aliases remain unchanged and are not recounted. This draft adds its own cases only. Native SDD publication and verification of the exact Action-managed branch precede implementation. The catalog preflight and checkpoint evidence described in the issue do not establish native publication, branch readiness, or feature acceptance. + +## 1. Executive summary + +Deliver actual relative initial and successor work, durable source-cycle processing, fair bounded maintenance shared with fixed recurrence, reviewed schedule changes, and truthful protected calendar projections. The recommended defaults remain the retained owners’ defaults. The principal safety rule is that only current, eligible source facts and current authorization may produce effects; durable cycle closure prevents duplicate or late effects across rule changes and compatible restoration. + +```text +Configure and review a relative rule -> bounded maintenance observes and validates source facts -> one durable eligible obligation is created -> authorized calendar views distinguish planned, due, and actual facts. +``` + +### Review correction of the first native publication + +The first native publication at 91adfe902055cb2c5bb42ceeb57c2b7ab53fb635 succeeded and remains immutable history. It generated 18 broader cases that overlap the retained owners instead of preserving the supplied independent refinements. The published references remain required below as integration aliases; they are not 18 additional counted journeys. Sections 14 and 17 instead count the 18 explicit technical refinements C19 through C36. An execution satisfying several obligations remains one execution. + +| Published integration alias | Retained owning/refinement coverage | Counting | +|---|---|---| +| recurrence-lifecycle-calendar-C01 | recurrence-execution-C06 | Required alias; not an additional counted case. | +| recurrence-lifecycle-calendar-C02 | recurrence-execution-C07, recurrence-execution-C32 | Required alias; not an additional counted case. | +| recurrence-lifecycle-calendar-C03 | recurrence-execution-C29, recurrence-calendar-C12, recurrence-calendar-C25 | Required alias; not an additional counted case. | +| recurrence-lifecycle-calendar-C04 | recurrence-execution-C21, recurrence-execution-C33, recurrence-calendar-C29, recurrence-calendar-C30 | Required alias; not an additional counted case. | +| recurrence-lifecycle-calendar-C05 | recurrence-execution-C24, recurrence-execution-C28 | Required alias; not an additional counted case. | +| recurrence-lifecycle-calendar-C06 | recurrence-execution-C28, recurrence-execution-C30 | Required alias; not an additional counted case. | +| recurrence-lifecycle-calendar-C07 | recurrence-execution-C17, recurrence-execution-C18, recurrence-execution-C19 | Required alias; not an additional counted case. | +| recurrence-lifecycle-calendar-C08 | recurrence-execution-C26 | Required alias; not an additional counted case. | +| recurrence-lifecycle-calendar-C09 | recurrence-execution-C27 | Required alias; not an additional counted case. | +| recurrence-lifecycle-calendar-C10 | recurrence-execution-C34, recurrence-execution-C43 | Required alias; not an additional counted case. | +| recurrence-lifecycle-calendar-C11 | recurrence-execution-C16, recurrence-execution-C23 | Required alias; not an additional counted case. | +| recurrence-lifecycle-calendar-C12 | recurrence-execution-C16, recurrence-execution-C23, recurrence-lifecycle-calendar-C21 | Required alias; not an additional counted case. | +| recurrence-lifecycle-calendar-C13 | recurrence-execution-C20, recurrence-execution-C31, recurrence-execution-C32, recurrence-execution-C36 | Required alias; not an additional counted case. | +| recurrence-lifecycle-calendar-C14 | recurrence-execution-C20, recurrence-execution-C28, recurrence-execution-C30, recurrence-lifecycle-calendar-C22 | Required alias; not an additional counted case. | +| recurrence-lifecycle-calendar-C15 | recurrence-execution-C37, recurrence-execution-C38, recurrence-execution-C39, recurrence-execution-C40, recurrence-execution-C41, recurrence-execution-C42 | Required alias; not an additional counted case. | +| recurrence-lifecycle-calendar-C16 | recurrence-execution-C22, recurrence-execution-C45, recurrence-execution-C46, recurrence-execution-C48, recurrence-execution-C49, recurrence-lifecycle-calendar-C33 | Required alias; not an additional counted case. | +| recurrence-lifecycle-calendar-C17 | recurrence-calendar-C16, recurrence-calendar-C17, recurrence-calendar-C18, recurrence-lifecycle-calendar-C35 | Required alias; not an additional counted case. | +| recurrence-lifecycle-calendar-C18 | recurrence-execution-C38, recurrence-execution-C43, recurrence-execution-C44, recurrence-lifecycle-calendar-C31, recurrence-lifecycle-calendar-C32 | Required alias; not an additional counted case. | + +The native draft also cited nonexistent `validate:specifications` and `generate:specifications` pnpm scripts. Both were checked against the exact selected package and both literal commands exited with ERR_PNPM_NO_SCRIPT. The consumer correction uses existing documentation validation and states the original native SDK outcomes without inventing scripts. Reproduction and the separate semantic observations are reported in [copilot#858](https://github.com/vypdev/copilot/issues/858#issuecomment-6615277620). No Copilot source, workflow, model, cache source or bound is changed. This correction changes planned acceptance; it does not claim implementation or test execution. + +## 2. Problem, current behavior, and evidence + +### 2.1 Problem + +Households need recurring task obligations that follow actual completed work, with an understandable calendar and recoverable review flow. The checkpoint does not yet consume relative sources into actual obligations or provide the remaining lifecycle, calendar, and UI journeys. A stored rule or passing foundation cases cannot tell a household that a successor was actually created, nor protect against duplicate or out-of-order source events. + +### 2.2 Current behavior + +1. The supplied immutable catalog at `8b1cbc110c389b9cdb6ecd11317c95abf2c4b0a9` identifies the three existing owners and current source paths. These paths are evidence of source locations, not proof of future behavior or framework support. +2. The issue reports that the merged checkpoint contains schema 8, fixed execution, relative creation with pristine unconsumed progress, and a fixed-only persistent runner. It lacks actual relative source materialization and the remaining lifecycle/calendar surface. +3. PR #24 merged schema 9; the issue reports current develop `8b1cbc110c389b9cdb6ecd11317c95abf2c4b0a9` contains it. Any later DDL must start from the actual supported schema 9 and use the next genuine version. +4. The issue reports a genuine schema 9 input and preserved earlier fixtures. This is not owner HA data or relative execution acceptance. + +### 2.3 Evidence + +- Code/catalog references: `backend/src/domus/domain/recurrence_time.py`, `backend/src/domus/application/recurrence_ports.py`, `backend/src/domus/infrastructure/recurrence_time.py`, `backend/src/domus/domain/recurrence_rules.py`, `backend/src/domus/domain/recurrence_rule_text.py`, `backend/src/domus/domain/recurrence_periods.py`, `backend/src/domus/domain/nominal_recurrence.py`, `backend/src/domus/domain/recurrence_planning.py`, `backend/src/domus/application/fixed_recurrence.py`. +- Existing owner specifications: `specs/recurrence-calendar.md`, `specs/recurrence-execution-foundation.md`, `specs/recurrence-storage-continuation.md`. +- Issue evidence: https://github.com/vypdev/domus/issues/23. Reported checkpoint hashes, test counts, reviews, database digests, and preflight results are supplied issue claims; they are not independently reverified here. +- Unknowns: actual adapter behavior and implementation/test evidence. Section 15 declares prospective implementation paths; proposed durable inbox/closure data uses the next genuine schema, subject to implementation verification. These must be established during admitted implementation. + +### 2.4 Retrospective classification (as-built baselines only) + +Not applicable: this prospective companion does not reconstruct an implemented baseline. Current facts above are bounded to supplied catalog and issue evidence; proposed behavior below is not represented as implemented. + +## 3. Actors, surfaces, and terminology + +| Actor | Goal | Entry point | Visible surfaces | +|---|---|---|---| +| Authorized household manager | Configure, review, pause, resume, and change schedules | Domus UI and existing authorized commands | Task and recurrence views, review plans | +| Authorized household member | Read allowed schedule and calendar facts | Domus UI or protected HA projection | Month, week, day, and agenda | +| Composition-owned maintenance capability | Consume bounded configured work for its actual installation/channel/household | Owned runtime lifetime | Honest pending, continuation, or failure state | +| Operator | Recover compatible application/data state | Existing operations procedures | Recovery evidence and diagnostics | + +A nominal identity is the stable scheduled occurrence. A source event identity is the observed task fact. A completion cycle is the immutable closure unit; record revision is mutable state. Generation identifies rule evolution. A scan position is progress evidence, not proof of effect or complete calendar freshness. Technical maintenance is a composition-owned local capability, never a human permission or caller-selected principal, installation, or channel. Actual performance, approval, planned start, and due date are separate facts. + +## 4. Goals + +| Requirement | Behavior | +|---|---| +| RCL-01 | Materialize the relative bootstrap and eligible successor as real task obligations; support reviewed compatible attachment/adoption without duplicating first work. | +| RCL-02 | Observe bounded live and historical source facts, persist a durable inbox and per-cycle closure, and retain successor identity across duplicates, rule changes, and compatible restoration. | +| RCL-03 | Rejection or reopening invalidates only unstarted derived successors with reason and history; downstream started/completed work survives with review attention and no fabricated effects. | +| RCL-04 | Fixed and relative work share a durable fair frontier and global 50-definition/1,000-slot budget; source scans have a separate explicit bounded budget and truthful continuation; both owned runtime lifetimes are wired. | +| RCL-05 | Support pause/resume, explicit overdue skip, reschedule exceptions, reviewed preview/apply, collision review, and safe handling of stale previews or lost responses. | +| RCL-06 | Persist task-owned timing classification, date-only/planned/deadline/actual-work facts, configurable household timing defaults, and bounded protected calendar queries. | +| RCL-07 | Protect commands, cursors, and original-operation recovery in both deployments; preserve original permissions, histories, and closed intent namespaces. | +| RCL-08 | Preserve schema 9 and supported fixtures; if new DDL is needed, use the next genuine schema version with compatible migration and rollback evidence. | +| RCL-09 | Deliver retained-case, English/Spanish UI, HA, both deployment, Stable/Edge, and all-domain acceptance with truthful documentation and quality evidence. | + +### 4.1 Goals + +The measurable outcomes are the nine requirements in the table above. All are required for the first usable all-domain release; foundation and checkpoint completion alone do not satisfy them. + +### 4.2 Non-goals + +1. Replacing or editing the three existing owning specifications. +2. Taking schema 9 ownership away from the administration continuation. +3. Changing the existing gateway, Copilot source/resources/configuration/credentials/bounds, or owner permission/history contracts. +4. HA update, release/tag/deployment, or automatic production promotion. +5. Claiming external synchronization, points, notifications, approvals, or compensation without their owning consumer and evidence. + +### 4.3 Fixed product/safety invariants + +1. Preserve actual-work local-calendar-day anchoring, first-valid gap and whole-date handling, one configured fold appearance, and per-task successor visibility. +2. Current authorization and original operation identity govern recovery; request data cannot choose a principal or technical scope. +3. Closure dominates duplicate/out-of-order completion or approval, including after rule changes and compatible restoration. Historical absence is not authority to create an effect. +4. Started/completed work and original history are never silently erased or fabricated. Maintenance is bounded and reports incomplete continuation truthfully. + +## 5. Current versus proposed product journey + +| Stage | Current | Proposed | User/operator effect | +|---|---|---|---| +| Configure | Relative definition and pristine progress are reported | Review initial-work choice and commit one actual obligation | Household can inspect its first obligation without a duplicate manual task | +| Complete | Existing task lifecycle facts are available | Durable bounded consumer validates source and creates one eligible successor | Actual work drives an explainable due date | +| Reopen/reject | No complete relative closure consumer evidenced | Close cycle; invalidate only unstarted derived work or surface review attention | Existing downstream work and history remain visible | +| Maintain | Fixed-only persistent runner reported | Shared fair mixed frontier plus independent source-scan budget in both owned lifetimes | Pending work is bounded and continuation is inspectable | +| Observe/change | Full calendar/review surface absent | Reviewed plan/apply and protected actual/planned/due calendar | Users can see conflicts and distinguish dates and performance | + +```mermaid +flowchart LR + A[Authorized configuration] --> B[Durable bounded observation] + B --> C[Validate current source and cycle] + C --> D[Commit obligation and closure atomically] + D --> E[Protected calendar and review] + C --> F[Truthful continuation or review attention] +``` + +Text equivalent: authorized configuration leads to bounded observation, current-source validation, atomic obligation/closure, then protected calendar or explicit continuation/review attention. + +## 6. Functional behavior and state model + +### 6.1 Happy path + +1. A currently authorized manager creates or reviews a relative definition and chooses the retained initial-work policy. +2. The application commits the original result and one bootstrap obligation atomically. It does not consume a completion or create ordinary manual duplicate work. +3. The owned runner scans within both independent budgets, persists exact inbox/source position, and revalidates live occurrence, cycle, rule, policy, definition, scope, and original eligibility at commit. +4. Eligible actual work creates at most one successor at original `performed_at` plus local calendar days in the snapshotted zone. Instant deadlines preserve millisecond wall time; date-only work remains a date. +5. Authorized calendar queries project actual, planned, and due facts with stable identity, masking, bounded page, and honest freshness/continuation. + +### 6.2 Alternative paths + +- Reviewed attachment preserves existing manual work or adopts an explicitly compatible unstarted occurrence at its exact revision. It cannot duplicate first work. +- Default `at_completion` admits current completed work pending approval; `at_verification` waits for current approval without moving the derived date. Late approval may make the successor overdue. +- Historical absent completion/approval is nonauthorizing. A closed cycle remains closed even when old events arrive after rule edits or restoration. +- Pause blocks new generation but retains assigned work. Resume previews backlog. `keep_visible` remains the default for overdue work; explicit skip records nominal identity, reason, actor, and no award. +- A rejection/reopening cancels only a still-unstarted derived successor. Started or completed downstream work remains, with durable review attention and no fabricated performance, verification, points, stock, or compensation. +- Reschedule preview identifies retained, moved, cancelled/replaced, added, and exceptional slots. Stale revisions, collisions, concurrent starts, or oversized plans require refreshed human review; no partial application is called complete. + +### 6.3 State machine + +| State | Entered when | User-visible meaning | Allowed next states | Recovery/owner | +|---|---|---|---|---| +| Pending observation | Durable source work remains | Some recurrence facts are not yet reconciled | Processing, attention, current | Owned runner resumes bounded frontier | +| Current | Source, policy, and revision checks pass | Eligible work may be committed | Closed, pending, attention | Application transaction and source adapter | +| Closed | Cycle is consumed, rejected, or reopened | This cycle cannot grant another effect | Closed | Durable closure invariant | +| Review attention | Collision or started downstream work needs review | Existing work is retained; a person must inspect it | Current, closed | Authorized manager reviews explicit plan | +| Paused | Manager pauses rule | Existing assignments remain; new generation stops | Active, retired | Current-authorized rule command | +| Retired | Rule is retired | No new obligations are generated | Retired | Operator preserves history | + +Duplicate source identity returns the exact original outcome. Stale and out-of-order observations cannot reopen closure. Cancellation does not erase started work. A partial scan records continuation and never advances past unprocessed work. + +Recurrence closure is consumer-owned grant/effect proof, separate from human TaskCycle.closed_at, performance and approval facts. Later rejection/reopening remains processable to invalidate or flag the recorded successor while retaining the closed grant. Consuming a completion never fabricates closure or approval in the human task cycle. + +## 7. User-facing configuration + +| Input | Type | Recommended default | Allowed values/range | Scope/persistence | +|---|---|---|---|---| +| Horizon | Integer days | 60 local days | 7..366 | Household policy snapshot | +| Definition/slot work | Integer budgets | 50 definitions; 1,000 slots | Global across fixed and relative; minimum implementation limits must not exceed these maxima | One durable shared frontier per run | +| Period scanning | Integer periods | 4,096 | Existing maximum 64,000; empty periods consume work | Per run, durable continuation | +| Source scanning | Integer rows | 4,096 proposed technical default | 1..64,000 per run; every examined row, including duplicate/nonactionable/lookahead, consumes currency | Composition policy; independent from slot/period budgets | +| Source page | Integer rows | At most 100 | No more than remaining scan budget | Per bounded read; no cursor skips unprocessed rows | +| Preview expiry | Seconds | 300 | Fixed bounded expiry | Context-bound reviewed plan | +| Calendar query | Days and rows | 31 days; 25 rows | 1..366 days and 1..100 rows; half-open range | Current authorized query; 300-second context-bound cursor | +| Week start | Enum | Monday | Monday or Sunday | Private member preference; browser-neutral fallback | + +Validate cross-field bounds before effects. A run admits at most 50 visited definitions and 1,000 slot work across both recurrence types. A separate source currency counts every examined row. Exhaustion yields incomplete continuation; empty source or period scans consume budget. Resume must preserve a fair durable frontier so a noisy old source cannot starve others. Retain existing RRULE, interval, time-policy, exclusion/addition, text, and horizon bounds. Supported legacy defaults are established only during explicit migration or member creation. Ordinary reads never initialize or repair missing/corrupt records; unknown values fail closed without coercion or silent truncation. One recommended configuration is the defaults above; a meaningful alternative is a 14-day horizon with Monday week start. Safety limits, current authorization, and closure semantics are not configurable. + +## 8. Clean Architecture + +### 8.1 Responsibilities and dependency direction + +| Layer/boundary | Owns | Must not own/import | +|---|---|---| +| Domain/pure policy | Generation, timing classification, source-cycle closure, review transitions | SQL, HA, web framework, provider DTOs | +| Application | Authorization, orchestration, fair admission, original results, bounded calendar predicates | Concrete provider SDK/process details | +| Adapters/data | Semantic source/current-proof mapping and storage mapping | Product policy or permission grants | +| Infrastructure/composition | SQLite, timezone/protocol adaptation, technical scope, clock/IDs, owned workers | Business decisions or caller-selected principal | +| Entrypoints | Input/event adaptation and trusted boundary checks | Duplicated orchestration or implicit authority | +| Presentation | Authorized view models, renderers, deliberate user intents | SQL access, mutation policy, permission transitions | + +```mermaid +flowchart LR + E[Entrypoint] --> A[Application use case] + A --> D[Domain policy] + A --> P[Semantic ports] + I[SQLite and source adapters] --> P + A --> V[Presentation model] + W[Composition-owned workers] --> A +``` + +Text equivalent: entrypoints call application use cases; application depends on pure domain and semantic ports; infrastructure implements ports; presentation renders application results; composition owns workers and technical scope. + +### 8.2 Contracts, state, and trust boundaries + +- Pure decisions: nominal identity, eligibility, timing, closure dominance, fairness, collision and review classification. +- Application contracts: immutable bounded inputs/outputs, current grant checks, original operation identity, atomic effect/result orchestration. +- Semantic ports: current and historical source observations, current live revalidation, transactional cycle closure, task creation, protected calendar reads. +- Durable state/schema ownership: existing recurrence owners remain authoritative; any new DDL follows actual schema 9 with genuine next version and preserves fixtures 1..9. Declare and create `tests/fixtures/sqlite/v9.sql` before any future commit if that supported input is required by implementation. +- Concurrency/idempotency: unique scoped source/cycle identity, atomic receipt/effect/closure/audit/outbox, optimistic exact revisions, independent-writer and rollback evidence. +- Trust: issue/comment/source text, HA/browser inputs, cursors, and provider DTOs are untrusted. Revalidate live facts and current authorization at commit and recovery. +- Provider errors: bounded transient continuation, explicit conflict/review, safe denial, and fail-closed corruption mapping; never report uncommitted effects as complete. + +### 8.3 Executable architecture constraints + +Extend repository dependency/cycle and forbidden-import checks for every new runtime module and keep all authored modules within global and per-module statement/branch coverage. Contract tests must parse closed DTOs, schema ownership/version, source adapter bounds, and both runtime wiring paths. Do not infer execution support from catalog paths or fixtures. + +## 9. UI/UX and content contract + +### 9.1 Information hierarchy + +Show current status, completed facts, next transition, required human action, impact, inspectable links, then collapsed technical evidence. A calendar entry labels planned start, due-only date, actual performance, and verification separately. Date-only values never acquire an invented time or UTC midnight. + +### 9.2 Representative primary view + +This fenced block is a literal interface mockup. The action labels describe application buttons; they are not documentation links or proposed URLs. + +```markdown +# Recurrence review: kitchen filter + +> **Current status:** The task was completed on 1 January; approval is pending. +> +> **Next task:** The next obligation is due on 31 January. +> +> **Action required:** An adult can review the completed task. + +## Progress + +- [x] Completion recorded from the current task cycle +- [x] The next task is created under the default completion policy +- [ ] Adult approval of the original work is pending + +## What happens next + +Approval does not move the next due date. A task explicitly configured to wait for approval shows its next obligation only after approval, using the same actual-work-derived date. + +## Actions + +Buttons: Open the task · Review the schedule + +
Technical detailsSource cycle, revision, and bounded continuation evidence.
+``` + +### 9.3 Issue, PR, and comment behavior + +This companion does not authorize new issue/PR automation, comments, labels, or notifications. Any implementation-facing status must use deterministic titles, one primary action, descriptive links, idempotent updates, and a bounded notification budget of zero unsolicited comments. Merge or close must not be presented as feature completion. Checks are supplemental evidence; only verified product journeys establish acceptance. + +### 9.4 Accessibility, localization, and responsive behavior + +Support English and Spanish with stable keys, base-language matching, and English fallback. Provide real month/week/day/agenda views, narrow-screen keyboard-accessible agenda, visible focus, non-color status, light/dark contrast, reduced motion, semantic headings/tables, and textual equivalents for visuals. Sanitize untrusted names/reasons as inert text; validate links and never render embedded commands, mentions, HTML markers, or diagrams as executable content. HA consumes the same authorized projection and stable entry identity; external synchronization is initially disabled. The existing gateway remains integrated and untouched. Do not claim an adapter exists until its actual implementation and acceptance paths are declared. + +## 10. Failure, recovery, and cleanup + +| Failure/partial state | User impact | Retained facts | Automatic retry | Required action | Cleanup | +|---|---|---|---|---|---| +| Source or lock unavailable | Projection is pending | Inbox, closure, original receipts, continuation | Bounded backoff | Inspect current status if persistent | Stop and join owned workers before storage closes | +| Stale preview or concurrent start | Plan cannot safely apply | Original plan and current revisions | No automatic apply | Refresh and review | Expire context-bound draft safely | +| Corrupt or conflicting evidence | Work is blocked for review | Original bytes/receipt and diagnostics | No coercion or repair | Operator follows recovery procedure | Preserve exact compatible backup | +| Response lost after commit | User may not know result | Original key, payload, result, revisions | No new-key retry | Recover original operation under current grants | Retain only original expiry/context | +| Oversized change | No complete plan is committed | Current schedule and requested draft | No partial success claim | Submit smaller reviewed plan | Keep draft until explicit correction | + +Errors state impact, cause, action, and retained state in that order. A committed batch resumes from durable facts after crash. Restore preserves later processed evidence without reinstating absent effects. Current authorization precedes original-result disclosure. No automatic fresh-key replacement or POST retry. Changed verified context or logout retires private drafts. + +## 11. Security, permissions, and privacy + +1. Both deployment entrypoints retain current HA proof, local membership, Origin, session CSRF, expected context, and strict bounded DTO validation. +2. Request data never selects a technical actor, installation, channel, or household. Maintenance uses composition-owned actual scope. +3. Recovery maps each original action explicitly and rechecks current grants before returning private facts. Cursor principal/scope/expiry is bound; a cursor is never authentication. +4. Calendar ranges and filters are closed and bounded. Outage reads retain only original expiry and current local masking; changes remain blocked. +5. Preserve both existing closed intent namespaces and lossless int64 revisions. Do not log secrets or unnecessary household personal data. Reject replay, forged scope, stale revisions, and untrusted markup. + +The new recurrence intent journal is a separate closed namespace bounded to eight records, 65,536 UTF-8 bytes and one hour, without automatic expiry renewal. It preserves exact original keys/payloads and lossless int64 revisions; entry and administration journals keep their existing closed contracts and cannot cross-decode recurrence records. Never persist tokens or CSRF secrets in these journals. + +Calendar display choices use a separate owner-private record and original-action namespace, including against administrators. Explicit migration and deliberate member creation store the Monday default at independent revision 1; ordinary reads never create or repair missing/corrupt records. Existing locale, summary, public profile and membership revisions remain independent. Private preferences and raw provider payloads are excluded from logs, events and public projections. + +## 12. Observability and operational UX + +- User-facing state: current, pending with continuation, review attention, paused, or safely blocked; distinguish stale projection from empty calendar. +- Operator evidence: scope, bounded run currency, visited definitions, source position, cycle identity, schema version, and safe correlation ID; no private payload dump. +- Logs/metrics: bounded counts for examined source rows, slot work, continuation age, conflicts, and worker lifecycle. Correlation never grants access. +- External dependency versus workflow failure: transient source outage is pending continuation; corrupt or unauthorized evidence is blocked/attention, not empty success. +- Rate-limit/retry: bounded exponential backoff in the retained five-second polling/backoff lifecycle; respect provider limits and stop/join workers. +- Noise budget: no unsolicited comments or notifications; one durable status update per actual state transition where a status surface exists. + +## 13. Compatibility, migration, rollout, and rollback + +Preserve schemas1..8 and their immutable fixtures. Capture and publish the genuine supported schema-9 fixture before adding durable inbox/closure/attention/plan data as actual schema 10. Do not edit a version number to reinterpret an unknown layout. Normal operations reject an old supported source until explicit atomic initialization; failed migration preserves original rows/version. Validate canonical tagged data and scope/generation/original-proof relations on direct reads and independent snapshot inspection. + +The genuine schema-9 input retains old recurrence and profile/privacy facts. Current schema-9 images must refuse schema 10 without mutation, and rollback selects the compatible complete image/database/generation pair. Restore keeps later original processing history, including closure, without authorizing absent work. Both actual architectures and persistent deployment lifetimes require current-source upgrade/refusal/rollback checks. Stable/Edge remain isolated; no automatic promotion or owner HA update is implied. The Edge.7 administration UI was separately installed and tested on the owner's HA; those observations do not certify recurrence, migration or calendar acceptance. + +## 14. Testing strategy and numeric budget + +All 93 counted cases in the three unchanged owners and their integration aliases remain required. Published C01 through C18 remain required non-counted integration aliases as mapped above. The following 18 independently refined technical cases C19 through C36 replace no owning journey. These are prospective assertions, not passing results. One execution satisfying several obligations still counts as one execution. + +| Area | Minimum distinct cases | Behaviors/risks covered | +|---|---:|---| +| State/application/idempotency/races | 6 | Concrete independent refinements in the inventory below. | +| Application use cases | 4 | Concrete independent refinements in the inventory below. | +| Adapters/provider contracts | 2 | Concrete independent refinements in the inventory below. | +| Domain/configuration/pure planning | 1 | Concrete independent refinements in the inventory below. | +| Integration/security/migration | 3 | Concrete independent refinements in the inventory below. | +| Workflows, permissions, schemas, and setup | 1 | Concrete independent refinements in the inventory below. | +| UI/UX/localization/sanitization | 1 | Concrete independent refinements in the inventory below. | +| Total | 18 | Distinct refinement cases; repeated executions and published aliases do not add cases. | + +| Case ID | Area | Requirements | Scenario | +|---|---|---|---| +| recurrence-lifecycle-calendar-C19 | State/application/idempotency/races | RCL-01,RCL-08 | A restored relative bootstrap has a retained original but no task; a new runner preserves absence, original identities and zero additional receipt/event effects. | +| recurrence-lifecycle-calendar-C20 | State/application/idempotency/races | RCL-01,RCL-05 | A reviewed manual adoption becomes incompatible before commit; it rejects the exact stale revision without a second initial task or partial mapping. | +| recurrence-lifecycle-calendar-C21 | Application use cases | RCL-02,RCL-04 | A source page spends the final scan unit on a duplicate/nonactionable lookahead; no unread position is certified and a fresh process resumes the exact remaining work. | +| recurrence-lifecycle-calendar-C22 | Adapters/provider contracts | RCL-02,RCL-07 | Current and historical rows have tied times or foreign scope; original source identity/position remains deterministic and no historical approval grants a successor. | +| recurrence-lifecycle-calendar-C23 | State/application/idempotency/races | RCL-02,RCL-03 | A cycle closes under an old rule generation, then delayed completion/approval arrives after a rule edit and restore; durable closure prevents new work without rewriting original completion facts. | +| recurrence-lifecycle-calendar-C24 | State/application/idempotency/races | RCL-03,RCL-08 | A downstream successor starts between invalidation planning and write; preserve its actual work, persist review attention once and invent no point/stock compensation. | +| recurrence-lifecycle-calendar-C25 | Application use cases | RCL-04 | Fixed/relative backlogs and replayed slots share exactly one shared limit of 50 definitions and 1,000 slots ceiling; period and source exhaustion retain separate truthful continuations. | +| recurrence-lifecycle-calendar-C26 | State/application/idempotency/races | RCL-04,RCL-02 | Repeated interruption after individual admission and a noisy unavailable source cannot skip unvisited peers; independent restarted runtimes preserve fair durable frontier and cycle closure. | +| recurrence-lifecycle-calendar-C27 | State/application/idempotency/races | RCL-05,RCL-07 | A plan containing exclusions, reschedules and newly started work becomes stale; original apply cannot overwrite it, and lost-result recovery never replaces the key or payload. | +| recurrence-lifecycle-calendar-C28 | Domain/configuration/pure planning | RCL-05,RCL-08 | Individually legal affected records exceed the complete canonical plan/result byte bound; preview/apply rejects without partial cancellation or silent truncation. | +| recurrence-lifecycle-calendar-C29 | Application use cases | RCL-06 | Combined actual category/type/status/priority/member filters paginate with the same entry/count predicate; due-only dates and actual performers retain different meanings. | +| recurrence-lifecycle-calendar-C30 | Application use cases | RCL-06,RCL-04 | A scan watermark advances while earlier source work remains unresolved; the calendar stays incomplete and never converts that watermark into an empty complete household. | +| recurrence-lifecycle-calendar-C31 | Integration/security/migration | RCL-07,RCL-06 | Another current administrator alters a principal-bound cursor or own calendar preference result key; both deployments reject disclosure without changing bytes or extending an outage lease. | +| recurrence-lifecycle-calendar-C32 | Integration/security/migration | RCL-07,RCL-09 | A calendar edit above 2^53 survives actual browser wire, SQLite and original recovery exactly; corrupt/foreign recurrence intent records cannot decode as entry/admin records. | +| recurrence-lifecycle-calendar-C33 | Workflows, permissions, schemas, and setup | RCL-08 | The actual frozen9input upgrades atomically to schema 10 with two fixed slots, pristine relative progress, false private summaries and original evidence intact; an injected late failure preserves schema-9 bytes/version. | +| recurrence-lifecycle-calendar-C34 | Integration/security/migration | RCL-08,RCL-02 | A compatible restored schema-10 generation retains later cycle closure/original proofs while its successor is absent; an actual schema-9 image refuses schema 10 and no edited header is treated as rollback. | +| recurrence-lifecycle-calendar-C35 | UI/UX/localization/sanitization | RCL-09,RCL-06 | Actual EN/ES narrow-screen month-to-agenda keyboard navigation names planned/due/performed/approval times, retains inert reasons and shows incomplete processing independently of color. | +| recurrence-lifecycle-calendar-C36 | Adapters/provider contracts | RCL-09,RCL-07 | Actual HA calendar retry preserves UIDs/offset policy and becomes unavailable on source failure; disabled external synchronization transmits no household records. | + +Tests first, strict types, real independent SQLite writers, deterministic clocks/provider failures, crash/restore, actual HTTP, Storybook, compiled browser and actual image acceptance. Require 100% supported coverage globally and per authored module: all four frontend metrics, backend statements/branches, independent authored-source inventories, and every-PR CI Check. Do not infer semantic or human acceptance from test counts. Preserve existing timeout, retry, privacy and architecture gates, and necessary actual failure evidence. + +## 15. Documentation and discoverability + +| Path | Change | +|---|---| +| `specs/recurrence-lifecycle-calendar.md` | create | +| `backend/src/domus/domain/relative_lifecycle.py` | create | +| `backend/src/domus/domain/calendar_values.py` | create | +| `backend/src/domus/application/relative_materialization.py` | create | +| `backend/src/domus/application/recurrence_sources.py` | create | +| `backend/src/domus/application/relative_lifecycle.py` | create | +| `backend/src/domus/application/recurrence_review.py` | create | +| `backend/src/domus/application/calendar_queries.py` | create | +| `backend/src/domus/application/calendar_commands.py` | create | +| `backend/src/domus/infrastructure/sqlite_relative_lifecycle.py` | create | +| `backend/src/domus/infrastructure/sqlite_calendar.py` | create | +| `backend/src/domus/entrypoints/recurrence_http.py` | create | +| `backend/src/domus/entrypoints/calendar_http.py` | create | +| `backend/src/domus/entrypoints/recurrence_dto.py` | create | +| `backend/src/domus/composition/calendar.py` | create | +| `frontend/src/domain/tasks.ts` | create | +| `frontend/src/application/task-ports.ts` | create | +| `frontend/src/application/task-controller.ts` | create | +| `frontend/src/adapters/task-values.ts` | create | +| `frontend/src/adapters/task-http.ts` | create | +| `frontend/src/adapters/task-journal.ts` | create | +| `frontend/src/presentation/domus-tasks.ts` | create | +| `frontend/src/presentation/task-messages.ts` | create | +| `frontend/stories/tasks.stories.ts` | create | +| `frontend/tests/tasks.test.ts` | create | +| `frontend/tests/task-controller.test.ts` | create | +| `frontend/tests/task-http.test.ts` | create | +| `frontend/tests/task-journal.test.ts` | create | +| `frontend/tests/task-component.test.ts` | create | +| `frontend/tests/e2e/tasks.spec.ts` | create | +| `frontend/src/domain/calendar.ts` | create | +| `frontend/src/application/calendar-controller.ts` | create | +| `frontend/src/adapters/calendar-http.ts` | create | +| `frontend/src/adapters/calendar-journal.ts` | create | +| `frontend/src/presentation/domus-calendar.ts` | create | +| `frontend/src/presentation/calendar-messages.ts` | create | +| `frontend/stories/calendar.stories.ts` | create | +| `tests/backend/test_relative_materialization.py` | create | +| `tests/backend/test_recurrence_source_observations.py` | create | +| `tests/backend/test_relative_lifecycle.py` | create | +| `tests/backend/test_sqlite_relative_lifecycle.py` | create | +| `tests/backend/test_mixed_recurrence.py` | create | +| `tests/backend/test_recurrence_review.py` | create | +| `tests/backend/test_calendar_values.py` | create | +| `tests/backend/test_calendar_queries.py` | create | +| `tests/backend/test_calendar_http.py` | create | +| `tests/backend/test_recurrence_http.py` | create | +| `tests/backend/test_recurrence_lifecycle_migration.py` | create | +| `frontend/tests/calendar-controller.test.ts` | create | +| `frontend/tests/calendar-http.test.ts` | create | +| `frontend/tests/calendar-journal.test.ts` | create | +| `frontend/tests/calendar-component.test.ts` | create | +| `frontend/tests/e2e/calendar.spec.ts` | create | +| `tests/fixtures/sqlite/v9.sql` | create | +| `tests/containers/test_relative_recurrence_image.py` | create | +| `tests/containers/test_calendar_image.py` | create | +| `docs/tasks/recurrence.md` | create | +| `docs/tasks/calendar.md` | create | +| `docs/evidence/recurrence-lifecycle-calendar.json` | create | +| `docs/evidence/task-workspace.json` | create | +| `tests/packaging/test_ui_wheel.py` | update | +| `specs/catalog.json` | update | +| `specs/CATALOG.md` | update | +| `backend/src/domus/domain/task_events.py` | update | +| `backend/src/domus/application/task_ports.py` | update | +| `backend/src/domus/infrastructure/sqlite_task_history.py` | update | +| `backend/src/domus/domain/tasks.py` | update | +| `backend/src/domus/domain/task_values.py` | update | +| `backend/src/domus/domain/relative_recurrence.py` | update | +| `backend/src/domus/application/ports.py` | update | +| `backend/src/domus/application/recurrence_ports.py` | update | +| `backend/src/domus/application/task_creation.py` | update | +| `backend/src/domus/application/task_commands.py` | update | +| `backend/src/domus/application/task_queries.py` | update | +| `backend/src/domus/entrypoints/task_http.py` | update | +| `tests/backend/test_task_queries.py` | update | +| `tests/backend/test_task_http.py` | update | +| `backend/src/domus/application/task_events.py` | update | +| `backend/src/domus/application/fair_recurrence.py` | update | +| `backend/src/domus/application/fixed_materialization.py` | update | +| `backend/src/domus/application/relative_recurrence_commands.py` | update | +| `backend/src/domus/infrastructure/sqlite.py` | update | +| `backend/src/domus/infrastructure/sqlite_snapshots.py` | update | +| `backend/src/domus/infrastructure/sqlite_recurrence_schema.py` | update | +| `backend/src/domus/infrastructure/sqlite_relative_recurrence.py` | update | +| `backend/src/domus/infrastructure/sqlite_relative_recurrence_records.py` | update | +| `backend/src/domus/infrastructure/sqlite_recurrence_integrity.py` | update | +| `backend/src/domus/infrastructure/sqlite_tasks.py` | update | +| `backend/src/domus/infrastructure/recurrence_worker.py` | update | +| `backend/src/domus/composition/recurrence.py` | update | +| `backend/src/domus/composition/server.py` | update | +| `backend/src/domus/composition/ingress.py` | update | +| `frontend/src/application/household-controller.ts` | update | +| `frontend/src/composition/entry-runtime.ts` | update | +| `frontend/src/presentation/messages.ts` | update | +| `frontend/playwright.entry.config.ts` | update | +| `frontend/tests/page.test.ts` | update | +| `frontend/tests/runtime.test.ts` | update | +| `frontend/tests/household-controller.test.ts` | update | +| `frontend/tests/component.test.ts` | update | +| `frontend/tests/e2e/administration.spec.ts` | update | +| `frontend/tests/e2e/entry.spec.ts` | update | +| `frontend/tests/e2e/catalog.spec.ts` | update | +| `frontend/src/composition/entry-page.ts` | update | +| `frontend/src/presentation/domus-app.ts` | update | +| `tests/browser/entry_server.py` | update | +| `tests/backend/test_openapi.py` | update | +| `tests/python/test_ci_contract.py` | update | +| `.github/workflows/ci_check.yml` | update | +| `architecture/python-application.ini` | update | +| `tests/architecture/test_python_architecture.py` | update | +| `docs/contracts/household-api.openapi.json` | update | +| `docs/contracts/native-household-api.openapi.json` | update | +| `docs/architecture-recurrence.md` | update | +| `docs/tasks/execution.md` | update | +| `docs/tasks/migration.md` | update | +| `docs/development.md` | update | +| `docs/operations.md` | update | +| `docs/user-guide.md` | update | +| `docs/acceptance-plan.md` | update | +| `docs/roadmap.md` | update | +| `specs/recurrence-calendar.md` | reference | +| `specs/recurrence-execution-foundation.md` | reference | +| `specs/recurrence-storage-continuation.md` | reference | +| `specs/household-tasks.md` | reference | +| `specs/task-execution-foundation.md` | reference | +| `specs/household-administration.md` | reference | +| `docs/contracts/home-assistant.md` | update | +| `addon-edge/config.yaml` | update | +| `addon-edge/Dockerfile` | update | +| `addon-edge/artifact.json` | update | +| `addon-edge/CHANGELOG.md` | update | +| `addon-edge/README.md` | update | +| `addon-edge/DOCS.md` | update | +| `tests/backend/test_app_package.py` | update | +| `tests/containers/test_app_ui.py` | update | +| `docs/installation-home-assistant.md` | update | +| `docs/product-vision.md` | update | +| `docs/architecture.md` | update | +| `docs/evidence/recurrence-execution.json` | update | +| `docs/evidence/recurrence-rules.json` | update | + +Operations are prospective and relative to the verified immutable base 8b1cbc110c389b9cdb6ecd11317c95abf2c4b0a9. The task-workspace integration and preview-packaging paths inherited from merged PR #28, including addon-edge/artifact.json, are updates against the Action-published synchronized head a954ce091d19f6350d64eec759670d5a1fadcfb5; all other original operations retain that immutable base. No managed remote branch was synchronized by a contributor. Each prospective path is checked against its stated actual base. Declare any additional actual implementation/test/integration paths before implementing them. Preserve the three retained owner documents byte-for-byte. The genuine supported schema-9 fixture must be published before further DDL; preserve released1..8fixtures. + +The first visible M2 increment connects the existing task commands and queries to a real create/list/detail/claim/complete/review workflow before adding recurrence and calendar controls. The owner requested a usable task surface before further foundation-only increments. Use server-derived allowed actions and current authorization, preserve original command recovery by key, and keep technical identifiers behind optional details. This sequencing does not retire any retained recurrence/calendar case or all-domain launch obligation. + +Document normal paths, current authorization, configuration precedence, pending/absent/unavailable states, original recovery, both deployments and compatible operator rollback. Run the actual existing `uv run domus-check docs` navigation/documentation check. Validate SDD format, consistency, reciprocal traceability, repository paths and catalog through the original unchanged native SDK. Regenerate the canonical catalog through its actual original publication/rendering flow when metadata changes; no nonexistent pnpm command or compatibility shim is introduced. Future source/test paths are declarations here, not false existing-file evidence in the catalog. + +## 16. Acceptance + +1. Given a configured relative rule with no existing compatible task, when bootstrap commits, then exactly one actual initial obligation is visible and pristine progress remains unconsumed. +2. Given current eligible completion at a local-day boundary, when bounded maintenance commits, then one successor has the correct actual-work-derived date and stable identity. +3. Given duplicate, delayed, or historical absent source facts after closure, when observed across rule edits or compatible restore, then no second effect is created and the original closure remains inspectable. +4. Given rejection or reopening, when reconciliation runs, then only unstarted derived work is invalidated; started/completed work retains its facts and receives review attention without fabricated compensation. +5. Given both fixed and relative backlogs, when a run exhausts either work budget, then the shared definition/slot limits and separate scan limit are respected and durable fair continuation is visible. +6. Given a stale preview or a concurrent start, when apply is attempted, then no stale destructive change commits and the user is asked to refresh and review. +7. Given a lost response after a committed command, when the authorized user recovers the original operation, then the exact original result is returned only after current permission checks. +8. Given a protected bounded calendar request, when the user selects a range, then date-only, planned, due, actual, and approval facts remain distinct and unauthorized or stale cursors reveal no data. +9. Given keyboard-only use in English or Spanish on a narrow viewport, when the agenda is reviewed, then focus, labels, status, and next action remain understandable without color or pointer input. +10. Given a genuine schema 9 input and supported fixtures, when a required migration runs, then it preserves original business rows, receipts, recurrence state, and rollback evidence without relabeling unknown layouts. +11. Given the exact admitted Action-owned branch, when implementation is proposed for merge, then every retained and new acceptance case has actual evidence, current-source images and applicable CI checks pass, no unresolved Bugbot finding remains, and no lifecycle label is misrepresented as full feature acceptance. + +## 17. Requirements traceability + +| Requirement | Cases | +|---|---| +| RCL-01 | recurrence-lifecycle-calendar-C19, recurrence-lifecycle-calendar-C20 | +| RCL-02 | recurrence-lifecycle-calendar-C21, recurrence-lifecycle-calendar-C22, recurrence-lifecycle-calendar-C23, recurrence-lifecycle-calendar-C26, recurrence-lifecycle-calendar-C34 | +| RCL-03 | recurrence-lifecycle-calendar-C23, recurrence-lifecycle-calendar-C24 | +| RCL-04 | recurrence-lifecycle-calendar-C21, recurrence-lifecycle-calendar-C25, recurrence-lifecycle-calendar-C26, recurrence-lifecycle-calendar-C30 | +| RCL-05 | recurrence-lifecycle-calendar-C20, recurrence-lifecycle-calendar-C27, recurrence-lifecycle-calendar-C28 | +| RCL-06 | recurrence-lifecycle-calendar-C29, recurrence-lifecycle-calendar-C30, recurrence-lifecycle-calendar-C31, recurrence-lifecycle-calendar-C35 | +| RCL-07 | recurrence-lifecycle-calendar-C22, recurrence-lifecycle-calendar-C27, recurrence-lifecycle-calendar-C31, recurrence-lifecycle-calendar-C32, recurrence-lifecycle-calendar-C36 | +| RCL-08 | recurrence-lifecycle-calendar-C19, recurrence-lifecycle-calendar-C24, recurrence-lifecycle-calendar-C28, recurrence-lifecycle-calendar-C33, recurrence-lifecycle-calendar-C34 | +| RCL-09 | recurrence-lifecycle-calendar-C32, recurrence-lifecycle-calendar-C35, recurrence-lifecycle-calendar-C36 | + +The table covers exactly the 18 independent refinements C19 through C36. The 93 retained owning cases and published integration aliases remain mandatory with their original mappings; they are not recounted here. Every requirement/case link must be reciprocal. Proposed inventories do not establish actual execution or acceptance. + +## 18. Implementation sequence + +1. Publish this companion through the supported native `companion` action for capability `recurrence-calendar`; preserve the three existing owners byte-for-byte and verify the Action-owned ref. +2. Verify the actual current development base and branch lifecycle. Do not implement on bootstrap master or a contributor replacement ref. +3. Establish pure timing, eligibility, closure, fairness, and review policies with complete branch coverage. +4. Implement durable source inbox, atomic cycle closure, task effects, and exact original-operation recovery; exercise independent-writer, rollback, restart, and restore behavior. +5. Implement shared maintenance budgets, separate source scanning, and both composition-owned runtime lifetimes. +6. Implement reviewed changes, timing classification, protected calendar projections, UI, HA consumption, English/Spanish content, and both deployment acceptance. +7. Complete migration only if required, preserving genuine schema 9 and fixtures; finish documentation, catalog evidence, security, human UX review, and all applicable CI gates. + +Do not declare a planned acceptance case executed until its implementation evidence is present. No implementation begins before native SDD publication and exact managed branch verification. + +## 19. Definition of Done + +- [ ] Every RCL requirement and all retained owner requirements have acceptance and reciprocal traceability. +- [ ] Existing owners remain verbatim; their 93 counted cases and integration aliases remain required and unchanged. +- [ ] Architecture boundaries and executable dependency, DTO, schema, and runtime wiring checks pass. +- [ ] The numeric budget and all inventory cases pass; repository and changed-module coverage thresholds pass, with complete supported statement/branch coverage per authored module. +- [ ] Shared and source budgets are separately enforced, fair, durable, and report truthful continuation in both owned runtime lifetimes. +- [ ] Current authorization, source eligibility, closure, original operation recovery, history, and restore invariants pass. +- [ ] Calendar and review UI, English/Spanish, accessibility, responsive, sanitization, and HA projection acceptance pass in applicable surfaces. +- [ ] Migration starts from genuine supported schema 9 if needed and preserves fixtures and rollback evidence. +- [ ] User, setup, operator, contributor, migration, and troubleshooting documentation is discoverable and validated. +- [ ] Required generated artifacts and applicable OpenAPI/browser/Storybook, architecture, package, type, image, and CI checks pass; no unresolved Bugbot finding remains. +- [ ] `specs/catalog.json` evidence and `specs/CATALOG.md` are current; actual documentation checks and the original native SDK format/consistency/catalog gates pass. +- [ ] No readiness-blocking decision remains unresolved; no release, deployment, HA update, or promotion is inferred. + +## 20. References and decisions + +- Primary sources: supplied immutable catalog at `8b1cbc110c389b9cdb6ecd11317c95abf2c4b0a9`; issue https://github.com/vypdev/domus/issues/23; repository specification standard and template. +- Related specifications: `specs/recurrence-calendar.md`, `specs/recurrence-execution-foundation.md`, `specs/recurrence-storage-continuation.md`. +- Related work: issue #20 continuation; PR #22 checkpoint; PR #24 schema 9 merge; upstream admission recovery evidence at https://github.com/vypdev/copilot/issues/763#issuecomment-6613738740. +- Decisions: use the supported companion route for the existing `recurrence-calendar` capability and new path `specs/recurrence-lifecycle-calendar.md`; preserve owners verbatim; retain all original product policies; use schema after 9 only if later DDL is required. +- Rejected alternatives: replacing an existing owner, recounting retained cases, inferring execution from test counts or lifecycle labels, taking schema 9 ownership from the administration continuation, or treating a bounded checkpoint as full acceptance. +- Follow-up outside this spec: any later product decision genuinely required by implementation must be recorded and block readiness until resolved; none is presently identified. \ No newline at end of file diff --git a/tests/backend/test_app_package.py b/tests/backend/test_app_package.py index 479b930..81bec81 100644 --- a/tests/backend/test_app_package.py +++ b/tests/backend/test_app_package.py @@ -15,7 +15,7 @@ def test_repository_and_edge_manifest_offer_native_ha_entry_without_user_credent manifest = yaml.safe_load((ROOT / "addon-edge/config.yaml").read_text()) assert repository["url"] == "https://github.com/vypdev/domus" assert manifest["slug"] == "domus_edge" and manifest["stage"] == "experimental" - assert manifest["boot"] == "manual" and manifest["version"] == "0.0.0-edge.7" + assert manifest["boot"] == "manual" and manifest["version"] == "0.0.0-edge.9" assert manifest["ingress"] is True and manifest["ingress_port"] == 8099 assert manifest["panel_admin"] is False and manifest["homeassistant_api"] is True assert manifest["options"] == {"public_origins": []} @@ -52,15 +52,15 @@ def test_build_pins_source_checksum_bases_lock_and_nonroot_native_entrypoint() - commits = re.findall(r"^ARG DOMUS_SOURCE_COMMIT=(\w+)$", source, re.MULTILINE) assert len(commits) == 2 and len(set(commits)) == 1 assert re.fullmatch(r"[a-f0-9]{40}", commits[0]) - assert commits[0] == "3200bd9278f57f142ed03b54843a38a4138fc0af" + assert commits[0] == "93d7d77b98a196640bf0049bca36a3d90b7a5857" assert ( - "DOMUS_SOURCE_SHA256=ca991d7b3560dbdb7a6a86a34eea19708f01f10c15da502e49ed8ab740a8533b" + "DOMUS_SOURCE_SHA256=97dc46699ad7eab54c487386ffaffc28624e8e50f9d3a2f1ffc6b525fb7b8f0e" in source ) - assert "ARG BUILD_VERSION=0.0.0-edge.7" in source + assert "ARG BUILD_VERSION=0.0.0-edge.9" in source candidate = json.loads((ROOT / "addon-edge/artifact.json").read_text()) assert candidate["sourceCommit"] == commits[0] - assert candidate["version"] == "0.0.0-edge.7" + assert candidate["version"] == "0.0.0-edge.9" assert "DOMUS_SOURCE_SHA256=" + candidate["sourceArchiveSHA256"] in source assert re.search(r"^ARG DOMUS_SOURCE_SHA256=[a-f0-9]{64}$", source, re.MULTILINE) assert "sha256sum --check" in source @@ -128,8 +128,8 @@ def test_edge_artifact_declares_exact_reviewed_runtime_without_rebinding_histori "pythonFiles", "uiFiles", } - assert artifact["schemaVersion"] == 1 and artifact["version"] == "0.0.0-edge.7" - assert artifact["sourceCommit"] == "3200bd9278f57f142ed03b54843a38a4138fc0af" + assert artifact["schemaVersion"] == 1 and artifact["version"] == "0.0.0-edge.9" + assert artifact["sourceCommit"] == "93d7d77b98a196640bf0049bca36a3d90b7a5857" assert artifact["storageSchema"] == 9 for field in ["sourceArchiveSHA256", "wheelSHA256"]: assert re.fullmatch("[a-f0-9]{64}", artifact[field]) diff --git a/tests/backend/test_recurrence_lifecycle_migration.py b/tests/backend/test_recurrence_lifecycle_migration.py new file mode 100644 index 0000000..046d632 --- /dev/null +++ b/tests/backend/test_recurrence_lifecycle_migration.py @@ -0,0 +1,72 @@ +"""Genuine supported schema-9 input, captured before lifecycle DDL or migration code.""" + +import hashlib +from types import SimpleNamespace + +from test_sqlite_member_profiles import FIXTURES, facts, frozen + +from domus.domain.member_profiles import MemberLocale +from domus.infrastructure.sqlite import SQLiteDatabase +from domus.infrastructure.sqlite_snapshots import SQLiteSnapshots + + +def test_schema9_fixture_preserves_the_exact_captured_pre_ddl_sql(): + assert hashlib.sha256((FIXTURES / "v9.sql").read_bytes()).hexdigest() == ( + "e3c81ef1fa42e1ea302de60662c10e7231466ab32da1931ecde1e6d7a49213b7" + ) + + +def test_supported_schema9_keeps_fixed_work_pristine_relative_progress_and_private_choices( + tmp_path, +): + store = frozen(tmp_path, 9) + before = store.path.read_bytes() + store.initialize(require_existing=True) + restarted = SQLiteDatabase(store.path, store.installation) + with restarted.read() as unit: + fixed, relative = ( + unit.recurrence.schedules("home"), + unit.recurrence.relative_schedules("home"), + ) + assert len(fixed) == len(relative) == 1 + slots = unit.recurrence.slots("home", fixed[0].schedule_id) + assert len(slots) == 2 + assert {slot.occurrence_id for slot in slots} == { + item.occurrence_id for item in unit.tasks.occurrences("home") + } + progress = unit.recurrence.relative_progress("home", relative[0].schedule_id) + assert progress.revision == 1 and progress.source_after == 0 + assert progress.current_occurrence_id is progress.current_slot_key is None + assert progress.evidence is progress.last_run_at is None + assert unit.recurrence.relative_slots("home", relative[0].schedule_id) == () + members = unit.members.list("home") + assert len(members) == 1 + public = unit.presentations.get("home", members[0].member_id) + private = unit.preferences.get("home", members[0].member_id) + assert public.alias == "Fixture 🧹" and public.revision == 2 + assert private.locale is MemberLocale.SPANISH + assert private.routine_participation_summaries is False and private.revision == 2 + assert store.path.read_bytes() == before + + +def test_independent_schema9_snapshot_inspection_preserves_every_original_row(tmp_path): + store = frozen(tmp_path, 9) + # Raw SQL import is test setup; the snapshot boundary requires an owned private file. + store.path.chmod(0o600) + assert store.path.stat().st_mode & 0o777 == 0o600 + with store._connection() as connection: + tables = tuple( + row[0] + for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'") + ) + assert connection.execute("PRAGMA user_version").fetchone()[0] == 9 + original = facts(store.path, tables) + before = store.path.read_bytes() + archive = tmp_path / "snapshot" + snapshots = SQLiteSnapshots( + store, archive, store.installation, SimpleNamespace(now=lambda: 1800000000000) + ) + checkpoint = snapshots.create("source-nine") + assert checkpoint.schema_version == 9 and snapshots.inspect() == checkpoint + assert facts(archive / "domus.sqlite3", tables) == original + assert store.path.read_bytes() == before diff --git a/tests/backend/test_recurrence_source_observations.py b/tests/backend/test_recurrence_source_observations.py new file mode 100644 index 0000000..c01c392 --- /dev/null +++ b/tests/backend/test_recurrence_source_observations.py @@ -0,0 +1,238 @@ +"""Recurrence observes bounded original live/historical facts without treating history as work.""" + +from dataclasses import replace + +import pytest +from test_task_commands import counts +from test_task_events import prepared + +from domus.application.fixed_materialization import MaintenanceScope +from domus.application.recurrence_sources import RecurrenceSourceObservations +from domus.application.task_commands import ReopenTask, VerifyTask +from domus.domain.errors import Forbidden, InvalidInput, StorageFailure +from domus.domain.task_events import TaskEventKind, TaskSourceRecord +from domus.domain.task_values import VerificationState +from domus.infrastructure.sqlite_tasks import SQLiteTasks + + +def source(store): + return RecurrenceSourceObservations(store, MaintenanceScope(store.installation, "home")) + + +def test_original_snapshots_are_bounded_ordered_and_independent_of_delivery(tmp_path): + commands, store, actor, identifiers, oid, done = prepared(tmp_path, child=True) + cid = done.result.decode()["cycle"]["cycle_id"] + approved = commands.verify("approve", VerifyTask(oid, 2, cid, VerificationState.APPROVED)) + reopened = commands.reopen("reopen", ReopenTask(oid, 3, cid, "Perform again")) + reader = source(store) + before, allocated, actor_calls = counts(store), identifiers.count, actor.calls + first = reader.read(limit=2) + assert [v.event.event_id for v in first.items] == [done.operation_id, approved.operation_id] + assert first.source_rows_examined == 2 and not first.complete + second = reader.read(limit=2, after=first.after_position) + assert [v.event.event_id for v in second.items] == [reopened.operation_id] + assert second.complete and second.source_rows_examined == 1 + assert [v.event.kind for v in first.items + second.items] == list(TaskEventKind) + assert [v.event.cycle.revision for v in first.items + second.items] == [1, 2, 3] + assert all(v.original.current for v in first.items + second.items) + assert len({v.event.cycle_key for v in first.items + second.items}) == 1 + assert counts(store) == before + with store.write() as unit: + unit.outbox.acknowledge(done.operation_id) + after_delivery = counts(store) + again = reader.read(limit=2) + assert again.items[0].event == first.items[0].event + assert again.items[0].original.evidence.delivered + assert ( + counts(store) == after_delivery + and identifiers.count == allocated + and actor.calls == actor_calls + ) + empty = reader.read(limit=2, after=second.after_position) + assert empty.complete and empty.items == () and empty.after_position == second.after_position + assert empty.source_rows_examined == 1 + + +@pytest.mark.parametrize("layer", ["application", "repository"]) +@pytest.mark.parametrize( + "values", + [ + {"limit": True}, + {"limit": 0}, + {"limit": 101}, + {"after": True}, + {"after": -1}, + {"after": 2**63}, + ], +) +def test_both_source_boundaries_reject_unbounded_or_noncanonical_requests(tmp_path, layer, values): + _, store, _, _, _, _ = prepared(tmp_path) + with pytest.raises(InvalidInput): + if layer == "application": + source(store).read(**values) + else: + with store.read() as unit: + unit.tasks.lifecycle_sources("home", **values) + + +@pytest.mark.parametrize("change", ["installation", "channel", "household"]) +def test_composition_scope_cannot_cross_installation_channel_or_household(tmp_path, change): + _, store, _, _, _, _ = prepared(tmp_path) + scope = source(store).scope + if change == "household": + scope = replace(scope, household_id="other") + else: + scope = replace( + scope, + installation=replace( + scope.installation, + **( + {"instance_id": "00000000-0000-0000-0000-000000000099"} + if change == "installation" + else {"channel": "stable"} + ), + ), + ) + before = counts(store) + with pytest.raises(Forbidden): + RecurrenceSourceObservations(store, scope).read() + assert counts(store) == before + + +def test_actual_restore_exposes_later_facts_without_rewriting_the_live_pending_cycle(tmp_path): + from test_activation_compatibility import identity_at + from test_sqlite_snapshots import Clock + + from domus.infrastructure.owned_storage import open_storage + from domus.infrastructure.sqlite_activation import SQLiteActivation + from domus.infrastructure.sqlite_restoration import SQLiteRestorations + from domus.infrastructure.sqlite_snapshots import SQLiteSnapshots + + root = tmp_path / "source" + root.mkdir(mode=0o700) + commands, store, _, identifiers, oid, done = prepared(root, child=True) + identity_at(root, store.installation) + archive, target = tmp_path / "archive", tmp_path / "prepared" + checkpoint = SQLiteSnapshots(store, archive, store.installation, Clock()).create("pending") + cid = done.result.decode()["cycle"]["cycle_id"] + approved = commands.verify("approve", VerifyTask(oid, 2, cid, VerificationState.APPROVED)) + reopened = commands.reopen("reopen", ReopenTask(oid, 3, cid, "Perform again")) + expected = source(store).read() + SQLiteRestorations(store, archive, target, store.installation, Clock(), checkpoint).prepare( + "restore-observations" + ) + assert SQLiteActivation(root, store.installation, Clock(), target).activate().state == "active" + restored = open_storage(root, "edge") + try: + before, allocated = counts(restored.database), identifiers.count + rows = source(restored.database).read() + assert [v.event for v in rows.items] == [v.event for v in expected.items] + assert [v.original.current for v in rows.items] == [True, False, False] + assert [v.event.event_id for v in rows.items] == [ + done.operation_id, + approved.operation_id, + reopened.operation_id, + ] + with restored.database.read() as unit: + cycle = unit.tasks.cycle("home", cid) + assert cycle.revision == 1 and cycle.verification is VerificationState.PENDING + assert unit.tasks.occurrence("home", oid).current_cycle_id == cid + assert counts(restored.database) == before and identifiers.count == allocated + finally: + restored.close() + + +@pytest.mark.parametrize("change", ["list", "extra", "order", "repeat", "foreign", "untyped"]) +def test_invalid_semantic_port_pages_fail_closed_without_effects(tmp_path, monkeypatch, change): + commands, store, _, _, oid, done = prepared(tmp_path) + commands.reopen( + "reopen", ReopenTask(oid, 2, done.result.decode()["cycle"]["cycle_id"], "Repeat") + ) + with store.read() as unit: + rows = unit.tasks.lifecycle_sources("home") + if change == "list": + bad = list(rows) + elif change == "extra": + bad = rows + elif change == "order": + bad = tuple(reversed(rows)) + elif change == "repeat": + bad = (rows[0], rows[0]) + elif change == "foreign": + bad = (replace(rows[0], cycle=replace(rows[0].cycle, household_id="other")),) + else: + bad = (None,) + monkeypatch.setattr(SQLiteTasks, "lifecycle_sources", lambda *args, **kwargs: bad) + before = counts(store) + with pytest.raises(StorageFailure): + source(store).read(limit=1 if change == "extra" else 2) + assert counts(store) == before + + +@pytest.mark.parametrize("values", [(None, None), (None, "cycle")]) +def test_source_records_require_typed_original_evidence_and_cycles(values): + with pytest.raises(InvalidInput): + TaskSourceRecord(*values) + + +def test_restored_away_completion_is_observed_without_inventing_a_live_cycle(tmp_path): + from test_activation_compatibility import identity_at + from test_sqlite_snapshots import Clock + from test_task_commands import fixture, resource + + from domus.application.task_commands import CompleteTask, CreateTask + from domus.domain.task_values import TaskContent + from domus.infrastructure.owned_storage import open_storage + from domus.infrastructure.sqlite_activation import SQLiteActivation + from domus.infrastructure.sqlite_restoration import SQLiteRestorations + from domus.infrastructure.sqlite_snapshots import SQLiteSnapshots + + root = tmp_path / "source" + root.mkdir(mode=0o700) + commands, store, _, identifiers = fixture(root) + oid = resource(commands.create("create", CreateTask(TaskContent("Restored work"))))[1] + identity_at(root, store.installation) + archive, target = tmp_path / "archive", tmp_path / "prepared" + checkpoint = SQLiteSnapshots(store, archive, store.installation, Clock()).create("before-work") + done = commands.complete("complete", CompleteTask(oid, 1, ("child",), 90)) + expected = source(store).read().items[0].event + SQLiteRestorations(store, archive, target, store.installation, Clock(), checkpoint).prepare( + "restore-source" + ) + assert SQLiteActivation(root, store.installation, Clock(), target).activate().state == "active" + restored = open_storage(root, "edge") + try: + before, allocated = counts(restored.database), identifiers.count + rows = source(restored.database).read() + assert len(rows.items) == 1 and rows.items[0].event == expected + assert not rows.items[0].original.current + assert rows.items[0].original.archived_by == "restore-source" + with restored.database.read() as unit: + assert unit.tasks.cycle("home", done.result.decode()["cycle"]["cycle_id"]) is None + assert unit.tasks.occurrence("home", oid).current_cycle_id is None + assert counts(restored.database) == before and identifiers.count == allocated + finally: + restored.close() + + +@pytest.mark.parametrize("damage", ["missing-audit", "missing-cycle", "invalid-cycle"]) +def test_incomplete_original_source_fails_before_any_consumer_progress(tmp_path, damage): + _, store, _, _, _, done = prepared(tmp_path) + import json + import sqlite3 + from contextlib import closing + + with closing(sqlite3.connect(store.path)) as connection, connection: + if damage == "missing-audit": + connection.execute("DELETE FROM audit WHERE operation_id=?", (done.operation_id,)) + else: + result = done.result.decode() + result["cycle"] = None if damage == "missing-cycle" else {"invalid": True} + connection.execute( + "UPDATE receipts SET result=? WHERE operation_id=?", + (json.dumps(result), done.operation_id), + ) + before = store.path.read_bytes() + with pytest.raises(StorageFailure): + source(store).read() + assert store.path.read_bytes() == before diff --git a/tests/backend/test_relative_lifecycle.py b/tests/backend/test_relative_lifecycle.py new file mode 100644 index 0000000..59daa10 --- /dev/null +++ b/tests/backend/test_relative_lifecycle.py @@ -0,0 +1,354 @@ +"""Cycle closure survives generation changes and never fabricates human work or compensation.""" + +from dataclasses import FrozenInstanceError, replace + +import pytest +from test_relative_recurrence_records import values +from test_tasks import definition + +from domus.domain.errors import InvalidInput +from domus.domain.identity import Role +from domus.domain.relative_lifecycle import ( + RelativeClosure, + RelativeClosureKind, + SuccessorDisposition, + WorkHistory, + successor_disposition, +) +from domus.domain.relative_recurrence import RelativeOperation +from domus.domain.storage import CommandKey, JsonDocument, Receipt, RestorationProvenance +from domus.domain.task_values import OccurrenceState, TaskPerformer, VerificationState + + +def closure(*, kind=RelativeClosureKind.GENERATED): + schedule, _, slot, cycle = values(restricted=kind is RelativeClosureKind.REJECTED) + if kind is RelativeClosureKind.REJECTED: + cycle = cycle.verify( + VerificationState.REJECTED, + TaskPerformer("reviewer", Role.ADULT), + cycle.recorded_at + 1, + "Please repeat", + ) + if kind is RelativeClosureKind.REOPENED: + cycle = cycle.close(cycle.recorded_at + 1) + slot = slot if kind is RelativeClosureKind.GENERATED else None + key = RelativeClosure.cycle_key(schedule, cycle, values()[2].installation) + command = ( + slot.command + if slot + else CommandKey("home", "system:recurrence", "recurrence.relative.close", key) + ) + return RelativeClosure( + values()[2].installation, + schedule, + cycle, + kind, + slot.created_at if slot else cycle.recorded_at + 100, + RelativeOperation(command, "closed-operation"), + slot, + ) + + +def successor(value, *, state=OccurrenceState.AVAILABLE): + work = definition(created_at=100).new_occurrence( + value.successor.occurrence_id, value.recorded_at + ) + if state is OccurrenceState.COMPLETED: + done = replace( + value.cycle.completion_snapshot(), + occurrence_id=work.occurrence_id, + cycle_id="downstream-cycle", + performed_at=work.created_at + 1, + recorded_at=work.created_at + 1, + ) + return work.complete(done, requirements=()) + return replace( + work, + state=state, + assignee_ids=("adult",) + if state in (OccurrenceState.ASSIGNED, OccurrenceState.IN_PROGRESS) + else (), + state_reason="Previously stopped" + if state in (OccurrenceState.CANCELLED, OccurrenceState.SKIPPED, OccurrenceState.BLOCKED) + else "", + ) + + +@pytest.mark.parametrize("kind", list(RelativeClosureKind)) +def test_closure_keeps_exact_fact_and_original_proof_without_closing_human_cycle(kind): + value = closure(kind=kind) + document = value.document() + receipt = Receipt( + value.operation.command, + "a" * 64, + value.operation.operation_id, + JsonDocument.from_value({"relative_closure": document.decode()}), + value.recorded_at, + ) + assert value.matches_receipt(receipt) + assert value.matches_receipt( + replace(receipt, restoration=RestorationProvenance("restored", False)) + ) + assert not value.matches_receipt(None) + assert not value.matches_receipt(replace(receipt, operation_id="another")) + assert not value.matches_receipt( + replace(receipt, command=replace(receipt.command, key="other")) + ) + assert not value.matches_receipt(replace(receipt, result=JsonDocument("{}"))) + assert not value.matches_receipt( + replace( + receipt, + result=JsonDocument.from_value( + {"relative_closure": {**document.decode(), "recorded_at": str(value.recorded_at)}} + ), + ) + ) + assert document.decode()["kind"] == kind.value + assert value.matches_source(value.schedule, value.cycle) + with pytest.raises(FrozenInstanceError): + value.recorded_at = 0 + if kind is RelativeClosureKind.GENERATED: + assert ( + value.cycle.closed_at is None + and value.successor.source.cycle == value.cycle.completion_snapshot() + ) + + +def test_cycle_identity_ignores_generation_and_verification_but_preserves_all_scope(): + value = closure() + edited = replace( + value.schedule, + revision=2, + pattern_revision=2, + interval=replace(value.schedule.interval, days=60), + ) + assert value.key == RelativeClosure.cycle_key(edited, value.cycle, value.installation) + assert value.matches_source(edited, value.cycle.close(value.recorded_at)) + for changed in [ + replace(value.schedule, schedule_id="other"), + replace(value.schedule, definition_id="other"), + replace(value.schedule, household_id="other"), + replace(value.schedule, created_at=101), + ]: + assert not value.matches_source(changed, value.cycle) + assert not value.matches_source( + value.schedule, replace(value.cycle, performed_at=value.cycle.performed_at - 1) + ) + assert value.key != RelativeClosure.cycle_key( + value.schedule, value.cycle, replace(value.installation, channel="stable") + ) + assert value.key != RelativeClosure.cycle_key( + value.schedule, replace(value.cycle, cycle_id="other"), value.installation + ) + assert value.key != RelativeClosure.cycle_key( + value.schedule, replace(value.cycle, occurrence_id="other"), value.installation + ) + + +@pytest.mark.parametrize( + "change", + [ + {"installation": None}, + {"schedule": None}, + {"cycle": None}, + {"kind": "generated"}, + {"operation": None}, + {"recorded_at": True}, + {"recorded_at": -1}, + {"recorded_at": 100}, + {"successor": None}, + ], +) +def test_invalid_or_incomplete_grant_never_becomes_a_closure(change): + with pytest.raises(InvalidInput): + replace(closure(), **change) + + +@pytest.mark.parametrize("change", ["installation", "schedule", "cycle", "time", "command"]) +def test_successor_and_command_must_match_exact_original_cycle_and_scope(change): + value = closure() + if change == "installation": + slot = replace(value.successor, installation=replace(value.installation, channel="stable")) + changes = {"successor": slot} + elif change == "schedule": + changes = {"schedule": replace(value.schedule, revision=2)} + elif change == "cycle": + changes = {"cycle": replace(value.cycle, cycle_id="other")} + elif change == "time": + changes = {"recorded_at": value.recorded_at + 1} + else: + changes = { + "operation": replace( + value.operation, command=replace(value.operation.command, key="other") + ) + } + with pytest.raises(InvalidInput): + replace(value, **changes) + + +@pytest.mark.parametrize( + "kind", + [ + RelativeClosureKind.REJECTED, + RelativeClosureKind.REOPENED, + RelativeClosureKind.HISTORICAL_ABSENT, + ], +) +def test_non_grant_fences_cannot_hide_a_successor_or_an_unrelated_command(kind): + value = closure(kind=kind) + with pytest.raises(InvalidInput): + replace(value, successor=closure().successor) + for field, bad in [ + ("actor_id", "human"), + ("household_id", "other"), + ("action", "task.complete"), + ("key", "other"), + ]: + with pytest.raises(InvalidInput): + replace( + value, + operation=replace( + value.operation, command=replace(value.operation.command, **{field: bad}) + ), + ) + if kind is not RelativeClosureKind.HISTORICAL_ABSENT: + with pytest.raises(InvalidInput): + replace(value, cycle=value.cycle.completion_snapshot()) + + +@pytest.mark.parametrize("state", list(OccurrenceState)) +@pytest.mark.parametrize("history", list(WorkHistory)) +def test_reopening_only_cancels_proven_unstarted_work_and_preserves_started_or_unknown_history( + state, history +): + value = closure() + work = successor(value, state=state) + result = successor_disposition(value, value.cycle.close(value.recorded_at), work, history) + expected = ( + SuccessorDisposition.RETAINED + if state in (OccurrenceState.CANCELLED, OccurrenceState.SKIPPED) + else SuccessorDisposition.REVIEW + if state in (OccurrenceState.IN_PROGRESS, OccurrenceState.COMPLETED) + or history is not WorkHistory.UNSTARTED + else SuccessorDisposition.CANCEL + ) + assert result is expected + assert work.state is state and value.cycle.closed_at is None + + +def test_absence_is_not_permission_to_recreate_and_source_loss_requires_review(): + value = closure() + assert ( + successor_disposition( + value, value.cycle.close(value.recorded_at), None, WorkHistory.UNSTARTED + ) + is SuccessorDisposition.ABSENT + ) + assert ( + successor_disposition(value, None, successor(value), WorkHistory.UNSTARTED) + is SuccessorDisposition.REVIEW + ) + assert ( + successor_disposition(value, value.cycle, successor(value), WorkHistory.UNSTARTED) + is SuccessorDisposition.CURRENT + ) + fenced = closure(kind=RelativeClosureKind.REOPENED) + assert ( + successor_disposition(fenced, fenced.cycle, None, WorkHistory.UNKNOWN) + is SuccessorDisposition.CLOSED + ) + + +@pytest.mark.parametrize( + "change", ["closure", "cycle", "history", "occurrence", "household", "definition"] +) +def test_invalidation_rejects_foreign_or_untyped_facts(change): + value = closure() + cycle = value.cycle.close(value.recorded_at) + work = successor(value) + args = [value, cycle, work, WorkHistory.UNSTARTED] + if change == "closure": + args[0] = None + elif change == "cycle": + args[1] = replace(cycle, cycle_id="foreign") + elif change == "history": + args[3] = "unstarted" + elif change == "occurrence": + args[2] = replace(work, occurrence_id="other") + elif change == "household": + args[2] = replace( + work, + household_id="other", + definition_snapshot=replace(work.definition_snapshot, household_id="other"), + ) + else: + args[2] = replace( + work, definition_snapshot=replace(work.definition_snapshot, definition_id="other") + ) + with pytest.raises(InvalidInput): + successor_disposition(*args) + + +@pytest.mark.parametrize("field", ["schedule", "cycle", "installation", "household"]) +def test_cycle_key_rejects_untyped_or_foreign_source_facts(field): + value = closure() + args = [value.schedule, value.cycle, value.installation] + if field == "household": + args[1] = replace(value.cycle, household_id="foreign") + else: + args[["schedule", "cycle", "installation"].index(field)] = None + with pytest.raises(InvalidInput, match="invalid_relative_closure"): + RelativeClosure.cycle_key(*args) + + +def test_non_grant_closure_cannot_predate_the_rule_and_zero_is_an_actual_timestamp(): + value = closure(kind=RelativeClosureKind.HISTORICAL_ABSENT) + cycle = replace(value.cycle, performed_at=0, recorded_at=0) + with pytest.raises(InvalidInput, match="invalid_relative_closure"): + replace(value, cycle=cycle, recorded_at=value.schedule.created_at - 1) + zero = replace( + value, schedule=replace(value.schedule, created_at=0), cycle=cycle, recorded_at=0 + ) + assert zero.document().decode()["recorded_at"] == 0 + assert zero.cycle.recorded_at == 0 + + +@pytest.mark.parametrize("kind", [RelativeClosureKind.REJECTED, RelativeClosureKind.REOPENED]) +def test_terminal_consumer_proof_cannot_predate_the_observed_human_decision(kind): + value = closure(kind=kind) + with pytest.raises(InvalidInput, match="invalid_relative_closure"): + replace(value, recorded_at=value.cycle.recorded_at) + + +def test_late_approval_keeps_the_original_basis_and_rejection_invalidates_only_the_successor(): + schedule, _, slot, cycle = values(restricted=True) + value = RelativeClosure( + slot.installation, + schedule, + cycle, + RelativeClosureKind.GENERATED, + slot.created_at, + RelativeOperation(slot.command, "original-grant"), + slot, + ) + before = value.document() + actor = TaskPerformer("reviewer", Role.ADULT) + approved = cycle.verify(VerificationState.APPROVED, actor, value.recorded_at + 1000) + assert value.matches_source(schedule, approved) + assert ( + successor_disposition(value, approved, successor(value), WorkHistory.UNSTARTED) + is SuccessorDisposition.CURRENT + ) + rejected = cycle.verify(VerificationState.REJECTED, actor, value.recorded_at + 1000, "Repeat") + assert value.matches_source(schedule, rejected) + assert ( + successor_disposition(value, rejected, successor(value), WorkHistory.UNSTARTED) + is SuccessorDisposition.CANCEL + ) + assert ( + successor_disposition(value, rejected, successor(value), WorkHistory.STARTED) + is SuccessorDisposition.REVIEW + ) + assert value.document() == before + assert not value.matches_source(None, cycle) + assert not value.matches_source(schedule, None) diff --git a/tests/backend/test_relative_materialization.py b/tests/backend/test_relative_materialization.py new file mode 100644 index 0000000..e706c6b --- /dev/null +++ b/tests/backend/test_relative_materialization.py @@ -0,0 +1,566 @@ +"""Actual initial relative work, atomic proof, manual-work review and restored absence.""" + +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager +from dataclasses import replace +from threading import Barrier + +import pytest +from test_relative_recurrence_creation import request, setup +from test_task_commands import counts + +from domus.application.fixed_materialization import MaintenanceScope +from domus.application.relative_materialization import RelativeMaintenance +from domus.application.task_requests import CreateTask, CreateTaskOccurrence +from domus.domain.errors import ( + Conflict, + Forbidden, + IntegrationUnavailable, + InvalidInput, + NotFound, + StorageBusy, + StorageFailure, +) +from domus.domain.recurrence_records import ScheduleState +from domus.domain.relative_recurrence import RelativeOperation +from domus.domain.storage import JsonDocument, RestorationProvenance +from domus.domain.task_values import DefinitionState, OccurrenceState, TaskContent +from domus.infrastructure.application_runtime import SecureIdentifiers +from domus.infrastructure.recurrence_time import PinnedRecurrenceTime +from domus.infrastructure.sqlite import SQLiteAudit, SQLiteDatabase, SQLiteOutbox, SQLiteReceipts +from domus.infrastructure.sqlite_relative_recurrence import SQLiteRelativeRecurrences +from domus.infrastructure.sqlite_tasks import SQLiteTasks + + +def configured(tmp_path, *, timed=False): + creation, tasks, store, actor, identifiers = setup(tmp_path) + result = creation.create("relative", request(timed=timed)).result.decode() + service = RelativeMaintenance( + store, + MaintenanceScope(store.installation, "home"), + creation.times, + creation.clock, + identifiers, + ) + return service, store, actor, identifiers, result["schedule_id"], tasks + + +def progress(store, schedule_id): + with store.read() as unit: + return unit.recurrence.relative_progress("home", schedule_id) + + +@pytest.mark.parametrize("timed", [False, True]) +def test_initial_step_commits_real_work_slot_continuation_and_proof_in_one_transaction( + tmp_path, timed +): + service, store, actor, _, schedule_id, _ = configured(tmp_path, timed=timed) + before, calls = counts(store), actor.calls + result = service.initial(schedule_id) + assert result.status == "created" and result.schedule_id == schedule_id + with store.read() as unit: + rule = unit.recurrence.relative_schedule("home", schedule_id) + saved = unit.recurrence.relative_progress("home", schedule_id) + slot = unit.recurrence.relative_slot("home", result.receipt.command.key) + work = unit.tasks.occurrence("home", result.occurrence_id) + assert work.state is OccurrenceState.AVAILABLE and work.assignee_ids == () + assert work.current_cycle_id is None and unit.tasks.cycles("home") == () + assert work.definition_snapshot == unit.tasks.definition("home", rule.definition_id) + assert work.due_at == (rule.initial.resolved.instant_ms if timed else None) + assert slot.source is None and slot.planned == rule.initial + assert slot.occurrence_id == work.occurrence_id and slot.matches_receipt(result.receipt) + assert saved.revision == 2 and saved.source_after == 0 and saved.last_run_at == 100 + assert saved.current_occurrence_id == work.occurrence_id + assert saved.current_slot_key == slot.key and not saved.pristine + assert saved.evidence == RelativeOperation( + result.receipt.command, result.receipt.operation_id + ) + assert saved.matches_receipt(result.receipt) + assert result.receipt.command.actor_id == "system:recurrence" + assert result.receipt.command.action == "recurrence.materialize" + assert unit.audit.entries("home")[-1].operation_id == result.receipt.operation_id + assert unit.outbox.pending()[-1].event_id == result.receipt.operation_id + assert counts(store) == (before[0], 1, before[2] + 1, before[3] + 1) + assert actor.calls == calls + + +def test_restart_at_saved_continuation_does_not_rearm_initial_work_or_require_provider( + tmp_path, monkeypatch +): + service, store, _, identifiers, schedule_id, _ = configured(tmp_path) + first = service.initial(schedule_id) + before, allocated = counts(store), identifiers.count + + def unavailable(*args): + raise AssertionError("Saved progress requires no new time-provider work") + + monkeypatch.setattr(PinnedRecurrenceTime, "require_policy", unavailable) + again = replace(service, transactions=SQLiteDatabase(store.path, store.installation)) + second = again.initial(schedule_id) + assert second.status == "current" and second.receipt is None + assert second.occurrence_id == first.occurrence_id + assert counts(store) == before and identifiers.count == allocated + + +def reset_progress(store, schedule_id): + with store.write() as unit: + previous = unit.recurrence.relative_progress("home", schedule_id) + unit.recurrence.save_relative_progress( + replace( + previous, + current_occurrence_id=None, + current_slot_key=None, + last_run_at=None, + evidence=None, + revision=previous.revision + 1, + ), + previous.revision, + ) + + +def test_original_live_slot_reconciles_pristine_cursor_without_more_work_or_provider( + tmp_path, monkeypatch +): + service, store, _, identifiers, schedule_id, _ = configured(tmp_path, timed=True) + first = service.initial(schedule_id) + reset_progress(store, schedule_id) + before, allocated = counts(store), identifiers.count + + def unavailable(*args): + raise AssertionError("Original proof precedes any provider preparation") + + monkeypatch.setattr(PinnedRecurrenceTime, "require_policy", unavailable) + second = service.initial(schedule_id) + assert second.status == "replayed" and second.receipt == first.receipt + assert second.occurrence_id == first.occurrence_id + assert progress(store, schedule_id).matches_receipt(first.receipt) + assert counts(store) == before and identifiers.count == allocated + + +@pytest.mark.parametrize("stage", ["work", "slot", "receipt", "audit", "outbox", "progress"]) +def test_failure_after_each_write_rolls_back_work_mapping_proof_and_continuation( + tmp_path, monkeypatch, stage +): + service, store, _, _, schedule_id, _ = configured(tmp_path) + target, name = { + "work": (SQLiteTasks, "save_occurrence"), + "slot": (SQLiteRelativeRecurrences, "add_relative_slot"), + "receipt": (SQLiteReceipts, "add"), + "audit": (SQLiteAudit, "append"), + "outbox": (SQLiteOutbox, "append"), + "progress": (SQLiteRelativeRecurrences, "save_relative_progress"), + }[stage] + original = getattr(target, name) + before, saved = counts(store), progress(store, schedule_id) + + def fail(self, *args): + original(self, *args) + raise StorageFailure("fixture_after_initial_write") + + with monkeypatch.context() as patch: + patch.setattr(target, name, fail) + with pytest.raises(StorageFailure, match="fixture_after_initial_write"): + service.initial(schedule_id) + assert counts(store) == before and progress(store, schedule_id) == saved + with store.read() as unit: + assert unit.recurrence.relative_slots("home", schedule_id) == () + assert service.initial(schedule_id).status == "created" + + +@pytest.mark.parametrize("change", ["household", "instance", "channel"]) +def test_initial_capability_is_bound_to_actual_installation_channel_and_household(tmp_path, change): + service, store, _, _, schedule_id, _ = configured(tmp_path) + scope = service.scope + scope = ( + replace(scope, household_id="other") + if change == "household" + else replace( + scope, + installation=replace( + scope.installation, + **( + {"channel": "stable"} + if change == "channel" + else {"instance_id": "00000000-0000-0000-0000-000000000001"} + ), + ), + ) + ) + before = counts(store) + with pytest.raises(Forbidden): + replace(service, scope=scope).initial(schedule_id) + assert counts(store) == before + + +@pytest.mark.parametrize("state", [ScheduleState.PAUSED, ScheduleState.RETIRED]) +def test_inactive_rule_does_not_generate_or_consume_its_initial_intention(tmp_path, state): + service, store, _, _, schedule_id, _ = configured(tmp_path) + with store.write() as unit: + rule = unit.recurrence.relative_schedule("home", schedule_id) + unit.recurrence.save_relative_schedule(replace(rule, state=state, revision=2), 1) + before = counts(store) + assert service.initial(schedule_id).status == state.value + assert counts(store) == before and progress(store, schedule_id).pristine + + +@pytest.mark.parametrize("state", [v for v in DefinitionState if v is not DefinitionState.ACTIVE]) +def test_inactive_definition_does_not_create_a_live_initial_obligation(tmp_path, state): + service, store, _, _, schedule_id, _ = configured(tmp_path) + with store.write() as unit: + rule = unit.recurrence.relative_schedule("home", schedule_id) + definition = unit.tasks.definition("home", rule.definition_id) + unit.tasks.save_definition(replace(definition, state=state, revision=2), 1) + before = counts(store) + assert service.initial(schedule_id).status == "definition_inactive" + assert counts(store) == before and progress(store, schedule_id).pristine + + +def test_existing_manual_work_requires_review_instead_of_another_initial_obligation(tmp_path): + service, store, _, _, schedule_id, tasks = configured(tmp_path) + with store.read() as unit: + definition_id = unit.recurrence.relative_schedule("home", schedule_id).definition_id + manual = tasks.create_occurrence("manual", CreateTaskOccurrence(definition_id)) + before = counts(store) + result = service.initial(schedule_id) + assert result.status == "initial_review_required" and result.receipt is None + assert result.occurrence_id is None and progress(store, schedule_id).pristine + assert counts(store) == before + with store.read() as unit: + assert ( + unit.tasks.occurrences("home")[0].occurrence_id + == manual.result.decode()["occurrence_id"] + ) + + +def test_other_definition_work_does_not_block_this_rules_initial_work(tmp_path): + service, store, _, _, schedule_id, tasks = configured(tmp_path) + tasks.create("other", CreateTask(TaskContent("Other household responsibility"))) + assert service.initial(schedule_id).status == "created" + assert counts(store)[:2] == (2, 2) + + +@pytest.mark.parametrize("change", ["rule", "definition", "progress", "manual"]) +def test_writer_rechecks_current_sources_and_manual_work_after_provider_preparation( + tmp_path, monkeypatch, change +): + service, store, _, _, schedule_id, tasks = configured(tmp_path) + original = PinnedRecurrenceTime.require_policy + before = counts(store) + + def change_source(self, policy): + original(self, policy) + if change == "manual": + with store.read() as unit: + definition_id = unit.recurrence.relative_schedule("home", schedule_id).definition_id + tasks.create_occurrence("racing-manual", CreateTaskOccurrence(definition_id)) + return + with store.write() as unit: + rule = unit.recurrence.relative_schedule("home", schedule_id) + if change == "rule": + unit.recurrence.save_relative_schedule( + replace(rule, state=ScheduleState.PAUSED, revision=2), 1 + ) + elif change == "definition": + item = unit.tasks.definition("home", rule.definition_id) + unit.tasks.save_definition( + replace(item, content=TaskContent("Updated"), revision=2), 1 + ) + else: + saved = unit.recurrence.relative_progress("home", schedule_id) + unit.recurrence.save_relative_progress(replace(saved, revision=2), 1) + + monkeypatch.setattr(PinnedRecurrenceTime, "require_policy", change_source) + with pytest.raises( + Conflict, match="relative_initial_review_required|recurrence_source_changed" + ): + service.initial(schedule_id) + assert progress(store, schedule_id).pristine + assert counts(store) == ( + (before[0], 1, before[2] + 1, before[3] + 1) if change == "manual" else before + ) + + +@pytest.mark.parametrize("boundary", ["before_provider", "after_provider", "inside_writer"]) +def test_owned_lifetime_stop_never_publishes_part_of_an_initial_effect( + tmp_path, monkeypatch, boundary +): + service, store, _, _, schedule_id, _ = configured(tmp_path) + stopped = boundary == "before_provider" + original = PinnedRecurrenceTime.require_policy + + def prepare(self, policy): + nonlocal stopped + original(self, policy) + if boundary == "after_provider": + stopped = True + + monkeypatch.setattr(PinnedRecurrenceTime, "require_policy", prepare) + writer = SQLiteDatabase.write + + @contextmanager + def admitted(self): + nonlocal stopped + with writer(self) as unit: + if boundary == "inside_writer": + stopped = True + yield unit + + monkeypatch.setattr(SQLiteDatabase, "write", admitted) + before = counts(store) + assert service.initial(schedule_id, should_continue=lambda: not stopped).status == "stopped" + assert counts(store) == before and progress(store, schedule_id).pristine + + +def test_provider_failure_keeps_new_work_and_proof_absent(tmp_path, monkeypatch): + service, store, _, _, schedule_id, _ = configured(tmp_path) + + def unavailable(*args): + raise IntegrationUnavailable("fixture_time_source_down") + + monkeypatch.setattr(PinnedRecurrenceTime, "require_policy", unavailable) + before = counts(store) + with pytest.raises(IntegrationUnavailable, match="fixture_time_source_down"): + service.initial(schedule_id) + assert counts(store) == before and progress(store, schedule_id).pristine + + +def test_missing_rule_and_stale_expected_revision_have_no_effect(tmp_path): + service, store, _, _, schedule_id, _ = configured(tmp_path) + with store.read() as unit: + rule = unit.recurrence.relative_schedule("home", schedule_id) + before = counts(store) + with pytest.raises(NotFound, match="recurrence_rule_missing"): + service.initial("absent") + with pytest.raises(Conflict, match="recurrence_source_changed"): + service.initial(schedule_id, expected_schedule=replace(rule, revision=2)) + with pytest.raises(InvalidInput): + service.initial("") + assert counts(store) == before + assert service.initial(schedule_id, expected_schedule=rule).status == "created" + + +def test_independent_initial_writers_leave_one_original_and_a_safe_retry(tmp_path): + service, store, _, _, schedule_id, _ = configured(tmp_path) + barrier = Barrier(2) + + class SimultaneousTimes(PinnedRecurrenceTime): + def require_policy(self, policy): + super().require_policy(policy) + barrier.wait(timeout=5) + + def run(_): + independent = replace( + service, + transactions=SQLiteDatabase(store.path, store.installation), + identifiers=SecureIdentifiers(), + times=SimultaneousTimes(), + ) + try: + return independent.initial(schedule_id) + except (Conflict, StorageBusy) as error: + return error + + with ThreadPoolExecutor(max_workers=2) as pool: + results = tuple(pool.map(run, range(2))) + winners = tuple(v for v in results if not isinstance(v, (Conflict, StorageBusy))) + assert len(winners) == 1 and winners[0].status == "created" + assert service.initial(schedule_id).status == "current" + with store.read() as unit: + assert len(unit.tasks.occurrences("home")) == 1 + assert len(unit.recurrence.relative_slots("home", schedule_id)) == 1 + assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 3 + assert progress(store, schedule_id).matches_receipt(winners[0].receipt) + + +@pytest.mark.parametrize("timed", [False, True]) +def test_actual_restore_retains_initial_proof_without_recreating_absent_work( + tmp_path, monkeypatch, timed +): + from test_activation_compatibility import identity_at + from test_sqlite_snapshots import Clock + + from domus.infrastructure.owned_storage import open_storage + from domus.infrastructure.sqlite_activation import SQLiteActivation + from domus.infrastructure.sqlite_restoration import SQLiteRestorations + from domus.infrastructure.sqlite_snapshots import SQLiteSnapshots + + root, archive, target = tmp_path / "root", tmp_path / "archive", tmp_path / "prepared" + root.mkdir(mode=0o700) + service, store, _, identifiers, schedule_id, _ = configured(root, timed=timed) + identity_at(root, store.installation) + checkpoint = SQLiteSnapshots(store, archive, store.installation, Clock()).create( + "before-initial" + ) + first = service.initial(schedule_id) + allocated = identifiers.count + SQLiteRestorations(store, archive, target, store.installation, Clock(), checkpoint).prepare( + "initial-restore" + ) + assert SQLiteActivation(root, store.installation, Clock(), target).activate().state == "active" + + def unavailable(*args): + raise AssertionError("Historical proof never repeats provider preparation") + + monkeypatch.setattr(PinnedRecurrenceTime, "require_policy", unavailable) + storage = open_storage(root, "edge") + try: + recovered = replace(service, transactions=storage.database) + result = recovered.initial(schedule_id) + assert result.status == "historical_absent" and result.receipt.historical + assert result.receipt.operation_id == first.receipt.operation_id + assert result.receipt.result == first.receipt.result + assert identifiers.count == allocated + with storage.database.read() as unit: + assert unit.tasks.occurrences("home") == () + assert unit.recurrence.relative_slots("home", schedule_id) == () + assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 2 + assert unit.recurrence.relative_progress("home", schedule_id).matches_receipt( + result.receipt + ) + assert recovered.initial(schedule_id).status == "current" + assert SQLiteSnapshots( + storage.database, tmp_path / "after", store.installation, Clock() + ).create("after") + finally: + storage.close() + + +@pytest.mark.parametrize("missing", ["progress", "definition", "binding"]) +def test_incomplete_or_mismatched_source_cannot_authorize_initial_work( + tmp_path, monkeypatch, missing +): + service, store, _, _, schedule_id, _ = configured(tmp_path) + saved = progress(store, schedule_id) + before = counts(store) + with monkeypatch.context() as patch: + if missing == "definition": + patch.setattr(SQLiteTasks, "definition", lambda *args: None) + else: + patch.setattr( + SQLiteRelativeRecurrences, + "relative_progress", + lambda *args: None if missing == "progress" else replace(saved, binding="0" * 64), + ) + with pytest.raises(StorageFailure, match="invalid_recurrence_source"): + service.initial(schedule_id) + assert counts(store) == before and progress(store, schedule_id) == saved + + +def test_local_maintenance_capability_cannot_be_repurposed_for_another_actor(tmp_path, monkeypatch): + from domus.application.commands import AtomicCommands + + service, store, _, _, schedule_id, _ = configured(tmp_path) + original = AtomicCommands.in_unit + + def altered(self, unit, command, payload, apply): + return original(self, unit, replace(command, actor_id="other-actor"), payload, apply) + + monkeypatch.setattr(AtomicCommands, "in_unit", altered) + before = counts(store) + with pytest.raises(Forbidden, match="maintenance_scope_denied"): + service.initial(schedule_id) + assert counts(store) == before and progress(store, schedule_id).pristine + + +@pytest.mark.parametrize( + "field,value", + [ + ("occurrence_id", None), + ("created_at", "100"), + ("created_at", 99), + ("created_at", 101), + ("relative_slot_fingerprint", "0" * 64), + ("relative_progress", {}), + ("schedule_id", "other"), + ("definition_id", "other"), + ("slot_key", "0" * 64), + ("version", True), + ], +) +def test_altered_original_result_is_not_adopted_as_cursor_proof( + tmp_path, monkeypatch, field, value +): + service, store, _, _, schedule_id, _ = configured(tmp_path) + first = service.initial(schedule_id) + reset_progress(store, schedule_id) + before, saved = counts(store), progress(store, schedule_id) + original = SQLiteReceipts.get + changed = replace( + first.receipt, + result=JsonDocument.from_value({**first.receipt.result.decode(), field: value}), + ) + + def altered(self, command): + return changed if command == first.receipt.command else original(self, command) + + with monkeypatch.context() as patch: + patch.setattr(SQLiteReceipts, "get", altered) + with pytest.raises(StorageFailure, match="invalid_recurrence_effect"): + service.initial(schedule_id) + assert counts(store) == before and progress(store, schedule_id) == saved + + +@pytest.mark.parametrize("kind", ["command", "payload"]) +def test_original_identity_and_payload_are_both_bound_to_the_initial_slot( + tmp_path, monkeypatch, kind +): + service, store, _, _, schedule_id, _ = configured(tmp_path) + first = service.initial(schedule_id) + reset_progress(store, schedule_id) + before, saved = counts(store), progress(store, schedule_id) + original = SQLiteReceipts.get + changed = ( + replace(first.receipt, command=replace(first.receipt.command, actor_id="other-actor")) + if kind == "command" + else replace(first.receipt, payload_fingerprint="f" * 64) + ) + + def altered(self, command): + return changed if command == first.receipt.command else original(self, command) + + with monkeypatch.context() as patch: + patch.setattr(SQLiteReceipts, "get", altered) + with pytest.raises(StorageFailure if kind == "command" else Conflict): + service.initial(schedule_id) + assert counts(store) == before and progress(store, schedule_id) == saved + + +@pytest.mark.parametrize("mapping", ["absent", "different"]) +def test_missing_or_contradictory_live_mapping_cannot_advance_initial_progress( + tmp_path, monkeypatch, mapping +): + service, store, _, _, schedule_id, _ = configured(tmp_path) + original = SQLiteRelativeRecurrences.relative_slot + + def contradictory(self, household_id, key): + saved = original(self, household_id, key) + return None if mapping == "absent" else replace(saved, occurrence_id="other-work") + + before = counts(store) + with monkeypatch.context() as patch: + patch.setattr(SQLiteRelativeRecurrences, "relative_slot", contradictory) + with pytest.raises(StorageFailure, match="invalid_recurrence_effect"): + service.initial(schedule_id) + assert counts(store) == before and progress(store, schedule_id).pristine + assert service.initial(schedule_id).status == "created" + + +def test_historical_proof_with_an_inconsistent_live_mapping_fails_closed(tmp_path, monkeypatch): + service, store, _, _, schedule_id, _ = configured(tmp_path) + first = service.initial(schedule_id) + with store.read() as unit: + slot = unit.recurrence.relative_slot("home", first.receipt.command.key) + reset_progress(store, schedule_id) + original = SQLiteReceipts.get + historical = replace(first.receipt, restoration=RestorationProvenance("fixture-restore", False)) + + def altered(self, command): + return historical if command == first.receipt.command else original(self, command) + + before, saved = counts(store), progress(store, schedule_id) + with monkeypatch.context() as patch: + patch.setattr(SQLiteReceipts, "get", altered) + patch.setattr(SQLiteRelativeRecurrences, "relative_slot", lambda *args: slot) + with pytest.raises(StorageFailure, match="invalid_recurrence_effect"): + service.initial(schedule_id) + assert counts(store) == before and progress(store, schedule_id) == saved diff --git a/tests/backend/test_sqlite_relative_lifecycle.py b/tests/backend/test_sqlite_relative_lifecycle.py new file mode 100644 index 0000000..98ff81d --- /dev/null +++ b/tests/backend/test_sqlite_relative_lifecycle.py @@ -0,0 +1,192 @@ +"""Closure storage encoding preserves immutable original facts and rejects ambiguous data.""" + +import json +from dataclasses import replace + +import pytest +from test_relative_lifecycle import closure +from test_relative_recurrence_records import values + +from domus.domain.errors import StorageFailure +from domus.domain.relative_lifecycle import RelativeClosureKind +from domus.domain.relative_recurrence import RelativeOperation +from domus.domain.storage import JsonDocument, Receipt, RestorationProvenance +from domus.infrastructure.sqlite_relative_lifecycle import ( + read_relative_closure, + relative_closure_document, +) + + +def date_only_closure(): + schedule, _, slot, cycle = values(False) + original = closure() + return replace( + original, + schedule=schedule, + cycle=cycle, + successor=slot, + recorded_at=slot.created_at, + operation=RelativeOperation(slot.command, original.operation.operation_id), + ) + + +@pytest.mark.parametrize( + "value", + [closure(kind=kind) for kind in RelativeClosureKind] + [date_only_closure()], + ids=[kind.value for kind in RelativeClosureKind] + ["generated-date-only"], +) +def test_closed_roundtrip_preserves_full_snapshot_and_exact_original_proof(value): + encoded = relative_closure_document(value) + restored = read_relative_closure(encoded.encoded) + assert restored == value and restored is not value + assert restored.key == value.key and restored.document() == value.document() + assert relative_closure_document(restored) == encoded + assert restored.operation == value.operation + assert restored.cycle == value.cycle and restored.schedule == value.schedule + assert restored.successor == value.successor + original = Receipt( + value.operation.command, + "a" * 64, + value.operation.operation_id, + JsonDocument.from_value({"relative_closure": value.document().decode()}), + value.recorded_at, + ) + assert restored.matches_receipt(original) + assert restored.matches_receipt( + replace(original, restoration=RestorationProvenance("restored", False)) + ) + assert encoded.decode()["operation"] == { + "command": { + "household_id": value.operation.command.household_id, + "actor_id": value.operation.command.actor_id, + "action": value.operation.command.action, + "key": value.operation.command.key, + }, + "operation_id": value.operation.operation_id, + } + + +def change(document, path, value): + for key in path[:-1]: + document = document[key] + document[path[-1]] = value + + +@pytest.mark.parametrize( + "path,value", + [ + (("version",), True), + (("version",), 1.0), + (("version",), 2), + (("version",), "1"), + (("kind",), "future"), + (("recorded_at",), True), + (("recorded_at",), -1), + (("recorded_at",), 0), + (("installation",), None), + (("installation", "channel"), "unknown"), + (("installation", "instance_id"), "invalid"), + (("schedule",), []), + (("schedule", "version"), 2), + (("schedule", "definition_id"), "other"), + (("schedule", "household_id"), "other"), + (("schedule", "initial", "nominal", "local"), "20260301T120000"), + (("cycle",), None), + (("cycle", "version"), 2), + (("cycle", "household_id"), "other"), + (("cycle", "performed_at"), True), + (("cycle", "cycle_id"), "other"), + (("operation",), None), + (("operation", "command", "household_id"), "other"), + (("operation", "command", "actor_id"), "other"), + (("operation", "command", "action"), "other"), + (("operation", "command", "key"), "other"), + (("operation", "operation_id"), ""), + (("successor",), None), + (("successor", "version"), 2), + (("successor", "installation", "channel"), "stable"), + (("successor", "schedule", "interval_days"), 31), + (("successor", "source", "cycle", "cycle_id"), "other"), + (("successor", "created_at"), 1), + ], +) +def test_corrupt_or_inconsistent_original_record_fails_without_a_default(path, value): + data = relative_closure_document(closure()).decode() + change(data, path, value) + with pytest.raises(StorageFailure, match="invalid_recurrence_record"): + read_relative_closure(JsonDocument.from_value(data).encoded) + + +@pytest.mark.parametrize( + "path", + [ + (), + ("installation",), + ("schedule",), + ("cycle",), + ("operation",), + ("operation", "command"), + ("successor",), + ], +) +def test_unknown_fields_are_not_silently_discarded_at_any_record_boundary(path): + data = relative_closure_document(closure()).decode() + target = data + for key in path: + target = target[key] + target["future_field"] = 1 + with pytest.raises(StorageFailure, match="invalid_recurrence_record"): + read_relative_closure(JsonDocument.from_value(data).encoded) + + +@pytest.mark.parametrize( + "field", + [ + "version", + "installation", + "schedule", + "cycle", + "kind", + "recorded_at", + "operation", + "successor", + ], +) +def test_required_fields_have_no_implicit_legacy_values(field): + data = relative_closure_document(closure()).decode() + del data[field] + with pytest.raises(StorageFailure, match="invalid_recurrence_record"): + read_relative_closure(JsonDocument.from_value(data).encoded) + + +@pytest.mark.parametrize( + "document", + ["", "null", "true", "[]", '"record"', '{"version":1,"version":1}', '{"private":"\\ud800"}'], +) +def test_invalid_json_duplicate_keys_and_non_utf8_text_are_rejected(document): + with pytest.raises(StorageFailure, match="invalid_recurrence_record"): + read_relative_closure(document) + + +def test_noncanonical_json_spacing_is_rejected_without_repair_or_default(): + original = relative_closure_document(closure()) + spaced = json.dumps(original.decode(), indent=3, ensure_ascii=False) + with pytest.raises(StorageFailure, match="invalid_recurrence_record"): + read_relative_closure(spaced) + assert relative_closure_document(read_relative_closure(original.encoded)) == original + assert spaced != original.encoded + + +@pytest.mark.parametrize( + "kind", + [ + RelativeClosureKind.REJECTED, + RelativeClosureKind.REOPENED, + RelativeClosureKind.HISTORICAL_ABSENT, + ], +) +def test_non_generated_closure_does_not_accept_a_successor_mapping(kind): + data = relative_closure_document(closure(kind=kind)).decode() + data["successor"] = relative_closure_document(closure()).decode()["successor"] + with pytest.raises(StorageFailure, match="invalid_recurrence_record"): + read_relative_closure(JsonDocument.from_value(data).encoded) diff --git a/tests/backend/test_task_http.py b/tests/backend/test_task_http.py index a83b620..4cc8ca4 100644 --- a/tests/backend/test_task_http.py +++ b/tests/backend/test_task_http.py @@ -17,6 +17,10 @@ def create(api, key="create", **values): def test_title_only_create_detail_history_and_own_operation_replay(api): receipt = create(api) result = receipt["result"] + workspace = api.client.get(PREFIX + "/access") + assert workspace.status_code == 200, workspace.text + member_id = api.client.get("/api/v1/authorization").json()["member"]["member_id"] + assert workspace.json() == {"household_id": "home", "member_id": member_id, "can_create": True} assert result["state"] == "available" and result["revision"] == 1 assert create(api) == receipt definitions = api.client.get(PREFIX + "/definitions").json()["items"] @@ -28,6 +32,16 @@ def test_title_only_create_detail_history_and_own_operation_replay(api): own = api.client.get(PREFIX + "/operations/" + receipt["operation_id"]) assert own.status_code == 200 and own.json()["result"] == result assert own.json()["delivery"] == "pending" + original = api.client.get(PREFIX + "/requests/task.create/create") + assert original.status_code == 200, original.text + assert original.json() == {**own.json(), "action": "task.create", "command_key": "create"} + with api.store.write() as unit: + unit.outbox.acknowledge(receipt["operation_id"]) + delivered = api.client.get(PREFIX + "/requests/task.create/create") + assert delivered.status_code == 200 and delivered.json()["delivery"] == "delivered" + assert api.client.get(PREFIX + "/requests/task.create/missing").status_code == 404 + assert api.client.get(PREFIX + "/requests/household.update/create").status_code == 422 + assert api.client.get(PREFIX + "/requests/task.create/create?actor_id=other").status_code == 422 history = api.client.get(PREFIX + "/occurrences/" + result["occurrence_id"] + "/history") assert history.status_code == 200 and len(history.json()["items"]) == 1 assert "ha_user_id" not in history.text and "payload_fingerprint" not in history.text @@ -505,6 +519,13 @@ def test_offline_activation_preserves_historical_receipt_in_both_http_compositio value = restored.json() assert value["state"] == "historical" and value["delivery"] == "suppressed" assert value["result"] == completed["result"] and value["archived_by"] == "restore-http" + original = client.get(PREFIX + "/requests/task.complete/historical-work") + assert original.status_code == 200, original.text + assert original.json() == { + **value, + "action": "task.complete", + "command_key": "historical-work", + } assert client.get(PREFIX + "/occurrences/" + oid).json()["cycle"] is None csrf = client.get("/api/v1/auth/csrf").json()["csrf_token"] replay = client.post( diff --git a/tests/backend/test_task_queries.py b/tests/backend/test_task_queries.py index a9e2b2e..94e6219 100644 --- a/tests/backend/test_task_queries.py +++ b/tests/backend/test_task_queries.py @@ -312,6 +312,20 @@ def test_own_operation_lookup_rechecks_original_authority_without_rerunning_prec query = queries(store, service.actor.current()) before = state(store, oid) assert query.operation(receipt.operation_id).receipt == receipt + if receipt.command.action in { + "task.create", + "task.claim", + "task.complete", + "task.verify", + "task.reopen", + }: + assert query.original(receipt.command.action, "lookup").receipt == receipt + with pytest.raises(NotFound): + queries(store, (commands if service is child else child).actor.current()).original( + receipt.command.action, "lookup" + ) + with pytest.raises(NotFound): + query.original(receipt.command.action, "unknown") assert state(store, oid) == before other = commands if service is child else child with pytest.raises(NotFound): @@ -349,6 +363,8 @@ def test_original_configuration_and_attribution_grants_are_required_again_for_op query = replace(query, human=HumanIdentity("ha_admin", False, True, False)) with pytest.raises(Forbidden): query.operation(receipt.operation_id) + with pytest.raises(Forbidden): + query.original(receipt.command.action, receipt.command.key) @pytest.mark.parametrize("kind", ["definitions", "occurrences"]) @@ -575,6 +591,9 @@ def test_actual_offline_restoration_exposes_history_without_reapplying_rolled_ba and history[-1].archived_by == "restore-work" ) assert service.operation(receipt.operation_id).receipt.historical + original = service.original(receipt.command.action, receipt.command.key) + assert original.receipt.historical and not original.receipt.restoration.current + assert original.receipt.result == receipt.result assert service.occurrence(oid).cycle is None and state(restored.database, oid) == before cursor = service.history(oid, limit=2).next_cursor fresh = TaskCommands(restored.database, child.actor, child.clock, ids) @@ -587,3 +606,76 @@ def test_actual_offline_restoration_exposes_history_without_reapplying_rolled_ba assert tail[0].historical and not tail[1].historical finally: restored.close() + + +def test_original_task_request_rejects_non_workspace_actions_and_invalid_keys(tmp_path): + _, store, actor, _ = fixture(tmp_path) + service = queries(store, actor.current()) + for action in ("household.update", "task.future", "task.policy.update"): + with pytest.raises(Forbidden): + service.original(action, "key") + with pytest.raises(InvalidInput): + service.original("task.create", "../key") + + +def test_workspace_access_uses_current_task_grants_and_canonical_member(tmp_path): + commands, child, store, _, admin, _ = prepared(tmp_path) + own = queries(store, commands.actor.current()).workspace() + assert own.household_id == "home" and own.member_id == admin and own.can_create + restricted = queries(store, child.actor.current()).workspace() + assert restricted.member_id == "child" and not restricted.can_create + with store.write() as unit: + member = unit.members.get("home", "child") + unit.members.save( + replace(member, exceptions=(PermissionRule(Permission.TASKS_READ, False),), revision=2), + 1, + ) + with pytest.raises(Forbidden): + queries(store, child.actor.current()).workspace() + + +@pytest.mark.parametrize( + "action,permission", + [("claim", Permission.TASKS_CLAIM), ("complete", Permission.TASKS_COMPLETE)], +) +def test_task_original_recovery_uses_current_task_grants_without_separate_operation_browsing( + tmp_path, action, permission +): + commands, child, store, oid, _, identifiers = prepared(tmp_path, assign=False) + with store.write() as unit: + member = unit.members.get("home", "child") + unit.members.save( + replace( + member, + exceptions=(PermissionRule(Permission.OPERATIONS_READ, False),), + revision=member.revision + 1, + ), + member.revision, + ) + if action == "complete": + child.claim("assign-to-self", ClaimTask(oid, 1)) + result = ( + child.claim("restricted-original", ClaimTask(oid, 1)) + if action == "claim" + else perform(child, store, oid, ("child",), "restricted-original") + ) + query = queries(store, child.actor.current()) + before, allocated = state(store, oid), identifiers.count + assert query.original(result.command.action, result.command.key).receipt == result + with pytest.raises(Forbidden): + query.operation(result.operation_id) + with pytest.raises(NotFound): + queries(store, commands.actor.current()).original(result.command.action, result.command.key) + with store.write() as unit: + member = unit.members.get("home", "child") + unit.members.save( + replace( + member, + exceptions=(*member.exceptions, PermissionRule(permission, False)), + revision=member.revision + 1, + ), + member.revision, + ) + with pytest.raises(Forbidden): + query.original(result.command.action, result.command.key) + assert state(store, oid) == before and identifiers.count == allocated diff --git a/tests/containers/test_app_ui.py b/tests/containers/test_app_ui.py index 363a8ee..2cc9b8b 100644 --- a/tests/containers/test_app_ui.py +++ b/tests/containers/test_app_ui.py @@ -70,8 +70,6 @@ def exercise_packaged_ui_delivery(image, data_volume, expected): def test_packaged_ui_bytes_and_same_listener_standalone_delivery(image, data_volume): expected = json.loads( - ( - Path(__file__).resolve().parents[2] / "docs/evidence/household-administration.json" - ).read_text() + (Path(__file__).resolve().parents[2] / "docs/evidence/task-workspace.json").read_text() )["currentSourcePackage"]["actualProductFiles"] exercise_packaged_ui_delivery(image, data_volume, expected) diff --git a/tests/fixtures/sqlite/v9.sql b/tests/fixtures/sqlite/v9.sql new file mode 100644 index 0000000..a3e2771 --- /dev/null +++ b/tests/fixtures/sqlite/v9.sql @@ -0,0 +1,91 @@ +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); +INSERT INTO "audit" VALUES('fixture-schema9-operation-1','home','fixture_ha_admin','member.presentation.update','fixture-schema9-alias','{"member_id":"domus_9ymqDtH5vfT-fpLaJAUtapBHZ-U7EBlR","previous_revision":1,"revision":2,"transition":"member.presentation.updated"}',1793623486212); +INSERT INTO "audit" VALUES('fixture-schema9-operation-2','home','fixture_ha_admin','member.preferences.update','fixture-schema9-preferences','{"member_id":"domus_9ymqDtH5vfT-fpLaJAUtapBHZ-U7EBlR","previous_revision":1,"revision":2,"transition":"member.preferences.updated"}',1793623486212); +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 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_preferences" VALUES('domus_9ymqDtH5vfT-fpLaJAUtapBHZ-U7EBlR','home','es',0,2); +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))); +INSERT INTO "member_presentations" VALUES('domus_9ymqDtH5vfT-fpLaJAUtapBHZ-U7EBlR','home','Fixture 🧹',2); +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); +INSERT INTO "outbox" VALUES(7,'fixture-schema9-operation-1','home','member.presentation.update','{"member_id":"domus_9ymqDtH5vfT-fpLaJAUtapBHZ-U7EBlR","revision":2}',1793623486212,0); +INSERT INTO "outbox" VALUES(8,'fixture-schema9-operation-2','home','member.preferences.update','{"member_id":"domus_9ymqDtH5vfT-fpLaJAUtapBHZ-U7EBlR","revision":2}',1793623486212,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); +INSERT INTO "receipts" VALUES('home','fixture_ha_admin','member.presentation.update','fixture-schema9-alias','7c9a5ba456b43d017b74f9b3f829e537de474f0600f1ff1265813213b1880a98','fixture-schema9-operation-1','{"alias":"Fixture 🧹","member_id":"domus_9ymqDtH5vfT-fpLaJAUtapBHZ-U7EBlR","revision":2}',1793623486212); +INSERT INTO "receipts" VALUES('home','fixture_ha_admin','member.preferences.update','fixture-schema9-preferences','b7778dafeffe849bdfc23f9491bd87203d8b0073705bf8524db40186af60327f','fixture-schema9-operation-2','{"locale":"es","member_id":"domus_9ymqDtH5vfT-fpLaJAUtapBHZ-U7EBlR","notification_delivery":"unavailable","revision":2,"routine_participation_summaries":false}',1793623486212); +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',8); +COMMIT; +PRAGMA application_id=1146047829; +PRAGMA user_version=9; diff --git a/tests/packaging/test_ui_wheel.py b/tests/packaging/test_ui_wheel.py index ac6b2d9..6fb4565 100644 --- a/tests/packaging/test_ui_wheel.py +++ b/tests/packaging/test_ui_wheel.py @@ -21,7 +21,7 @@ def test_built_wheel_contains_exact_product_bundle_and_no_browser_fixture(tmp_pa if path.is_file() } assert len(expected) >= 4 - candidate = json.loads((ROOT / "docs/evidence/household-administration.json").read_text())[ + candidate = json.loads((ROOT / "docs/evidence/task-workspace.json").read_text())[ "currentSourcePackage" ]["actualProductFiles"] assert candidate == { diff --git a/tests/python/test_ci_contract.py b/tests/python/test_ci_contract.py index 6b2433c..42c57cd 100644 --- a/tests/python/test_ci_contract.py +++ b/tests/python/test_ci_contract.py @@ -130,6 +130,7 @@ def test_new_runtime_sources_require_an_explicit_coverage_scope() -> None: "frontend/.storybook/preview.ts", "frontend/stories/entry.stories.ts", "frontend/stories/administration.stories.ts", + "frontend/stories/tasks.stories.ts", "frontend/playwright.config.ts", "frontend/playwright.entry.config.ts", "frontend/vite.config.ts", @@ -256,19 +257,23 @@ def test_installed_copilot_resources_are_unchanged(relative: str, expected: str) assert hashlib.sha256((ROOT / relative).read_bytes()).hexdigest() == expected, relative -def test_both_cpu_images_run_all_actual_administration_browser_journeys() -> None: +def test_both_cpu_images_run_all_actual_household_browser_journeys() -> None: steps = workflow()["jobs"]["containers"]["steps"] actual = next( step for step in steps - if step.get("name") == "Execute administration in the actual isolated image from Chromium" + if step.get("name") + == "Execute household workflows in the actual isolated image from Chromium" ) assert actual["env"] == { "DOMUS_BROWSER_IMAGE": "domus-app-ci", "DOMUS_TEST_SOURCE_COMMIT": "${{ github.sha }}", "DOMUS_TEST_ARCH": "${{ matrix.arch }}", } - assert "playwright.entry.config.ts tests/e2e/administration.spec.ts" in actual["run"] + assert ( + "playwright.entry.config.ts tests/e2e/administration.spec.ts tests/e2e/tasks.spec.ts" + in actual["run"] + ) assert "--grep" not in actual["run"] assert "--pass-with-no-tests" not in actual["run"] commands = [step["run"] for step in steps if "run" in step] @@ -277,10 +282,10 @@ def test_both_cpu_images_run_all_actual_administration_browser_journeys() -> Non audit = next( step for step in steps - if step.get("name") == "Require all thirty-four actual-image administration journeys" + if step.get("name") == "Require all forty-four actual-image household journeys" ) assert all(field in audit["run"] for field in ["expected", "unexpected", "skipped", "flaky"]) - assert "expected']==34" in audit["run"] + assert "expected']==44" in audit["run"] cleanup = next( step for step in steps if step.get("name") == "Require no surviving image browser fixtures" ) @@ -322,3 +327,35 @@ def test_both_cpu_images_require_the_immutable_schema9_application_rollback_pair assert "assert SCHEMA_VERSION == 9" in fixture assert "pnpm install --frozen-lockfile" in fixture assert "uv sync --frozen --no-dev --package domus-server --no-editable" in fixture + + +def test_actual_image_browser_evidence_is_retained_per_cpu_even_after_failure() -> None: + steps = workflow()["jobs"]["containers"]["steps"] + artifact = next( + (step for step in steps if step.get("name") == "Retain actual-image browser evidence"), + None, + ) + assert artifact is not None, "A failing actual-image browser run must retain its evidence" + assert artifact["if"] == "${{ always() }}" + assert artifact["with"]["name"] == "image-browser-${{ matrix.arch }}-${{ github.sha }}" + assert artifact["with"]["path"] == "frontend/test-results/" + assert artifact["with"]["include-hidden-files"] == "true" + assert artifact["with"]["retention-days"] == "7" + assert artifact["with"]["if-no-files-found"] == "warn" + execution = next( + step + for step in steps + if step.get("name") + == "Execute household workflows in the actual isolated image from Chromium" + ) + assert steps.index(artifact) > steps.index(execution) + + +def test_actual_image_fixture_retirement_is_checked_even_after_browser_failure() -> None: + cleanup = next( + step + for step in workflow()["jobs"]["containers"]["steps"] + if step.get("name") == "Require no surviving image browser fixtures" + ) + assert cleanup.get("if") == "${{ always() }}" + assert "continue-on-error" not in cleanup