From 0806a38f8a2de1f102a017bff25ad6856fb17bcd Mon Sep 17 00:00:00 2001
From: "copilot-action[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sun, 1 Nov 2026 22:25:12 +0100
Subject: [PATCH 01/16] docs(sdd): specify issue contract in
specs/recurrence-storage-continuation.md
---
specs/CATALOG.md | 4 +-
specs/catalog.json | 3 +-
specs/recurrence-storage-continuation.md | 389 +++++++++++++++++++++++
3 files changed, 393 insertions(+), 3 deletions(-)
create mode 100644 specs/recurrence-storage-continuation.md
diff --git a/specs/CATALOG.md b/specs/CATALOG.md
index 412e4de..d0c0235 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) + 1 companion | 168 paths · 2026-10-31 |
| `household-tasks` | Proposed | Members create and perform household responsibilities with explicit assignments, actual performers and history. A recurring definition survives completion of each individual occurrence. | [Task definitions, occurrences and household execution](./household-tasks.md) + 1 companion | 32 paths · 2026-11-01 |
-| `recurrence-calendar` | Proposed | A bounded scheduler turns task definitions into stable occurrences and a family calendar distinguishes planned start, deadline and actual completion. | [Recurrence engine and family calendar](./recurrence-calendar.md) + 1 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) + 2 companion | 22 paths · 2026-10-29 |
| `points-rewards` | Proposed | Task completion produces auditable point entries. Household-configurable collaboration policies determine each share and the actual award total; corrections preserve history and optional rewards never control basic participation. | [Auditable points, participation and optional rewards](./points-rewards.md) | 5 paths · 2026-11-01 |
| `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)
+- 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)
- 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 fc13b69..a954304 100644
--- a/specs/catalog.json
+++ b/specs/catalog.json
@@ -482,7 +482,8 @@
"lastVerified": "2026-10-29",
"specs": [
"specs/recurrence-calendar.md",
- "specs/recurrence-execution-foundation.md"
+ "specs/recurrence-execution-foundation.md",
+ "specs/recurrence-storage-continuation.md"
],
"workflows": [],
"entrypoints": [],
diff --git a/specs/recurrence-storage-continuation.md b/specs/recurrence-storage-continuation.md
new file mode 100644
index 0000000..d78cd47
--- /dev/null
+++ b/specs/recurrence-storage-continuation.md
@@ -0,0 +1,389 @@
+# Durable recurrence execution and calendar continuation
+
+- Status: Draft
+- Date: 2026-11-01
+- Catalog capability ID: recurrence-calendar
+- Last verified: 2026-10-29 (catalog evidence); implementation baseline: develop commit `3131810082760a98ee4af66f11bb65eaac49cc3b`
+- Owners: Domus maintainers (review assignment pending)
+- Scope: Complete durable recurrence execution and bounded calendar queries after the approved planning checkpoint, preserving all existing owner and companion requirements.
+- Related issues/PRs: [Issue #20](https://github.com/vypdev/domus/issues/20), issue #17, PRs #18 and #19
+- Required review gates: product UX, architecture, testing, documentation, security/operations
+- Open decisions blocking readiness: None identified by the owner. Status remains Draft until implementation evidence and required review gates are complete.
+
+## 1. Executive summary
+
+Domus will turn authorized recurring responsibilities into durable household tasks and expose truthful, bounded calendar facts across its native and standalone persistent runtimes. The recommendation is to keep the existing planning policy and add transactional execution, lifecycle handling, and protected calendar queries under the recurrence-calendar capability. The safety rule is that a nominal occurrence is committed at most once, with its task/progress, receipt, audit, and outbox facts atomically consistent; outages and stale previews remain explicit states and never silently broaden authorization.
+
+```text
+Authorized schedule -> bounded materialization -> durable task and receipt -> lifecycle progress -> protected calendar query
+```
+
+This companion adds four storage-contract refinements. It does not replace or redefine `specs/recurrence-calendar.md` or `specs/recurrence-execution-foundation.md`. All eight RCE requirements, all 52 companion cases, and all 37 original owner cases remain required. Existing cases are retained evidence and are not recounted in this companion's new case inventory. No planned acceptance case below is claimed as an executed test.
+
+## 2. Problem, current behavior, and evidence
+
+### 2.1 Problem
+
+Households and operators cannot yet rely on planned responsibilities surviving restart, concurrent workers, lifecycle changes, or compatible restoration. The planning foundation can describe occurrences, but it does not prove that execution committed them. Calendar consumers also lack an owned bounded query contract that distinguishes planned start, deadline, and actual completion.
+
+### 2.2 Current behavior
+
+1. The verified development tree includes approved merges #18 and #19 and is byte-identical to reviewed/tested synced head `580ab11`.
+2. The foundation has strict rule validation, finite original frequency periods, timezone resolution, and side-effect-free planning.
+3. Planning cursors are values, not durable execution proof. The database is schema 7 and has no recurrence tables, durable materializer, lifecycle consumer, calendar query/API, or composed runner.
+4. Current immutable owner Edge.5 images do not invoke these modules. Five initial SQLite source/test/fixture files are preserved privately, but have an expected missing-symbol failure and are not implemented, passing, or publishable evidence.
+5. The combined foundation's 3,398 backend tests across 117 authored modules report 100% statement and branch coverage. This is foundation evidence only, not execution acceptance.
+
+### 2.3 Evidence
+
+- Code and documentation: `backend/src/domus/application/fixed_recurrence.py`, `backend/src/domus/application/recurrence_ports.py`, `backend/src/domus/domain/recurrence_rules.py`, `backend/src/domus/domain/recurrence_planning.py`, `backend/src/domus/domain/nominal_recurrence.py`, `backend/src/domus/infrastructure/recurrence_time.py`, `backend/src/domus/infrastructure/sqlite.py`, and the existing task/source/restore seams.
+- Concrete example: after a planner returns a nominal occurrence, a restart or competing worker currently has no durable recurrence receipt to prove whether materialization already happened.
+- External primary sources: not applicable; this contract is repository-specific.
+- Unknowns: no new owner decision is pending. Exact implementation details remain subject to architecture review; no framework execution support is implied by current catalog paths.
+
+### 2.4 Retrospective classification (as-built baselines only)
+
+Not applicable: this is a prospective companion. The current behavior above is evidence for the baseline, not a reconstruction of an implemented execution capability.
+
+## 3. Actors, surfaces, and terminology
+
+| Actor | Goal | Entry point | Visible surfaces |
+|---|---|---|---|
+| Household member | Understand upcoming responsibilities and truthful task dates | Existing task/calendar consumer | Task and calendar views, later UI/HA surfaces |
+| Authorized application caller | Create or attach a schedule without creating a duplicate first task | Current authorized application flow | Task result and explicit conflict/unavailable result |
+| Native runtime | Materialize and consume durable work | Composed application runner | Operational status and logs |
+| Standalone runtime | Perform equivalent bounded durable work | Composed application runner | Operational status and logs |
+| Operator | Recover, inspect, or restore without recreating processed work | Maintenance and restore seams | Logs, durable receipts, audit evidence |
+
+A **schedule** is a persistent fixed or completion-relative recurrence definition. A **nominal slot** is a stable scheduled occurrence identity. A **receipt** records the durable result for a slot. **Actual work** is the completion fact used as the relative anchor; it is distinct from planned start and deadline. **Schema 8** is the recurrence execution migration. A **preview** is a bounded proposed operation that can become stale. Existing technical maintenance capability does not establish current human proof or permissions. Ownership/channel/generation fences and original receipts remain authoritative.
+
+## 4. Goals
+
+| Requirement | Behavior |
+|---|---|
+| REC-CONT-01 | Persist fixed and completion-relative schedules, unique nominal slots, lifecycle inbox/cycle closure, and transactional task/progress/receipt/audit/outbox facts; support bounded materialization in native and standalone runtimes. |
+| REC-CONT-02 | Preserve authorized create/reviewed attachment semantics without duplicating an existing first task; provide pause, resume, skip, reschedule, and preview/apply with stale-preview conflict behavior. |
+| REC-CONT-03 | Preserve schema 1–7 compatibility and fixture bytes; introduce recurrence schema 8 with atomic migration/rollback, restart/concurrency/crash safety, and historical restore without recreation. |
+| REC-CONT-04 | Expose bounded protected calendar queries and strict HTTP contracts with genuine task classification, date-only, planned-start, deadline, and actual-work facts; retain configured fold, first-valid gap/whole-date adjustment, and collision-review behavior. |
+
+### 4.1 Goals
+
+1. Deliver all four requirements above while retaining every requirement and case in both owning specifications.
+2. Ensure concurrent materialization, restart, crash recovery, and compatible restoration do not duplicate processed slots.
+3. Keep the first all-domain release, complete UI/HA calendar surfaces, and both deployments required; this continuation does not declare any accepted.
+
+### 4.2 Non-goals
+
+1. Changing the existing gateway or modifying Copilot resources/configuration; report Copilot defects upstream.
+2. Selecting a principal, installation, channel, or human permission on behalf of a request.
+3. Declaring an owner HA update, deployment, release/tag, or first all-domain release complete.
+4. Replacing either existing specification or treating closed/100% lifecycle labels on issue #17 as full feature acceptance.
+
+### 4.3 Fixed product/safety invariants
+
+1. Authorization remains current and explicit. Technical maintenance capability is not human proof or permission.
+2. Provider outages, stale previews, historical results, and original-result recovery are explicit unavailable/conflict/historical outcomes.
+3. No duplicate task or slot may result from retries, races, restart, or restore. A transaction cannot expose a task without its matching progress/receipt/audit/outbox facts.
+4. Original receipts, ownership/channel/generation fences, compatible whole application/database rollback, immutable historical fixture bytes, and existing time-resolution rules are preserved.
+
+## 5. Current versus proposed product journey
+
+| Stage | Current | Proposed | User/operator effect |
+|---|---|---|---|
+| Authorize schedule | Planning foundation exists; execution is absent | Current authorization gates scheduled creation or reviewed attachment | Caller sees created, attached, unavailable, or conflict result |
+| Plan occurrence | Side-effect-free planner returns values | Bounded materializer claims stable nominal slots | Work remains bounded and inspectable |
+| Commit occurrence | No recurrence receipt proves durable processing | Atomic task/progress/receipt/audit/outbox transaction | Retry and concurrent workers converge on one result |
+| Process lifecycle | No durable lifecycle consumer | Inbox and cycle closure process duplicate/out-of-order events safely | Operators can distinguish pending, completed, and recoverable work |
+| Query calendar | No owned calendar query/API | Protected bounded query returns truthful date facts | Consumers share one scheduling model |
+
+```mermaid
+flowchart LR
+ A[Authorized schedule action] --> B[Bounded planning and slot claim]
+ B --> C[Atomic task and receipt commit]
+ C --> D[Lifecycle inbox and cycle closure]
+ D --> E[Protected bounded calendar query]
+```
+
+Text equivalent: authorized schedule action -> bounded slot planning and atomic durable commit -> lifecycle processing -> protected calendar facts.
+
+## 6. Functional behavior and state model
+
+### 6.1 Happy path
+
+1. The application verifies current authorization and the selected schedule operation. It does not infer a principal, installation, channel, or permission.
+2. Creation or reviewed attachment checks whether the first task already exists and avoids duplication.
+3. A bounded materializer computes eligible nominal slots and atomically claims each unique slot with task, progress, receipt, audit, and outbox facts.
+4. Lifecycle events enter a durable inbox. Duplicate delivery is idempotent; cycle closure advances completion-relative scheduling from actual work.
+5. Calendar queries enforce authorization and bounds and return task classification plus date-only, planned-start, deadline, and actual-work facts.
+
+### 6.2 Alternative paths
+
+- Pause prevents new materialization; resume continues from durable state without recreating receipts.
+- Skip and reschedule operate on a reviewed slot and preserve audit history. Preview/apply rejects a stale preview as conflict.
+- Provider outage returns unavailable with retained durable facts; retry is bounded and does not widen authorization.
+- Historical restore preserves original receipts and does not recreate already processed work.
+- Fixed and completion-relative schedules use the existing configured single fold, first-valid gap/whole-date adjustment, and collision-review behavior.
+
+### 6.3 State machine
+
+| State | Entered when | User-visible meaning | Allowed next states | Recovery/owner |
+|---|---|---|---|---|
+| Authorized | Current authorization is verified | Requested schedule action may proceed | Previewed, attached, rejected | Application use case |
+| Previewed | Bounded operation is computed | Proposed changes await apply | Committed, stale conflict, unavailable | Application and operator |
+| Due | Nominal slot is eligible | Work is ready for bounded materialization | Committed, paused, skipped | Materializer |
+| Committed | Atomic facts and unique receipt exist | Occurrence was processed once | Lifecycle pending, closed | Database/application |
+| Lifecycle pending | Inbox event awaits processing | Completion/progress may be pending | Closed, retryable failure | Lifecycle consumer |
+| Closed | Cycle completion recorded | Relative schedule may use actual-work anchor | Due, paused | Application |
+| Historical | Restore identifies prior result | Prior processing is retained | Historical only | Restore seam |
+
+Duplicate events return the original result. Out-of-order events are retained/handled according to lifecycle ordering and cannot overwrite a newer generation. Canceled or paused schedules produce no new slots. Partial transaction failure rolls back the whole transaction; a committed transaction's receipt remains authoritative after response loss.
+
+## 7. User-facing configuration
+
+| Input | Type | Recommended default | Allowed values/range | Scope/persistence |
+|---|---|---|---|---|
+| Materialization batch limit | Positive integer | Small bounded batch selected by runtime policy | Positive, implementation-defined hard maximum; never unbounded | Per invocation; reread for next batch |
+| Calendar query limit | Positive integer | Conservative bounded page | Positive, implementation-defined hard maximum | Per request |
+| Time-zone ambiguity policy | Existing rule value | Existing configured single fold | Existing supported fold/gap policy only | Persisted schedule snapshot |
+| Relative anchor | Enum | Actual work completion | Actual work only | Durable cycle fact |
+
+Validation rejects nonpositive or over-limit bounds, invalid cross-field date ranges, unsupported time rules, and schedule data that violates existing strict rule validation. Operation authorization is reread at execution; a preview does not authorize later apply. Persisted schedules retain the validated rule snapshot. Schema migration treats absent legacy recurrence data as no recurrence state and preserves historical schema 1–7 data. Unknown future values fail closed and remain diagnosable; they are not interpreted as executable instructions. Safety invariants are not configurable.
+
+Recommended example: materialize the next bounded page of a weekly household schedule using its persisted timezone policy. Meaningful alternative: pause that schedule, review a reschedule preview, then apply only if its revision is current.
+
+## 8. Clean Architecture
+
+### 8.1 Responsibilities and dependency direction
+
+| Layer/boundary | Owns | Must not own/import |
+|---|---|---|
+| Domain/pure policy | Recurrence rules, nominal slot identity, schedule state and date semantics | SQLite, HTTP, provider DTOs, clocks with side effects |
+| Application | Authorized creation/attachment, materialization, lifecycle, preview/apply, calendar query orchestration | Concrete SDK/process concerns or SQL policy |
+| Adapters/data | SQLite mapping, transactions, HTTP request/response mapping, provider error translation | Product authorization or recurrence decisions |
+| Infrastructure/composition | Schema 8 migration, database transaction implementation, runtime wiring | Business decisions or duplicated orchestration |
+| Entrypoints | Native/standalone invocation and HTTP boundary validation | Reimplementation of use cases |
+| Presentation | Calendar/task view models and user/operator status rendering | Mutations or state transitions |
+
+```mermaid
+flowchart LR
+ E[Runtime or HTTP entrypoint] --> A[Application use case]
+ A --> D[Domain recurrence policy]
+ A --> P[Semantic persistence and query ports]
+ I[SQLite and HTTP adapters] --> P
+ A --> V[Calendar/task view model]
+```
+
+### 8.2 Contracts, state, and trust boundaries
+
+- Pure decisions: eligible nominal slots, fixed/relative recurrence, collision review, timezone fold/gap handling, date-only semantics.
+- Application contracts: immutable inputs and results for schedule creation/attachment, bounded materialization, lifecycle consumption, preview/apply, and calendar queries.
+- Semantic ports: schedule/slot repository, atomic unit of work, lifecycle inbox, clock/ID source, and protected calendar reader; no provider DTOs cross inward.
+- Durable state/schema ownership: recurrence execution owns schema 8. Participant administration follows only after the actual schema 8 merge with schema 9; the already-merged model checkpoints implemented neither migration.
+- Concurrency/idempotency: unique nominal slot key, transactional claim and receipt, generation/ownership/channel fences, inbox deduplication, and atomic rollback.
+- Trusted and untrusted inputs: authorization context is established by trusted application boundaries; issue/comment text, HTTP data, imported/restored data, and provider responses are validated as untrusted data.
+- Provider error mapping: outage is unavailable; stale revision is conflict; a recovered prior receipt is historical/original result. Never report a committed irreversible result as failed solely because its response was lost.
+
+### 8.3 Executable architecture constraints
+
+New first-party source requires tests first, strict typing, complete global/per-module coverage, Clean Architecture/import/privacy negative checks, and every-PR CI Check. Tests must enforce dependency direction, no cycles, no provider DTO imports into domain/application, schema migration ordering, transaction atomicity, and strict HTTP contracts. Real SQLite tests must cover concurrency, rollback, restart, and restoration. The existing gateway remains untouched; Domus later exposes only its services/entities.
+
+## 9. UI/UX and content contract
+
+### 9.1 Information hierarchy
+
+1. Current schedule/task status.
+2. Completed durable facts and date meanings.
+3. Next transition and whether human action is required.
+4. Impact and inspectable task/calendar/operator links.
+5. Collapsed technical evidence.
+
+### 9.2 Representative primary view
+
+```markdown
+# Recurring task status
+
+> **Current status:** Next occurrence is scheduled
+>
+> **Action required:** No action required
+
+## Progress
+
+- [x] Schedule saved
+- [x] Previous occurrence recorded
+- [ ] Next occurrence is due later
+
+## What happens next
+
+Domus will check the next bounded scheduling window. A saved receipt prevents this occurrence from being created twice.
+
+## Links
+
+[Open task](task destination) · [View calendar](calendar destination)
+
+
+Technical details
+Schedule revision, nominal slot, receipt, and sanitized runtime evidence.
+
+```
+
+State variants:
+
+- Pending/no action: “Status: Schedule paused. Action required: No action required. Existing tasks remain; no new occurrence will be created until resumed.”
+- Action required: “Status: Reschedule preview is ready. Action required: Review and apply this preview before its revision changes.”
+- Blocked/failed: “Status: Calendar data is unavailable. Cause: The storage/provider request failed. Action: Retry when available. Retained state: Previously committed receipts and tasks remain inspectable.”
+- Partial success: “Status: Occurrence committed; response was interrupted. Action required: No action required. Retained state: The receipt and task are durable; reopening the task shows the original result.”
+- Completed: “Status: Cycle closed. Action required: No action required. Actual completion is recorded and anchors the next relative occurrence.”
+
+### 9.3 Issue, PR, and comment behavior
+
+- Deterministic titles identify recurrence execution and calendar query work; the canonical issue is linked directly.
+- Merge enables code through the approved branch/CI lifecycle; closing an issue does not establish feature acceptance. Retry reuses the nominal slot and returns its durable original result.
+- Durable database receipts and audit facts are authoritative; labels/checks are supplemental.
+- No recurring comment or notification is required for materialization. PR summaries update idempotently, with one primary status and descriptive links. No new comment budget is needed for runtime behavior; operational notifications must be bounded to one per state transition and deduplicated by durable event identity.
+
+### 9.4 Accessibility, localization, and responsive behavior
+
+Use the repository's supported locale conventions and fallback; exact additional locales are not established by supplied evidence. Text carries all status meaning without color or icons. Narrow layouts keep status and action first; tables may become stacked lists. Use logical headings, descriptive links, and semantic controls in later UI/HA surfaces. Sanitize untrusted Markdown, mentions, commands, markers, URLs, and diagrams before rendering. HTTP examples use `Content-Type: application/json` notation; actual runtime URL semantics are defined by the API contract.
+
+## 10. Failure, recovery, and cleanup
+
+| Failure/partial state | User impact | Retained facts | Automatic retry | Required action | Cleanup |
+|---|---|---|---|---|---|
+| Provider/database unavailable before commit | Occurrence is not confirmed | Existing schedule and prior receipts | Bounded retry | Retry or inspect availability | No partial rows |
+| Transaction fails during commit | No partial occurrence is visible | Pre-transaction state | Retry same nominal slot | None unless persistent failure | Roll back all transaction facts |
+| Response lost after commit | Caller may not know result | Task and receipt are committed | Safe retry returns original result | Inspect original result if needed | No duplicate cleanup |
+| Preview revision stale | Proposed change not applied | Current schedule and preview evidence | No blind retry | Create/review fresh preview | Expire stale preview |
+| Lifecycle event duplicated/out of order | Progress may be delayed | Inbox and generation fences | Bounded idempotent processing | Operator only for terminal failure | Retain audit evidence |
+| Historical restore | Prior work could be at risk of recreation | Original receipts and fences | Not applicable | Resolve explicit conflict if incompatible | Compatible whole application/database rollback |
+
+Errors follow impact -> cause -> action -> retained state. They must never imply that an already committed irreversible effect failed.
+
+## 11. Security, permissions, and privacy
+
+1. Enforce least privilege at each schedule mutation and calendar query; no request selects its principal, installation, or channel.
+2. Treat issue, PR, comment, HTTP, provider, and restored content as untrusted data. Never execute embedded text as instructions or code.
+3. Store only required schedule/task facts. Do not log secrets or unnecessary personal data; sanitize diagnostics and bind queries.
+4. Prevent replay/forgery through authorization checks, unique nominal slots, generation and ownership/channel fences, durable receipts, and audit records. Maintenance capability does not substitute for current human proof.
+
+## 12. Observability and operational UX
+
+- User-facing state distinguishes scheduled, paused, conflict, unavailable, committed, and historical results.
+- Operator evidence includes bounded batch outcome, schedule revision, slot identity, receipt identity, migration version, and correlation ID, with sensitive values omitted.
+- Metrics cover materialized/skipped/conflicted slots, retries, inbox age, query bounds, and migration outcomes.
+- Provider outage is an unavailable dependency state unless the workflow itself violates its contract. Retry uses bounded backoff and the same idempotency identity.
+- Notifications are deduplicated by state transition; routine successful batches do not create comments or noisy alerts.
+
+## 13. Compatibility, migration, rollout, and rollback
+
+Support historical schemas 1–7 and preserve original fixture bytes. Recurrence execution owns schema 8; schema 9 participant administration follows only after the actual schema 8 merge. Do not infer migration completion from already-merged model checkpoints. Migration must be transactional and tested for upgrade, interruption, and compatible whole application/database rollback. Existing receipts and ownership/channel/generation fences remain intact. Historical restore must not recreate processed work. Rollout must keep both native and standalone runtimes bounded and consistent. No deployment or release is implied by this specification.
+
+## 14. Testing strategy
+
+The continuation has four new independent storage refinements, each requiring a distinct executable case below. The retained 52 companion cases and 37 original owner cases, plus all eight RCE requirements, remain mandatory under their owning specifications and are not counted as new cases here. The numeric budget is a floor for this companion inventory; broader retained requirements keep their original budgets.
+
+| Area | Minimum distinct cases | Behaviors/risks covered |
+|---|---:|---|
+| Integration/security/migration | 1 | Observable scenarios in the case inventory. |
+| State/application/idempotency/races | 1 | Observable scenarios in the case inventory. |
+| Domain/configuration/pure planning | 1 | Observable scenarios in the case inventory. |
+| Adapters/provider contracts | 1 | Observable scenarios in the case inventory. |
+| Total | 4 | Distinct inventory cases; repeated executions do not add cases. |
+
+| Case ID | Area | Requirements | Scenario |
+|---|---|---|---|
+| recurrence-storage-continuation-C01 | Integration/security/migration | REC-CONT-03 | Starting from schema 7, migrate to schema 8, interrupt and roll back the whole transaction, then restart; supported schemas 1–7 remain readable and original fixture bytes and receipts are unchanged. |
+| recurrence-storage-continuation-C02 | State/application/idempotency/races | REC-CONT-01, REC-CONT-03 | Two SQLite-backed workers concurrently materialize one nominal slot; exactly one task/progress/receipt/audit/outbox set commits, and restart/retry returns that original result. |
+| recurrence-storage-continuation-C03 | Domain/configuration/pure planning | REC-CONT-02, REC-CONT-04 | Pause, resume, skip, and reschedule a previewed occurrence; applying a stale revision conflicts, while the valid path preserves actual-work anchoring and configured fold/gap/collision behavior. |
+| recurrence-storage-continuation-C04 | Adapters/provider contracts | REC-CONT-04 | An authorized bounded HTTP calendar query returns strict task classification, date-only, planned-start, deadline, and actual-work facts; invalid bounds and unauthorized requests fail without leaking protected data. |
+
+Define the complete repository threshold as 100% statement and branch coverage for backend source, including every changed module, with strict typing and no untested first-party source. New pure policy has 100% branch coverage. Test categories include table-driven boundary cases, replay, real SQLite race/rollback/restart/restoration, adapter and strict HTTP contracts, migration, architecture/import/privacy negatives, browser/OpenAPI/image tests where applicable, and CI Check on every PR. Use deterministic clocks/IDs and fakes; no real waits or live services. Golden UI fixtures require semantic assertions for status, action, links, sanitization, and narrow layout, not snapshot-only approval. Manual UX evidence must confirm a household member and operator can distinguish planned start, deadline, actual completion, conflict, unavailable, and historical result without logs. No test results are asserted by this draft.
+
+## 15. Documentation and discoverability
+
+| Path | Change |
+|---|---|
+| `specs/recurrence-storage-continuation.md` | create |
+| `specs/recurrence-calendar.md` | reference |
+| `specs/recurrence-execution-foundation.md` | reference |
+| `docs/architecture-recurrence.md` | update |
+| `docs/user-guide.md` | update |
+| `docs/operations.md` | update |
+| `docs/contracts/home-assistant.md` | update |
+| `docs/evidence/recurrence-execution.json` | update |
+| `docs/evidence/recurrence-rules.json` | update |
+| `backend/src/domus/infrastructure/sqlite.py` | update |
+| `backend/src/domus/application/fixed_recurrence.py` | reference |
+| `backend/src/domus/application/recurrence_ports.py` | reference |
+| `backend/src/domus/domain/recurrence_rules.py` | reference |
+| `backend/src/domus/domain/recurrence_planning.py` | reference |
+| `backend/src/domus/domain/nominal_recurrence.py` | reference |
+| `backend/src/domus/infrastructure/recurrence_time.py` | reference |
+| `docs/product-vision.md` | update |
+| `docs/architecture.md` | update |
+
+| Audience | Artifact/page | Required content | Validation/navigation |
+|---|---|---|---|
+| User | `docs/user-guide.md` | Schedule, pause/resume, calendar date meanings, examples | Links and examples checked against contract fixtures |
+| Setup owner | `docs/contracts/home-assistant.md` | Later owned service/entity contract and prerequisites | Contract validation; no claim that surfaces are implemented |
+| Operator | `docs/operations.md` | Migration, outage, stale preview, restore and recovery | Recovery decision tree and migration fixtures |
+| Contributor | `docs/architecture-recurrence.md`, `docs/architecture.md` | Ports, transaction ownership, trust boundaries, test commands | Architecture/import checks |
+
+Documentation changes must distinguish proposed behavior from current evidence, include migration and troubleshooting guidance, and keep examples synchronized with implementation fixtures. Paths declared as update are existing immutable-base files; proposed new implementation files, if any, must be declared as create before committing them.
+
+## 16. Acceptance
+
+1. Given a currently authorized schedule creation request and an existing first task, when reviewed attachment proceeds, then the existing task is attached without duplication and the result is inspectable.
+2. Given a due nominal slot, when two workers materialize concurrently, then exactly one atomic task/progress/receipt/audit/outbox set is durable and both observe the same original result.
+3. Given a completion-relative schedule, when lifecycle closure records actual work, then the next occurrence uses that actual-work anchor; planned dates are not substituted.
+4. Given pause, skip, reschedule, or resume, when a valid operation is applied, then durable state and audit facts reflect it; applying a stale preview returns conflict without mutation.
+5. Given a database outage before commit, when materialization is attempted, then the result is unavailable, no partial facts remain, and bounded retry is safe.
+6. Given a response interruption after commit or a historical restore, when the operation is retried/restored, then the original receipt is retained and no occurrence is recreated.
+7. Given an unauthorized or malformed calendar request, when it reaches the HTTP boundary, then strict validation rejects it and protected household data is not disclosed.
+8. Given a valid bounded query, when a household member views results, then task classification, date-only, planned start, deadline, and actual completion remain distinct and accessible in text.
+9. Given schema 1–7 data, when schema 8 migration and compatible rollback are exercised, then prior data and fixture bytes are preserved and recurrence state is consistent.
+10. Given the proposed change, when CI and review gates run, then strict typing, global/per-module coverage, architecture/privacy negatives, applicable browser/OpenAPI/image checks, every-PR CI Check, and zero unresolved Bugbot findings are required before merge.
+11. Given the documentation and catalog publication, when validation runs, then every proposed path is declared, existing companion specifications remain intact, and Draft/readiness blockers remain visible until real acceptance.
+
+## 17. Requirements traceability
+
+| Requirement | Cases |
+|---|---|
+| REC-CONT-01 | recurrence-storage-continuation-C02 |
+| REC-CONT-02 | recurrence-storage-continuation-C03 |
+| REC-CONT-03 | recurrence-storage-continuation-C01, recurrence-storage-continuation-C02 |
+| REC-CONT-04 | recurrence-storage-continuation-C03, recurrence-storage-continuation-C04 |
+
+The retained requirements and cases remain governed by `specs/recurrence-calendar.md` and `specs/recurrence-execution-foundation.md`: eight RCE requirements, `recurrence-execution-C01` through `recurrence-execution-C52`, and `recurrence-calendar-C01` through `recurrence-calendar-C37`. They are referenced, not redefined or included in this companion's new case inventory.
+
+## 18. Implementation sequence
+
+1. Update this companion through the Action-managed admission workflow; preserve existing specifications verbatim and keep Draft status.
+2. Add tests first for schema 8, slot uniqueness, atomicity, migration, rollback, and SQLite concurrency.
+3. Implement pure execution/lifecycle/calendar contracts and verify strict typing and complete coverage.
+4. Implement application use cases and semantic ports, then SQLite adapters and bounded native/standalone composition.
+5. Add strict HTTP contracts and later presentation/service/entity consumers without changing the gateway.
+6. Add restore, restart, crash, security, migration, and provider failure evidence; update user, operator, setup, and contributor documentation alongside behavior.
+7. Run architecture/import/privacy negatives, applicable browser/OpenAPI/image tests, full coverage, every-PR CI Check, and resolve all Bugbot findings before merge.
+8. Update catalog evidence and generated catalog through the normal validation/publication workflow. Do not claim deployment, release, or first all-domain acceptance from this sequence alone.
+
+## 19. Definition of Done
+
+- [ ] All four companion requirements and every normative requirement in both retained specifications have observable acceptance and traceability.
+- [ ] `specs/recurrence-calendar.md` and `specs/recurrence-execution-foundation.md` remain verbatim; all 8 RCE requirements, 52 companion cases, and 37 original owner cases remain required.
+- [ ] Architecture boundaries, strict typing, import/privacy negatives, and application/database transaction ownership are enforced automatically.
+- [ ] Schema 8 supports schemas 1–7, immutable fixtures, transactional rollback, restart, concurrency, and historical restore without recreation.
+- [ ] Native and standalone materialization is bounded; lifecycle, authorization, time resolution, and calendar facts meet contract.
+- [ ] Numeric test budget, global/per-module coverage, real SQLite scenarios, and applicable browser/OpenAPI/image checks pass.
+- [ ] UI states, accessibility, localization conventions, responsive layout, sanitization, and notification budgets are reviewed with manual evidence.
+- [ ] User, setup, operator, contributor, migration, and troubleshooting documentation is complete and discoverable.
+- [ ] Failure, partial success, retry, idempotency, security, and cleanup pass; no unresolved Bugbot finding remains.
+- [ ] Every-PR CI Check passes. Copilot defects are reported upstream; no Copilot configuration/resource change or scope/coverage relaxation is made.
+- [ ] `specs/catalog.json` evidence and `specs/CATALOG.md` are current and `pnpm run validate:specifications` passes.
+- [ ] All retained specifications and this companion remain Draft until real implementation acceptance; first all-domain release, complete UI/HA surfaces, and both deployments remain unaccepted until separately evidenced.
+
+## 20. References and decisions
+
+- Primary sources: immutable development base `3131810082760a98ee4af66f11bb65eaac49cc3b`, tree `c18c57b60e20ccbc83d61464ab5fe623c8ed1713`, reviewed/tested synced head `580ab11`, and the supplied validated recurrence-calendar catalog record.
+- Related specs/issues/PRs: [Issue #20](https://github.com/vypdev/domus/issues/20), issue #17, PRs #18 and #19, `specs/recurrence-calendar.md`, `specs/recurrence-execution-foundation.md`.
+- Decisions: use the explicitly approved companion path `specs/recurrence-storage-continuation.md`; preserve the two existing specifications and their requirements/cases; schema 8 belongs to recurrence execution; participant administration follows only after actual schema 8 merge with schema 9.
+- Follow-up outside this spec: first all-domain release, complete UI/HA surfaces, both deployments, owner HA update, deployment/release/tag, and any Copilot resource/configuration changes. No such work is authorized by starting this continuation.
From f835775b2f230db956750a224f8ebd6cc8bbbb5f Mon Sep 17 00:00:00 2001
From: Efra Espada
Date: Sun, 1 Nov 2026 23:27:42 +0100
Subject: [PATCH 02/16] feat: persist scoped recurrence records in SQLite
---
.dockerignore | 26 ++
.github/workflows/ci_check.yml | 13 +-
.../src/domus/application/fixed_recurrence.py | 21 +-
backend/src/domus/application/ports.py | 4 +
.../src/domus/application/recurrence_ports.py | 35 ++
.../src/domus/domain/recurrence_planning.py | 15 +-
.../src/domus/domain/recurrence_records.py | 147 ++++++
backend/src/domus/infrastructure/sqlite.py | 24 +-
.../domus/infrastructure/sqlite_recurrence.py | 256 +++++++++++
.../sqlite_recurrence_integrity.py | 37 ++
.../sqlite_recurrence_records.py | 334 ++++++++++++++
.../sqlite_recurrence_schema.py | 51 +++
.../domus/infrastructure/sqlite_snapshots.py | 7 +-
docs/architecture-recurrence.md | 54 ++-
docs/evidence/recurrence-storage.json | 159 +++++++
docs/tasks/migration.md | 30 +-
specs/recurrence-storage-continuation.md | 420 +++++-------------
.../backend/test_activation_compatibility.py | 2 +-
tests/backend/test_recurrence_records.py | 179 ++++++++
tests/backend/test_restore_source.py | 4 +-
tests/backend/test_sqlite_history.py | 2 +-
tests/backend/test_sqlite_identity.py | 2 +-
tests/backend/test_sqlite_recovery.py | 2 +-
tests/backend/test_sqlite_recurrence.py | 236 ++++++++++
.../test_sqlite_recurrence_integrity.py | 360 +++++++++++++++
.../backend/test_sqlite_recurrence_records.py | 123 +++++
tests/backend/test_sqlite_snapshots.py | 2 +-
tests/backend/test_sqlite_task_integrity.py | 4 +-
tests/backend/test_sqlite_task_materials.py | 6 +-
tests/backend/test_sqlite_task_reviews.py | 12 +-
tests/backend/test_task_restoration.py | 2 +-
tests/containers/restore_fixture.py | 2 +-
tests/containers/test_app_compatibility.py | 13 +-
tests/containers/test_app_image.py | 15 +-
tests/containers/test_recurrence_image.py | 53 +++
tests/fixtures/current-app/Dockerfile | 40 ++
tests/fixtures/sqlite/v7.sql | 54 +++
37 files changed, 2375 insertions(+), 371 deletions(-)
create mode 100644 .dockerignore
create mode 100644 backend/src/domus/domain/recurrence_records.py
create mode 100644 backend/src/domus/infrastructure/sqlite_recurrence.py
create mode 100644 backend/src/domus/infrastructure/sqlite_recurrence_integrity.py
create mode 100644 backend/src/domus/infrastructure/sqlite_recurrence_records.py
create mode 100644 backend/src/domus/infrastructure/sqlite_recurrence_schema.py
create mode 100644 docs/evidence/recurrence-storage.json
create mode 100644 tests/backend/test_recurrence_records.py
create mode 100644 tests/backend/test_sqlite_recurrence.py
create mode 100644 tests/backend/test_sqlite_recurrence_integrity.py
create mode 100644 tests/backend/test_sqlite_recurrence_records.py
create mode 100644 tests/containers/test_recurrence_image.py
create mode 100644 tests/fixtures/current-app/Dockerfile
create mode 100644 tests/fixtures/sqlite/v7.sql
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..9a76619
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,26 @@
+# The current-source CI build receives only its explicit workspace inputs.
+**
+!pyproject.toml
+!uv.lock
+!package.json
+!pnpm-lock.yaml
+!pnpm-workspace.yaml
+!backend/
+!backend/**
+!tooling/
+!tooling/**
+!frontend/
+!frontend/**
+!tests/
+!tests/fixtures/
+!tests/fixtures/frontend-toolchain/
+!tests/fixtures/frontend-toolchain/package.json
+!tests/fixtures/current-app/
+!tests/fixtures/current-app/Dockerfile
+**/node_modules/
+**/__pycache__/
+**/.venv/
+**/.pytest_cache/
+**/.env
+**/.env.*
+backend/src/domus/ui/
diff --git a/.github/workflows/ci_check.yml b/.github/workflows/ci_check.yml
index f05f210..23cea79 100644
--- a/.github/workflows/ci_check.yml
+++ b/.github/workflows/ci_check.yml
@@ -239,8 +239,15 @@ jobs:
version: "0.11.29"
enable-cache: false
- run: uv sync --frozen
- - name: Build the exact Supervisor app folder
- run: docker build --build-arg BUILD_ARCH=${{ matrix.arch }} -t domus-app-ci addon-edge
+ - name: Build the installed immutable Edge.5 image for schema-7 compatibility
+ run: docker build --build-arg BUILD_ARCH=${{ matrix.arch }} -t domus-app-edge5 addon-edge
+ - name: Build the exact checked-out source and frontend
+ run: >-
+ docker build --file tests/fixtures/current-app/Dockerfile
+ --build-arg BUILD_ARCH=${{ matrix.arch }}
+ --build-arg DOMUS_SOURCE_COMMIT=${{ github.sha }}
+ --build-arg BUILD_VERSION=ci-current
+ -t domus-app-ci .
- name: Build the immutable previous image for upgrade and downgrade checks
run: >-
docker build --build-arg BUILD_ARCH=${{ matrix.arch }}
@@ -255,8 +262,10 @@ jobs:
- name: Verify the real image, listener and cleanup
env:
DOMUS_TEST_IMAGE: domus-app-ci
+ DOMUS_TEST_SOURCE_COMMIT: ${{ github.sha }}
DOMUS_PREVIOUS_IMAGE: domus-app-previous
DOMUS_UPGRADE_IMAGE: domus-app-installed
+ DOMUS_SCHEMA7_IMAGE: domus-app-edge5
run: uv run pytest tests/containers --no-cov
aggregate:
diff --git a/backend/src/domus/application/fixed_recurrence.py b/backend/src/domus/application/fixed_recurrence.py
index 2797a16..74080e0 100644
--- a/backend/src/domus/application/fixed_recurrence.py
+++ b/backend/src/domus/application/fixed_recurrence.py
@@ -1,25 +1,22 @@
"""Side-effect-free bounded planning composes nominal/time ports without claiming persistence."""
-import hashlib
-import json
-from dataclasses import asdict, dataclass, replace
+from dataclasses import dataclass, replace
from datetime import datetime
from domus.application.recurrence_ports import NominalExpansion, RecurrenceTimes
from domus.domain.errors import Conflict, IntegrationUnavailable, InvalidInput
from domus.domain.recurrence_periods import period_bounds
-from domus.domain.recurrence_planning import FixedBatch, FixedCursor, PlannedFixedSlot, StartCheck
+from domus.domain.recurrence_planning import (
+ FixedBatch,
+ FixedCursor,
+ PlannedFixedSlot,
+ StartCheck,
+ cursor_binding,
+)
from domus.domain.recurrence_rules import FixedPattern, NominalDate, NominalSlot, slot_datetime
from domus.domain.recurrence_time import NominalTime, TimePolicy
-def _binding(pattern: FixedPattern, policy: TimePolicy) -> str:
- value = {"version": 1, "pattern": pattern.fingerprint, "policy": asdict(policy)}
- return hashlib.sha256(
- json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
- ).hexdigest()
-
-
def _beyond_end(pattern: FixedPattern, lower: datetime) -> bool:
if isinstance(pattern.until, NominalDate):
return lower.date() > pattern.until.local
@@ -66,7 +63,7 @@ def expand(
raise InvalidInput("invalid_recurrence_limits")
if before is not None and type(before) is not type(pattern.start):
raise InvalidInput("invalid_recurrence_horizon")
- binding = _binding(pattern, policy)
+ binding = cursor_binding(pattern, policy)
current = cursor or FixedCursor(binding)
if current.binding != binding:
raise Conflict("recurrence_cursor_mismatch")
diff --git a/backend/src/domus/application/ports.py b/backend/src/domus/application/ports.py
index 6288192..dc76f6d 100644
--- a/backend/src/domus/application/ports.py
+++ b/backend/src/domus/application/ports.py
@@ -3,6 +3,7 @@
from contextlib import AbstractContextManager
from typing import Protocol
+from domus.application.recurrence_ports import RecurrenceRepository
from domus.application.task_material_ports import TaskMaterialRepository
from domus.application.task_ports import TaskRepository
from domus.domain.identity import HouseholdPolicy, Member
@@ -61,6 +62,9 @@ def delivered(self, event_id: str) -> bool | None: ...
class UnitOfWork(Protocol):
+ @property
+ def recurrence(self) -> RecurrenceRepository: ...
+
@property
def task_materials(self) -> TaskMaterialRepository: ...
diff --git a/backend/src/domus/application/recurrence_ports.py b/backend/src/domus/application/recurrence_ports.py
index 9c86a36..632c33e 100644
--- a/backend/src/domus/application/recurrence_ports.py
+++ b/backend/src/domus/application/recurrence_ports.py
@@ -2,8 +2,15 @@
from typing import Protocol
+from domus.domain.recurrence_records import (
+ FixedProgress,
+ FixedSchedule,
+ ScheduledSlot,
+ ScheduleState,
+)
from domus.domain.recurrence_rules import FixedPattern, PeriodCandidates
from domus.domain.recurrence_time import NominalTime, RelativeInterval, ResolvedTime, TimePolicy
+from domus.domain.storage import Installation
class RecurrenceTimes(Protocol):
@@ -19,3 +26,31 @@ def after_local_days(
class NominalExpansion(Protocol):
def period(self, pattern: FixedPattern, index: int) -> PeriodCandidates: ...
+
+
+class RecurrenceRepository(Protocol):
+ @property
+ def installation(self) -> Installation: ...
+
+ def schedule(self, household_id: str, schedule_id: str) -> FixedSchedule | None: ...
+ def progress(self, household_id: str, schedule_id: str) -> FixedProgress | None: ...
+ def slot(self, household_id: str, slot_key: str) -> ScheduledSlot | None: ...
+ def save_schedule(self, value: FixedSchedule, expected_revision: int) -> None: ...
+ def save_progress(self, value: FixedProgress, expected_revision: int) -> None: ...
+ def add_slot(self, value: ScheduledSlot) -> None: ...
+ def schedules(
+ self,
+ household_id: str,
+ *,
+ limit: int = 25,
+ after: str | None = None,
+ state: ScheduleState | None = None,
+ ) -> tuple[FixedSchedule, ...]: ...
+ def slots(
+ self,
+ household_id: str,
+ schedule_id: str,
+ *,
+ limit: int = 25,
+ after: str | None = None,
+ ) -> tuple[ScheduledSlot, ...]: ...
diff --git a/backend/src/domus/domain/recurrence_planning.py b/backend/src/domus/domain/recurrence_planning.py
index 3eedd6c..31e70ec 100644
--- a/backend/src/domus/domain/recurrence_planning.py
+++ b/backend/src/domus/domain/recurrence_planning.py
@@ -1,11 +1,20 @@
"""Immutable bounded planning progress; these records are not persisted execution proof."""
+import hashlib
+import json
import re
-from dataclasses import dataclass
+from dataclasses import asdict, dataclass
from domus.domain.errors import InvalidInput
-from domus.domain.recurrence_rules import NominalDate, NominalSlot
-from domus.domain.recurrence_time import NominalTime, ResolvedTime
+from domus.domain.recurrence_rules import FixedPattern, NominalDate, NominalSlot
+from domus.domain.recurrence_time import NominalTime, ResolvedTime, TimePolicy
+
+
+def cursor_binding(pattern: FixedPattern, policy: TimePolicy) -> str:
+ value = {"version": 1, "pattern": pattern.fingerprint, "policy": asdict(policy)}
+ return hashlib.sha256(
+ json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
+ ).hexdigest()
@dataclass(frozen=True, slots=True)
diff --git a/backend/src/domus/domain/recurrence_records.py b/backend/src/domus/domain/recurrence_records.py
new file mode 100644
index 0000000..44c84a8
--- /dev/null
+++ b/backend/src/domus/domain/recurrence_records.py
@@ -0,0 +1,147 @@
+"""Scoped schedule, continuation and immutable generated-slot facts contain no human grants."""
+
+from dataclasses import dataclass
+from enum import StrEnum
+
+from domus.domain.errors import InvalidInput
+from domus.domain.identity import validate_revision
+from domus.domain.recurrence_planning import FixedCursor, PlannedFixedSlot, cursor_binding
+from domus.domain.recurrence_rules import FixedPattern, NominalDate
+from domus.domain.recurrence_time import TimePolicy
+from domus.domain.storage import CommandKey, Installation, JsonDocument, validate_identifier
+from domus.domain.task_values import validate_task_time
+
+MAINTENANCE_ACTOR = "system:recurrence"
+
+
+class ScheduleState(StrEnum):
+ ACTIVE = "active"
+ PAUSED = "paused"
+ RETIRED = "retired"
+
+
+@dataclass(frozen=True, slots=True)
+class FixedSchedule:
+ schedule_id: str
+ household_id: str
+ definition_id: str
+ pattern: FixedPattern
+ policy: TimePolicy
+ created_at: int
+ state: ScheduleState = ScheduleState.ACTIVE
+ revision: int = 1
+ pattern_revision: int = 1
+
+ def __post_init__(self) -> None:
+ for value in (self.schedule_id, self.household_id, self.definition_id):
+ validate_identifier(value)
+ for revision in (self.revision, self.pattern_revision):
+ validate_revision(revision)
+ validate_task_time(self.created_at)
+ if (
+ not isinstance(self.pattern, FixedPattern)
+ or not isinstance(self.policy, TimePolicy)
+ or not isinstance(self.state, ScheduleState)
+ or self.pattern_revision > self.revision
+ ):
+ raise InvalidInput("invalid_recurrence_schedule")
+
+
+@dataclass(frozen=True, slots=True)
+class FixedProgress:
+ schedule_id: str
+ household_id: str
+ pattern_revision: int
+ cursor: FixedCursor
+ revision: int = 1
+ last_run_at: int | None = None
+ range_complete: bool = False
+
+ def __post_init__(self) -> None:
+ validate_identifier(self.schedule_id)
+ validate_identifier(self.household_id)
+ validate_revision(self.pattern_revision)
+ validate_revision(self.revision)
+ if self.last_run_at is not None:
+ validate_task_time(self.last_run_at)
+ if (
+ not isinstance(self.cursor, FixedCursor)
+ or type(self.range_complete) is not bool
+ or (self.cursor.closed and not self.range_complete)
+ ):
+ raise InvalidInput("invalid_recurrence_progress")
+
+ def matches_schedule(self, schedule: FixedSchedule) -> bool:
+ """A typed source relationship is an integrity fact, never caller authorization."""
+ return (
+ self.schedule_id == schedule.schedule_id
+ and self.household_id == schedule.household_id
+ and self.pattern_revision == schedule.pattern_revision
+ and self.cursor.binding == cursor_binding(schedule.pattern, schedule.policy)
+ and (
+ schedule.pattern.count is None
+ or self.cursor.nominal_count <= schedule.pattern.count
+ )
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class ScheduledSlot:
+ installation: Installation
+ schedule: FixedSchedule
+ planned: PlannedFixedSlot
+ occurrence_id: str
+ created_at: int
+
+ def __post_init__(self) -> None:
+ validate_identifier(self.occurrence_id)
+ validate_task_time(self.created_at)
+ if (
+ not isinstance(self.installation, Installation)
+ or not isinstance(self.schedule, FixedSchedule)
+ or not isinstance(self.planned, PlannedFixedSlot)
+ or type(self.planned.nominal) is not type(self.schedule.pattern.start)
+ or (
+ self.planned.resolved is not None
+ and self.planned.resolved.policy != self.schedule.policy
+ )
+ or self.created_at < self.schedule.created_at
+ ):
+ raise InvalidInput("invalid_recurrence_slot")
+
+ def consistent_with(self, schedule: FixedSchedule) -> bool:
+ """Older snapshots survive edits; future or contradictory generations do not."""
+ source = self.schedule
+ return (
+ source.schedule_id == schedule.schedule_id
+ and source.household_id == schedule.household_id
+ and source.definition_id == schedule.definition_id
+ and source.created_at == schedule.created_at
+ and source.revision <= schedule.revision
+ and source.pattern_revision <= schedule.pattern_revision
+ and (
+ source.pattern_revision < schedule.pattern_revision
+ or (source.pattern == schedule.pattern and source.policy == schedule.policy)
+ )
+ )
+
+ @property
+ def key(self) -> str:
+ return JsonDocument.from_value(
+ {
+ "version": 1,
+ "instance": self.installation.instance_id,
+ "channel": self.installation.channel,
+ "household": self.schedule.household_id,
+ "schedule": self.schedule.schedule_id,
+ "pattern_revision": self.schedule.pattern_revision,
+ "kind": "date" if isinstance(self.planned.nominal, NominalDate) else "time",
+ "nominal": self.planned.nominal.local.isoformat(),
+ }
+ ).fingerprint
+
+ @property
+ def command(self) -> CommandKey:
+ return CommandKey(
+ self.schedule.household_id, MAINTENANCE_ACTOR, "recurrence.materialize", self.key
+ )
diff --git a/backend/src/domus/infrastructure/sqlite.py b/backend/src/domus/infrastructure/sqlite.py
index 256d61d..38393e3 100644
--- a/backend/src/domus/infrastructure/sqlite.py
+++ b/backend/src/domus/infrastructure/sqlite.py
@@ -30,6 +30,8 @@
from domus.infrastructure.sqlite_history import HISTORY_STATEMENTS, SQLiteHistory
from domus.infrastructure.sqlite_identity import SQLiteMembers, SQLitePolicies
from domus.infrastructure.sqlite_recovery import SQLiteRecoveries
+from domus.infrastructure.sqlite_recurrence import SQLiteRecurrences
+from domus.infrastructure.sqlite_recurrence_schema import RECURRENCE_STATEMENTS, RECURRENCE_TABLES
from domus.infrastructure.sqlite_task_material_schema import (
MATERIAL_STATEMENTS,
MATERIAL_TABLES,
@@ -40,7 +42,7 @@
from domus.infrastructure.sqlite_task_schema import TASK_STATEMENTS, TASK_TABLES
from domus.infrastructure.sqlite_tasks import SQLiteTasks
-SCHEMA_VERSION = 7
+SCHEMA_VERSION = 8
APPLICATION_ID = 0x444F4D55
BASE_STATEMENTS = (
"CREATE TABLE installation(singleton INTEGER PRIMARY KEY CHECK(singleton=1), "
@@ -94,13 +96,15 @@
*TASK_STATEMENTS,
*MATERIAL_STATEMENTS,
*REVIEW_STATEMENTS,
+ *RECURRENCE_STATEMENTS,
)
BASE_TABLES = {"installation", "households", "receipts", "audit", "outbox"}
IDENTITY_TABLES = BASE_TABLES | {"members", "household_policy"}
RECOVERY_TABLES = IDENTITY_TABLES | {"recovery_requests"}
HISTORY_TABLES = RECOVERY_TABLES | {"retained_operations"}
TASK_SCHEMA_TABLES = HISTORY_TABLES | TASK_TABLES
-TABLES = TASK_SCHEMA_TABLES | MATERIAL_TABLES
+MATERIAL_SCHEMA_TABLES = TASK_SCHEMA_TABLES | MATERIAL_TABLES
+TABLES = MATERIAL_SCHEMA_TABLES | RECURRENCE_TABLES
def _scope(command: CommandKey) -> tuple[str, str, str, str]:
@@ -321,7 +325,8 @@ def delivered(self, event_id: str) -> bool | None:
class SQLiteUnit:
- def __init__(self, connection: sqlite3.Connection) -> None:
+ def __init__(self, connection: sqlite3.Connection, installation: Installation) -> None:
+ self.recurrence = SQLiteRecurrences(connection, installation)
self.households = SQLiteHouseholds(connection)
self.receipts = SQLiteReceipts(connection)
self.audit = SQLiteAudit(connection)
@@ -379,7 +384,9 @@ def _inspect(
}
if version == 0 and application_id == 0 and not objects and allow_empty:
return 0
- if version != SCHEMA_VERSION and not (allow_previous and version in (1, 2, 3, 4, 5, 6)):
+ if version != SCHEMA_VERSION and not (
+ allow_previous and version in (1, 2, 3, 4, 5, 6, 7)
+ ):
raise DatabaseRejected("unsupported_schema")
expected_tables = {
1: BASE_TABLES,
@@ -387,6 +394,8 @@ def _inspect(
3: RECOVERY_TABLES,
4: HISTORY_TABLES,
5: TASK_SCHEMA_TABLES,
+ 6: MATERIAL_SCHEMA_TABLES,
+ 7: MATERIAL_SCHEMA_TABLES,
}.get(version, TABLES)
if application_id != APPLICATION_ID or objects != expected_tables:
raise DatabaseRejected("unrelated_database")
@@ -427,14 +436,15 @@ def initialize(self, *, require_existing: bool = False) -> None:
)
connection.execute(f"PRAGMA application_id={APPLICATION_ID}")
connection.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
- elif version in (1, 2, 3, 4, 5, 6):
+ elif version in (1, 2, 3, 4, 5, 6, 7):
migration = (
*(IDENTITY_STATEMENTS if version == 1 else ()),
*(RECOVERY_STATEMENTS if version < 3 else ()),
*(HISTORY_STATEMENTS if version < 4 else ()),
*(TASK_STATEMENTS if version < 5 else ()),
*(MATERIAL_STATEMENTS if version < 6 else ()),
- *REVIEW_STATEMENTS,
+ *(REVIEW_STATEMENTS if version < 7 else ()),
+ *RECURRENCE_STATEMENTS,
)
for statement in migration:
connection.execute(statement)
@@ -459,7 +469,7 @@ def _transaction(self, *, write: bool) -> Iterator[UnitOfWork]:
try:
connection.execute("BEGIN IMMEDIATE" if write else "BEGIN")
self._inspect(connection)
- yield SQLiteUnit(connection)
+ yield SQLiteUnit(connection, self.installation)
connection.execute("COMMIT")
except BaseException as error:
if connection.in_transaction:
diff --git a/backend/src/domus/infrastructure/sqlite_recurrence.py b/backend/src/domus/infrastructure/sqlite_recurrence.py
new file mode 100644
index 0000000..fe45906
--- /dev/null
+++ b/backend/src/domus/infrastructure/sqlite_recurrence.py
@@ -0,0 +1,256 @@
+"""Scoped recurrence repositories never commit independently or infer caller authority."""
+
+import sqlite3
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import cast
+
+from domus.domain.errors import Conflict, InvalidInput, StorageFailure
+from domus.domain.recurrence_records import (
+ FixedProgress,
+ FixedSchedule,
+ ScheduledSlot,
+ ScheduleState,
+)
+from domus.domain.recurrence_rules import NominalDate
+from domus.domain.storage import Installation, validate_identifier
+from domus.infrastructure.sqlite_recurrence_records import (
+ progress_document,
+ read_progress,
+ read_schedule,
+ read_slot,
+ schedule_document,
+ slot_document,
+)
+
+
+def schedule_headers(value: FixedSchedule) -> dict[str, object]:
+ return {
+ "schedule_id": value.schedule_id,
+ "household_id": value.household_id,
+ "definition_id": value.definition_id,
+ "revision": value.revision,
+ "pattern_revision": value.pattern_revision,
+ "state": value.state.value,
+ }
+
+
+def progress_headers(value: FixedProgress) -> dict[str, object]:
+ return {
+ "schedule_id": value.schedule_id,
+ "household_id": value.household_id,
+ "pattern_revision": value.pattern_revision,
+ "revision": value.revision,
+ }
+
+
+def slot_headers(value: ScheduledSlot) -> dict[str, object]:
+ return {
+ "household_id": value.schedule.household_id,
+ "slot_key": value.key,
+ "schedule_id": value.schedule.schedule_id,
+ "definition_id": value.schedule.definition_id,
+ "pattern_revision": value.schedule.pattern_revision,
+ "occurrence_id": value.occurrence_id,
+ "nominal_kind": "date" if isinstance(value.planned.nominal, NominalDate) else "time",
+ "nominal_value": value.planned.nominal.local.isoformat(),
+ "resolved_at": None
+ if value.planned.resolved is None
+ else value.planned.resolved.instant_ms,
+ "created_at": value.created_at,
+ "receipt_actor": value.command.actor_id,
+ "receipt_action": value.command.action,
+ }
+
+
+def _model[T](
+ row: sqlite3.Row | None, decode: Callable[[str], T], headers: Callable[[T], dict[str, object]]
+) -> T | None:
+ if row is None:
+ return None
+ value = decode(row["document"])
+ if any(
+ type(row[key]) is not type(expected) or row[key] != expected
+ for key, expected in headers(value).items()
+ ):
+ raise StorageFailure("invalid_recurrence_record")
+ return value
+
+
+def _expected(value: int) -> None:
+ if type(value) is not int or not 0 <= value <= 2**63 - 1:
+ raise InvalidInput("invalid_revision")
+
+
+def _page(
+ household_id: str, limit: int, after: str | None, key: str
+) -> tuple[list[str], list[object]]:
+ validate_identifier(household_id)
+ if type(limit) is not int or not 1 <= limit <= 101:
+ raise InvalidInput("invalid_page_size")
+ where, parameters = ["household_id=?"], [household_id]
+ if after is not None:
+ validate_identifier(after)
+ where.append(key + ">?")
+ parameters.append(after)
+ return where, list(parameters)
+
+
+@dataclass
+class SQLiteRecurrences:
+ connection: sqlite3.Connection
+ installation: Installation
+
+ def schedule(self, household_id: str, schedule_id: str) -> FixedSchedule | None:
+ validate_identifier(household_id)
+ validate_identifier(schedule_id)
+ return _model(
+ self.connection.execute(
+ "SELECT * FROM recurrence_rules WHERE household_id=? AND schedule_id=?",
+ (household_id, schedule_id),
+ ).fetchone(),
+ read_schedule,
+ schedule_headers,
+ )
+
+ def progress(self, household_id: str, schedule_id: str) -> FixedProgress | None:
+ validate_identifier(household_id)
+ validate_identifier(schedule_id)
+ return _model(
+ self.connection.execute(
+ "SELECT * FROM recurrence_progress WHERE household_id=? AND schedule_id=?",
+ (household_id, schedule_id),
+ ).fetchone(),
+ read_progress,
+ progress_headers,
+ )
+
+ def slot(self, household_id: str, slot_key: str) -> ScheduledSlot | None:
+ validate_identifier(household_id)
+ validate_identifier(slot_key)
+ return self._slot(
+ self.connection.execute(
+ "SELECT * FROM recurrence_slots WHERE household_id=? AND slot_key=?",
+ (household_id, slot_key),
+ ).fetchone()
+ )
+
+ def _slot(self, row: sqlite3.Row | None) -> ScheduledSlot | None:
+ value = _model(row, read_slot, slot_headers)
+ if value is not None and value.installation != self.installation:
+ raise StorageFailure("invalid_recurrence_record")
+ return value
+
+ def _write(self, table: str, headers: dict[str, object], document: str, expected: int) -> None:
+ values = {**headers, "document": document}
+ if expected == 0:
+ try:
+ self.connection.execute(
+ f"INSERT INTO {table}({','.join(values)}) "
+ f"VALUES ({','.join('?' for _ in values)})",
+ tuple(values.values()),
+ )
+ except sqlite3.IntegrityError:
+ raise Conflict("recurrence_record_conflict") from None
+ elif (
+ self.connection.execute(
+ f"UPDATE {table} SET {','.join(key + '=?' for key in values)} "
+ "WHERE household_id=? AND schedule_id=? AND revision=?",
+ (*values.values(), headers["household_id"], headers["schedule_id"], expected),
+ ).rowcount
+ != 1
+ ):
+ raise Conflict("revision_conflict")
+
+ def save_schedule(self, value: FixedSchedule, expected_revision: int) -> None:
+ _expected(expected_revision)
+ if value.revision != expected_revision + 1:
+ raise Conflict("revision_conflict")
+ previous = self.schedule(value.household_id, value.schedule_id)
+ if previous is not None:
+ changed = value.pattern != previous.pattern or value.policy != previous.policy
+ if (
+ value.definition_id != previous.definition_id
+ or value.created_at != previous.created_at
+ or value.pattern_revision != previous.pattern_revision + int(changed)
+ ):
+ raise Conflict("recurrence_identity_conflict")
+ self._write(
+ "recurrence_rules",
+ schedule_headers(value),
+ schedule_document(value).encoded,
+ expected_revision,
+ )
+
+ def save_progress(self, value: FixedProgress, expected_revision: int) -> None:
+ _expected(expected_revision)
+ if value.revision != expected_revision + 1:
+ raise Conflict("revision_conflict")
+ rule = self.schedule(value.household_id, value.schedule_id)
+ if rule is None or not value.matches_schedule(rule):
+ raise Conflict("recurrence_cursor_mismatch")
+ self._write(
+ "recurrence_progress",
+ progress_headers(value),
+ progress_document(value).encoded,
+ expected_revision,
+ )
+
+ def add_slot(self, value: ScheduledSlot) -> None:
+ if value.installation != self.installation:
+ raise Conflict("recurrence_scope_mismatch")
+ current = self.schedule(value.schedule.household_id, value.schedule.schedule_id)
+ if (
+ current is None
+ or value.schedule.pattern_revision != current.pattern_revision
+ or not value.consistent_with(current)
+ ):
+ raise Conflict("recurrence_source_conflict")
+ values = {**slot_headers(value), "document": slot_document(value).encoded}
+ try:
+ self.connection.execute(
+ f"INSERT INTO recurrence_slots({','.join(values)}) "
+ f"VALUES ({','.join('?' for _ in values)})",
+ tuple(values.values()),
+ )
+ except sqlite3.IntegrityError:
+ raise Conflict("recurrence_slot_conflict") from None
+
+ def schedules(
+ self,
+ household_id: str,
+ *,
+ limit: int = 25,
+ after: str | None = None,
+ state: ScheduleState | None = None,
+ ) -> tuple[FixedSchedule, ...]:
+ where, parameters = _page(household_id, limit, after, "schedule_id")
+ if state is not None:
+ if not isinstance(state, ScheduleState):
+ raise InvalidInput("invalid_recurrence_state")
+ where.append("state=?")
+ parameters.append(state.value)
+ rows = self.connection.execute(
+ "SELECT * FROM recurrence_rules WHERE "
+ + " AND ".join(where)
+ + " ORDER BY schedule_id LIMIT ?",
+ (*parameters, limit),
+ )
+ return tuple(
+ cast(FixedSchedule, _model(row, read_schedule, schedule_headers)) for row in rows
+ )
+
+ def slots(
+ self, household_id: str, schedule_id: str, *, limit: int = 25, after: str | None = None
+ ) -> tuple[ScheduledSlot, ...]:
+ where, parameters = _page(household_id, limit, after, "slot_key")
+ validate_identifier(schedule_id)
+ where.append("schedule_id=?")
+ parameters.append(schedule_id)
+ rows = self.connection.execute(
+ "SELECT * FROM recurrence_slots WHERE "
+ + " AND ".join(where)
+ + " ORDER BY slot_key LIMIT ?",
+ (*parameters, limit),
+ )
+ return tuple(cast(ScheduledSlot, self._slot(row)) for row in rows)
diff --git a/backend/src/domus/infrastructure/sqlite_recurrence_integrity.py b/backend/src/domus/infrastructure/sqlite_recurrence_integrity.py
new file mode 100644
index 0000000..958e623
--- /dev/null
+++ b/backend/src/domus/infrastructure/sqlite_recurrence_integrity.py
@@ -0,0 +1,37 @@
+"""Snapshot verification includes scoped recurrence values and original slot evidence."""
+
+import sqlite3
+from typing import cast
+
+from domus.domain.errors import StorageFailure
+from domus.domain.recurrence_records import FixedSchedule, ScheduledSlot
+from domus.domain.storage import Installation, JsonDocument
+from domus.infrastructure.sqlite_recurrence import SQLiteRecurrences
+
+
+def verify_recurrence_records(connection: sqlite3.Connection, installation: Installation) -> None:
+ repository = SQLiteRecurrences(connection, installation)
+ for row in connection.execute("SELECT household_id,schedule_id FROM recurrence_rules"):
+ # These keys came from the same read cursor; record decoding still checks every header.
+ rule = cast(FixedSchedule, repository.schedule(row[0], row[1]))
+ progress = repository.progress(row[0], row[1])
+ if progress is None or not progress.matches_schedule(rule):
+ raise StorageFailure("invalid_recurrence_record")
+ for row in connection.execute("SELECT household_id,slot_key FROM recurrence_slots"):
+ value = cast(ScheduledSlot, repository.slot(row[0], row[1]))
+ source = repository.schedule(value.schedule.household_id, value.schedule.schedule_id)
+ if source is None or not value.consistent_with(source):
+ raise StorageFailure("invalid_recurrence_record")
+ receipt = connection.execute(
+ "SELECT result FROM receipts WHERE household_id=? "
+ "AND actor_id=? AND action=? AND command_key=?",
+ (value.command.household_id, value.command.actor_id, value.command.action, value.key),
+ ).fetchone()
+ if receipt is None:
+ raise StorageFailure("invalid_recurrence_record")
+ result = JsonDocument(receipt[0]).decode()
+ if (
+ result.get("slot_key") != value.key
+ or result.get("occurrence_id") != value.occurrence_id
+ ):
+ raise StorageFailure("invalid_recurrence_record")
diff --git a/backend/src/domus/infrastructure/sqlite_recurrence_records.py b/backend/src/domus/infrastructure/sqlite_recurrence_records.py
new file mode 100644
index 0000000..49edbdf
--- /dev/null
+++ b/backend/src/domus/infrastructure/sqlite_recurrence_records.py
@@ -0,0 +1,334 @@
+"""Closed versioned recurrence documents; imported/corrupt facts receive no guessed defaults."""
+
+from collections.abc import Callable
+from datetime import date, datetime
+from typing import cast
+
+from domus.domain.errors import DomusError, InvalidInput, StorageFailure
+from domus.domain.recurrence_planning import FixedCursor, PlannedFixedSlot
+from domus.domain.recurrence_records import (
+ FixedProgress,
+ FixedSchedule,
+ ScheduledSlot,
+ ScheduleState,
+)
+from domus.domain.recurrence_rules import (
+ ByDay,
+ FixedPattern,
+ Frequency,
+ NominalDate,
+ NominalSlot,
+ Weekday,
+)
+from domus.domain.recurrence_time import (
+ FoldPolicy,
+ NominalTime,
+ ResolvedTime,
+ TimePolicy,
+ TimeResolution,
+)
+from domus.domain.storage import Installation, JsonDocument, JsonObject, JsonValue
+
+
+def _object(value: JsonValue, keys: tuple[str, ...]) -> JsonObject:
+ if type(value) is not dict or set(value) != set(keys):
+ raise InvalidInput("invalid_recurrence_record")
+ return value
+
+
+def _record(value: JsonValue, keys: tuple[str, ...]) -> JsonObject:
+ row = _object(value, ("version", *keys))
+ if type(row["version"]) is not int or row["version"] != 1:
+ raise InvalidInput("unsupported_recurrence_record")
+ return row
+
+
+def _array(value: JsonValue) -> list[JsonValue]:
+ if type(value) is not list:
+ raise InvalidInput("invalid_recurrence_record")
+ return value
+
+
+def _local(value: JsonValue, *, timed: bool) -> date | datetime:
+ if type(value) is not str:
+ raise InvalidInput("invalid_recurrence_record")
+ result = datetime.fromisoformat(value) if timed else date.fromisoformat(value)
+ if result.isoformat() != value:
+ raise InvalidInput("invalid_recurrence_record")
+ return result
+
+
+def _nominal_value(value: NominalSlot) -> JsonObject:
+ return {
+ "kind": "date" if isinstance(value, NominalDate) else "time",
+ "local": value.local.isoformat(),
+ }
+
+
+def _nominal(value: JsonValue) -> NominalSlot:
+ row = _object(value, ("kind", "local"))
+ if row["kind"] == "date":
+ return NominalDate(_local(row["local"], timed=False))
+ if row["kind"] == "time":
+ return NominalTime(cast(datetime, _local(row["local"], timed=True)))
+ raise InvalidInput("invalid_recurrence_record")
+
+
+def _policy_value(value: TimePolicy) -> JsonObject:
+ return {
+ "zone": value.zone,
+ "data_identity": value.data_identity,
+ "repeated_time": value.repeated_time.value,
+ "revision": value.revision,
+ }
+
+
+def _policy(value: JsonValue) -> TimePolicy:
+ row = _object(value, ("zone", "data_identity", "repeated_time", "revision"))
+ return TimePolicy(
+ cast(str, row["zone"]),
+ cast(str, row["data_identity"]),
+ FoldPolicy(cast(str, row["repeated_time"])),
+ cast(int, row["revision"]),
+ )
+
+
+def _pattern_value(value: FixedPattern) -> JsonObject:
+ return {
+ "start": _nominal_value(value.start),
+ "frequency": value.frequency.value,
+ "interval": value.interval,
+ "week_start": value.week_start.value,
+ "by_day": [
+ {"weekday": item.weekday.value, "ordinal": item.ordinal} for item in value.by_day
+ ],
+ "by_month": list(value.by_month),
+ "by_month_day": list(value.by_month_day),
+ "by_set_position": list(value.by_set_position),
+ "count": value.count,
+ "until": _nominal_value(value.until)
+ if isinstance(value.until, NominalDate)
+ else value.until,
+ }
+
+
+def _day(value: JsonValue) -> ByDay:
+ row = _object(value, ("weekday", "ordinal"))
+ return ByDay(Weekday(cast(str, row["weekday"])), cast(int | None, row["ordinal"]))
+
+
+def _pattern(value: JsonValue) -> FixedPattern:
+ row = _object(
+ value,
+ (
+ "start",
+ "frequency",
+ "interval",
+ "week_start",
+ "by_day",
+ "by_month",
+ "by_month_day",
+ "by_set_position",
+ "count",
+ "until",
+ ),
+ )
+ until = row["until"]
+ return FixedPattern(
+ _nominal(row["start"]),
+ Frequency(cast(str, row["frequency"])),
+ cast(int, row["interval"]),
+ Weekday(cast(str, row["week_start"])),
+ tuple(_day(item) for item in _array(row["by_day"])),
+ tuple(cast(list[int], _array(row["by_month"]))),
+ tuple(cast(list[int], _array(row["by_month_day"]))),
+ tuple(cast(list[int], _array(row["by_set_position"]))),
+ cast(int | None, row["count"]),
+ cast(NominalDate, _nominal(until)) if type(until) is dict else cast(int | None, until),
+ )
+
+
+def _schedule_value(value: FixedSchedule) -> JsonObject:
+ return {
+ "version": 1,
+ "schedule_id": value.schedule_id,
+ "household_id": value.household_id,
+ "definition_id": value.definition_id,
+ "pattern": _pattern_value(value.pattern),
+ "policy": _policy_value(value.policy),
+ "created_at": value.created_at,
+ "state": value.state.value,
+ "revision": value.revision,
+ "pattern_revision": value.pattern_revision,
+ }
+
+
+def _schedule(value: JsonValue) -> FixedSchedule:
+ row = _record(
+ value,
+ (
+ "schedule_id",
+ "household_id",
+ "definition_id",
+ "pattern",
+ "policy",
+ "created_at",
+ "state",
+ "revision",
+ "pattern_revision",
+ ),
+ )
+ return FixedSchedule(
+ cast(str, row["schedule_id"]),
+ cast(str, row["household_id"]),
+ cast(str, row["definition_id"]),
+ _pattern(row["pattern"]),
+ _policy(row["policy"]),
+ cast(int, row["created_at"]),
+ ScheduleState(cast(str, row["state"])),
+ cast(int, row["revision"]),
+ cast(int, row["pattern_revision"]),
+ )
+
+
+def schedule_document(value: FixedSchedule) -> JsonDocument:
+ return JsonDocument.from_value(_schedule_value(value))
+
+
+def progress_document(value: FixedProgress) -> JsonDocument:
+ return JsonDocument.from_value(
+ {
+ "version": 1,
+ "schedule_id": value.schedule_id,
+ "household_id": value.household_id,
+ "pattern_revision": value.pattern_revision,
+ "revision": value.revision,
+ "last_run_at": value.last_run_at,
+ "range_complete": value.range_complete,
+ "cursor": {
+ "binding": value.cursor.binding,
+ "period_index": value.cursor.period_index,
+ "offset": value.cursor.offset,
+ "nominal_count": value.cursor.nominal_count,
+ "closed": value.cursor.closed,
+ },
+ }
+ )
+
+
+def _progress(value: JsonValue) -> FixedProgress:
+ row = _record(
+ value,
+ (
+ "schedule_id",
+ "household_id",
+ "pattern_revision",
+ "revision",
+ "last_run_at",
+ "range_complete",
+ "cursor",
+ ),
+ )
+ cursor = _object(
+ row["cursor"], ("binding", "period_index", "offset", "nominal_count", "closed")
+ )
+ return FixedProgress(
+ cast(str, row["schedule_id"]),
+ cast(str, row["household_id"]),
+ cast(int, row["pattern_revision"]),
+ FixedCursor(
+ cast(str, cursor["binding"]),
+ cast(int, cursor["period_index"]),
+ cast(int, cursor["offset"]),
+ cast(int, cursor["nominal_count"]),
+ cast(bool, cursor["closed"]),
+ ),
+ cast(int, row["revision"]),
+ cast(int | None, row["last_run_at"]),
+ cast(bool, row["range_complete"]),
+ )
+
+
+def _resolved_value(value: ResolvedTime) -> JsonObject:
+ return {
+ "nominal": _nominal_value(value.nominal),
+ "policy": _policy_value(value.policy),
+ "resolved_local": value.resolved_local.isoformat(),
+ "offset_seconds": value.offset_seconds,
+ "resolution": value.resolution.value,
+ "zone_digest": value.zone_digest,
+ }
+
+
+def _resolved(value: JsonValue) -> ResolvedTime:
+ row = _object(
+ value,
+ ("nominal", "policy", "resolved_local", "offset_seconds", "resolution", "zone_digest"),
+ )
+ nominal = _nominal(row["nominal"])
+ if not isinstance(nominal, NominalTime):
+ raise InvalidInput("invalid_recurrence_record")
+ return ResolvedTime(
+ nominal,
+ _policy(row["policy"]),
+ cast(datetime, _local(row["resolved_local"], timed=True)),
+ cast(int, row["offset_seconds"]),
+ TimeResolution(cast(str, row["resolution"])),
+ cast(str, row["zone_digest"]),
+ )
+
+
+def slot_document(value: ScheduledSlot) -> JsonDocument:
+ return JsonDocument.from_value(
+ {
+ "version": 1,
+ "installation": {
+ "instance_id": value.installation.instance_id,
+ "channel": value.installation.channel,
+ },
+ "schedule": _schedule_value(value.schedule),
+ "occurrence_id": value.occurrence_id,
+ "created_at": value.created_at,
+ "planned": {
+ "nominal": _nominal_value(value.planned.nominal),
+ "resolved": None
+ if value.planned.resolved is None
+ else _resolved_value(value.planned.resolved),
+ },
+ }
+ )
+
+
+def _slot(value: JsonValue) -> ScheduledSlot:
+ row = _record(value, ("installation", "schedule", "occurrence_id", "created_at", "planned"))
+ installation = _object(row["installation"], ("instance_id", "channel"))
+ planned = _object(row["planned"], ("nominal", "resolved"))
+ return ScheduledSlot(
+ Installation(cast(str, installation["instance_id"]), cast(str, installation["channel"])),
+ _schedule(row["schedule"]),
+ PlannedFixedSlot(
+ _nominal(planned["nominal"]),
+ None if planned["resolved"] is None else _resolved(planned["resolved"]),
+ ),
+ cast(str, row["occurrence_id"]),
+ cast(int, row["created_at"]),
+ )
+
+
+def _read[T](document: str, build: Callable[[JsonValue], T]) -> T:
+ try:
+ return build(JsonDocument(document).decode())
+ except DomusError, KeyError, TypeError, ValueError, OverflowError:
+ raise StorageFailure("invalid_recurrence_record") from None
+
+
+def read_schedule(document: str) -> FixedSchedule:
+ return _read(document, _schedule)
+
+
+def read_progress(document: str) -> FixedProgress:
+ return _read(document, _progress)
+
+
+def read_slot(document: str) -> ScheduledSlot:
+ return _read(document, _slot)
diff --git a/backend/src/domus/infrastructure/sqlite_recurrence_schema.py b/backend/src/domus/infrastructure/sqlite_recurrence_schema.py
new file mode 100644
index 0000000..a6a585d
--- /dev/null
+++ b/backend/src/domus/infrastructure/sqlite_recurrence_schema.py
@@ -0,0 +1,51 @@
+"""Scoped recurrence rows and deferred original evidence share the enclosing atomic commit."""
+
+RECURRENCE_TABLES = {"recurrence_rules", "recurrence_progress", "recurrence_slots"}
+RECURRENCE_STATEMENTS = (
+ "CREATE UNIQUE INDEX recurrence_occurrence_scope "
+ "ON task_occurrences(household_id,definition_id,occurrence_id)",
+ "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')), document TEXT NOT NULL, "
+ "UNIQUE(household_id,schedule_id), UNIQUE(household_id,schedule_id,definition_id), "
+ "UNIQUE(household_id,schedule_id,pattern_revision), "
+ "FOREIGN KEY(household_id,definition_id) "
+ "REFERENCES task_definitions(household_id,definition_id), "
+ "FOREIGN KEY(household_id,schedule_id,pattern_revision) "
+ "REFERENCES recurrence_progress(household_id,schedule_id,pattern_revision) "
+ "DEFERRABLE INITIALLY DEFERRED)",
+ "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), "
+ "document TEXT NOT NULL, UNIQUE(household_id,schedule_id,pattern_revision), "
+ "FOREIGN KEY(household_id,schedule_id,pattern_revision) "
+ "REFERENCES recurrence_rules(household_id,schedule_id,pattern_revision) "
+ "DEFERRABLE INITIALLY DEFERRED)",
+ "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'), "
+ "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) "
+ "REFERENCES recurrence_rules(household_id,schedule_id,definition_id), "
+ "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)",
+ "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)",
+)
diff --git a/backend/src/domus/infrastructure/sqlite_snapshots.py b/backend/src/domus/infrastructure/sqlite_snapshots.py
index 7d53727..f95a8e9 100644
--- a/backend/src/domus/infrastructure/sqlite_snapshots.py
+++ b/backend/src/domus/infrastructure/sqlite_snapshots.py
@@ -38,6 +38,8 @@
verify_history,
)
from domus.infrastructure.sqlite_recovery import _record as _recovery
+from domus.infrastructure.sqlite_recurrence_integrity import verify_recurrence_records
+from domus.infrastructure.sqlite_recurrence_schema import RECURRENCE_STATEMENTS
from domus.infrastructure.sqlite_task_integrity import verify_task_records
from domus.infrastructure.sqlite_task_material_schema import MATERIAL_STATEMENTS
from domus.infrastructure.sqlite_task_reviews import REVIEW_STATEMENTS
@@ -121,6 +123,7 @@ def _expected_schema(version: int) -> tuple[tuple[str, str], ...]:
*(TASK_STATEMENTS if version >= 5 else ()),
*(MATERIAL_STATEMENTS if version >= 6 else ()),
*(REVIEW_STATEMENTS if version >= 7 else ()),
+ *(RECURRENCE_STATEMENTS if version >= 8 else ()),
)
for statement in statements:
connection.execute(statement)
@@ -173,7 +176,7 @@ def _records(connection: sqlite3.Connection, version: int, installation: Install
CommandKey(
row["household_id"], row["actor_id"], row["action"], row["command_key"]
)
- unit = SQLiteUnit(connection)
+ unit = SQLiteUnit(connection, installation)
for row in households:
unit.households.get(row[0])
if version >= 2:
@@ -190,6 +193,8 @@ def _records(connection: sqlite3.Connection, version: int, installation: Install
raise DatabaseRejected("invalid_snapshot_database")
if version >= 4:
verify_history(connection)
+ if version >= 8:
+ verify_recurrence_records(connection, installation)
except DomusError as error:
raise DatabaseRejected("invalid_snapshot_records") from error
diff --git a/docs/architecture-recurrence.md b/docs/architecture-recurrence.md
index 6c5c946..4822f50 100644
--- a/docs/architecture-recurrence.md
+++ b/docs/architecture-recurrence.md
@@ -1,4 +1,6 @@
-# Recurrence temporal foundation
+# Recurrence architecture and implementation evidence
+
+## Temporal checkpoint
The first source checkpoint implements immutable [temporal facts](../backend/src/domus/domain/recurrence_time.py),
the [semantic application port](../backend/src/domus/application/recurrence_ports.py), and a
@@ -42,11 +44,11 @@ loading in domain/application modules. Real disposable negative import graphs ex
layers. The implementation, tests and verification limits are recorded in
[checkpoint evidence](evidence/recurrence-execution.json).
-Rule parsing/expansion, task classification, schema 8, durable materialization, source consumption,
-preview/apply, calendar queries, the composed runner and actual image/browser/HA acceptance remain
-outstanding. All 52 companion cases and 37 original owning cases remain requirements. This
-checkpoint does not establish those journeys, a shipped package, Stable distribution or the first
-all-domain release.
+At this temporal checkpoint, rule parsing/expansion, task classification, schema 8, durable
+materialization, source consumption, preview/apply, calendar queries, the composed runner and actual
+image/browser/HA acceptance remained outstanding. All 52 companion cases and 37 original owning
+cases remained requirements. The later checkpoints below record subsequent source progress;
+this original evidence does not establish a shipped package or the first all-domain release.
## Bounded rule and planning checkpoint
@@ -85,3 +87,43 @@ before future exception filtering; explicit exception/addition provenance, recei
historical restore non-recreation and atomic persistence remain required. The current cursor is
not exposed as a public authorization token. No scheduled task is created by this checkpoint.
See [rule/planning evidence](evidence/recurrence-rules.json) for measured results and remaining scope.
+
+## Owned fixed-recurrence storage
+
+The current [records](../backend/src/domus/domain/recurrence_records.py) remain pure immutable
+facts. A record constructor grants no human or technical authority. Rule record revisions support
+compare-and-swap updates. A separate pattern revision changes only when the pattern or time policy
+changes, so a pause does not invent a new nominal obligation. Cursor binding includes the complete
+canonical pattern and time-policy snapshot.
+
+The [repository port](../backend/src/domus/application/recurrence_ports.py) is part of the existing
+unit of work. Its [SQLite adapter](../backend/src/domus/infrastructure/sqlite_recurrence.py) never
+commits independently. Schema 8 stores rules, matching progress and immutable nominal slots with
+scoped definition/occurrence relationships. Deferred constraints require matching rule/progress
+pattern revisions and the exact slot command receipt before commit. A failed partial update leaves
+all prior rows intact; previous slot snapshots survive a valid pattern/progress replacement.
+
+A slot key hashes installation, channel, household, schedule, pattern revision and the tagged
+nominal value. It is independent of the task identifier and mutable record revision. The technical
+command namespace is fixed. It does not create a current human identity or bypass future runner
+authorization. Receipt, audit and outbox records still share the existing atomic transaction.
+
+[Closed versioned codecs](../backend/src/domus/infrastructure/sqlite_recurrence_records.py) reject
+unknown fields/versions, duplicate JSON keys, noncanonical date/time tags and corrupt header/body
+bindings. Date-only records retain dates without a fabricated UTC midnight. The existing canonical
+JSON byte bound applies. Snapshot inspection additionally verifies current source/cursor binding,
+installation scope and the slot/occurrence identifiers in original receipt results. It rejects
+progress above the configured count, future slot generations and contradictory snapshots in the
+same generation. Legitimate older generations remain valid after edits. Incomplete or inconsistent
+records are rejected without rewriting their source.
+
+The [migration guide](tasks/migration.md) distinguishes current source schema 8 from the immutable
+installed Edge.5 schema 7. CI builds a separate checked-out-source image for both CPU architectures
+and retains Edge.5 as a genuine old-image compatibility control. Its version is ci-current and it
+has no publication/deployment step. Actual hosted build/test outcomes remain required evidence.
+
+These are repository and migration capabilities. Scheduled human commands, actual fixed/relative
+materialization, lifecycle consumption, restore non-recreation, preview/apply, protected calendar
+queries and composed runners remain unfinished. The four additional storage refinements do not
+recount the retained 52 companion or 37 original owner cases. See
+[storage evidence](evidence/recurrence-storage.json) for exact tests and acceptance limits.
diff --git a/docs/evidence/recurrence-storage.json b/docs/evidence/recurrence-storage.json
new file mode 100644
index 0000000..51f27ee
--- /dev/null
+++ b/docs/evidence/recurrence-storage.json
@@ -0,0 +1,159 @@
+{
+ "status": "partial; storage boundary implemented, complete recurrence feature remains in progress",
+ "recordedAtUTC": "2026-11-01T22:18:53.593225+00:00",
+ "issue": "https://github.com/vypdev/domus/issues/20",
+ "sourceBase": "0806a38f8a2de1f102a017bff25ad6856fb17bcd",
+ "developmentBase": "3131810082760a98ee4af66f11bb65eaac49cc3b",
+ "backendSourceInventorySHA256": "37ab8446a4584c54f2a3a2fd7af2fb5e2b7ec6371cbc4f5b3e27b7146aee1700",
+ "implemented": [
+ "Pure scoped fixed-rule/progress/slot records and stable nominal command identity",
+ "Strict bounded versioned codecs and typed semantic unit-of-work repository",
+ "SQLite schema8 with scoped task/rule relationships, deferred matching progress and exact original receipt namespace",
+ "Compare-and-swap persistence, immutable original slot snapshots and atomic partial-edit refusal",
+ "Explicit supported-source migration, genuine schema7 fixture and injected DDL rollback",
+ "Snapshot physical/semantic recurrence verification with source/cursor/result binding"
+ ],
+ "testsFirst": {
+ "repository": "Initial 8 executions failed because SQLiteUnit had no recurrence repository, before its implementation. Original earlier missing-symbol output remains retained.",
+ "codec": "Original collection failed with missing domus.infrastructure.sqlite_recurrence_records before its implementation; original output retained."
+ },
+ "focused": {
+ "executions": 102,
+ "authoredBehaviorModules": 4,
+ "coverageTotals": {
+ "covered_lines": 282,
+ "num_statements": 282,
+ "percent_covered": 100.0,
+ "percent_covered_display": "100.00",
+ "missing_lines": 0,
+ "excluded_lines": 0,
+ "percent_statements_covered": 100.0,
+ "percent_statements_covered_display": "100.00",
+ "num_branches": 74,
+ "num_partial_branches": 0,
+ "covered_branches": 74,
+ "missing_branches": 0,
+ "percent_branches_covered": 100.0,
+ "percent_branches_covered_display": "100.00"
+ },
+ "scope": "Storage/domain/codec boundaries; not94 independent accepted product journeys."
+ },
+ "completeBackend": {
+ "executions": 3500,
+ "authoredModules": 122,
+ "coverageTotals": {
+ "covered_lines": 8706,
+ "num_statements": 8706,
+ "percent_covered": 100.0,
+ "percent_covered_display": "100.00",
+ "missing_lines": 0,
+ "excluded_lines": 267,
+ "percent_statements_covered": 100.0,
+ "percent_statements_covered_display": "100.00",
+ "num_branches": 2482,
+ "num_partial_branches": 0,
+ "covered_branches": 2482,
+ "missing_branches": 0,
+ "percent_branches_covered": 100.0,
+ "percent_branches_covered_display": "100.00"
+ },
+ "independentPerModuleAudit": "passed",
+ "durationSeconds": 103.73,
+ "loopback": "Final campaign permits disposable local fake-HA servers. Earlier sandbox-denied sockets are not product failures or successful evidence.",
+ "testRefinement": "Final full campaign includes both independent-writer cases, actual TaskOccurrence rollback and the six source/count integrity regressions."
+ },
+ "checks": {
+ "strictTypes": {
+ "result": "passed",
+ "files": 122
+ },
+ "ruffCheck": "passed",
+ "ruffFormat": {
+ "result": "passed",
+ "files": 235
+ },
+ "architecture": {
+ "result": "passed",
+ "contractsKept": 3,
+ "contractsBroken": 0,
+ "filesAnalyzed": 166,
+ "dependencies": 1022
+ },
+ "originalPinnedSdd": {
+ "result": "passed",
+ "upstreamRevision": "3e7ceea08e6131360e026937d1c0cc1b120472e0",
+ "policyModulesByteVerified": 7,
+ "immutableBaseOwners": 18,
+ "retainedOwnersUnchanged": true,
+ "currentCompanionSHA256": "8f933d1d31842a92bdb9061fdb2dc54cc6b67f3d34cde9c1f9c6124515fc0cb1",
+ "scope": "Explicit contributor integration; original first publication remains in Git. Format, reciprocal inventory/path consistency and current catalog pass."
+ },
+ "packageAndArchitectureTests": {
+ "result": "passed",
+ "executions": 52,
+ "requires": "Fresh frontend build and current backend wheel; both completed before acceptance."
+ }
+ },
+ "migration": {
+ "currentSourceSchema": 8,
+ "supportedHistoricalSchemas": [
+ 1,
+ 2,
+ 3,
+ 4,
+ 5,
+ 6,
+ 7
+ ],
+ "schema7FixtureSHA256": "c5ca7b2383e9f75561a59b9ea0bbbd98736ae400b19e735b5a6fdf90ca139ea5",
+ "originalSchema7Source": "64908ee59705e6e9dc6e4a06a8d828bf5eea6d4f",
+ "originalFixtureBytesUnchanged": true,
+ "rollback": "Compatible complete application/database/generation pair; no version-number downgrade."
+ },
+ "additionalStorageCaseObservations": {
+ "recurrence-storage-C01": "Partial: strict codec input byte bound tested; actual per-slot/batch execution and receipt sizing remain to implement.",
+ "recurrence-storage-C02": "Repository/codec canonical tags, closed versions/fields and header/body corruption are tested.",
+ "recurrence-storage-C03": "Ordinary schema7 reads/writes refuse before explicit initialization; successful source migration and failure rollback are tested.",
+ "recurrence-storage-C04": "A current slot without the exact scoped actor/action/key receipt cannot commit; rollback and valid evidence are tested."
+ },
+ "images": {
+ "currentSource": "CI builds checked-out backend/frontend with ci-current label on amd64 and aarch64; actual hosted results are pending.",
+ "schema7Control": {
+ "version": "0.0.0-edge.5",
+ "sourceCommit": "271a3a07feb1535f0a5ec8b55abb3de9aac049a9",
+ "scope": "Immutable owner-package fixture used for actual schema7 upgrade/refusal checks, without changing its pin."
+ },
+ "smoke": "Current recurrence/time/codec program passes a host control; actual CPU-image execution remains pending CI.",
+ "localDocker": "Not run; no local Docker engine was started."
+ },
+ "specification": {
+ "publishedActionRun": 41351649126,
+ "publishedCommit": "0806a38f8a2de1f102a017bff25ad6856fb17bcd",
+ "retainedExecutionCases": 52,
+ "retainedOriginalOwnerCases": 37,
+ "newIndependentStorageCases": 4,
+ "reviewCorrection": "The first publication four broad integration cases overlap retained owners. Their historical IDs and owner mapping remain explicit; they are not counted as additional independent journeys."
+ },
+ "copilotReports": [
+ "https://github.com/vypdev/copilot/issues/763#issuecomment-6563925226",
+ "https://github.com/vypdev/copilot/issues/835#issuecomment-6564066025"
+ ],
+ "retainedEvidence": {
+ "directory": "/private/tmp/domus-recurrence-storage-64908ee",
+ "scope": "Necessary original failures, exact source manifest, final full coverage/output, focused boundary coverage, original SDK proofs and genuine schema7 input. Compact and relocate after this local verification iteration; keep pending authored integration/stash until verified publication."
+ },
+ "pending": [
+ "Current-authorized scheduled creation and reviewed attachment",
+ "Actual fixed and completion-relative materialization and cycle/source handling",
+ "Historical restore non-recreation by the actual recurrence consumer",
+ "Pause/resume/skip/reschedule and reviewed preview/apply use cases",
+ "Task classification, protected calendar query/HTTP and native/standalone runner composition",
+ "Actual hosted images, runtime/browser/HA and complete52-case/first all-domain release acceptance"
+ ],
+ "ownerHa": "Installed Edge.5 and retained Edge.3 are unchanged. No new owner update, release, registry publication or deployment is performed.",
+ "integrityRegressions": {
+ "before": "Six targeted tests failed before the additional source/count checks; original output is retained.",
+ "after": "Progress above COUNT, future/missing sources and same-generation pattern contradictions are refused. Legitimate older slot snapshots remain valid.",
+ "domainPolicy": "Pure matches_schedule/consistent_with relationships are shared by persistence and snapshot inspection; they grant no authority."
+ }
+}
diff --git a/docs/tasks/migration.md b/docs/tasks/migration.md
index 5c9060f..6c19844 100644
--- a/docs/tasks/migration.md
+++ b/docs/tasks/migration.md
@@ -1,8 +1,9 @@
# Task storage migration and compatibility
-The development task source uses SQLite schema 7. Installed Edge.3 remains the separately pinned
-M1 schema-4 package. Source migration tests do not upgrade that installation or establish a released
-package upgrade. Keep the exact application/data pair and installation/channel identity.
+The development source uses SQLite schema 8 for fixed recurrence storage. The installed immutable
+Edge.5 package uses schema 7; retained Edge.3 remains the separately pinned M1 schema-4 package.
+Current source tests do not upgrade those installations or establish a released package upgrade.
+Keep the exact application/data pair and installation/channel identity.
| Schema | Added task state | Preservation |
|---|---|---|
@@ -10,8 +11,9 @@ package upgrade. Keep the exact application/data pair and installation/channel i
| 5 | Task policy, definitions, occurrences and completion cycles with scoped constraints | Existing household/member/history rows are retained |
| 6 | Frozen material bindings, usage intents, strict codecs and compare-and-set guards | Legacy task/request evidence remains readable; original hashes/receipts stay unchanged |
| 7 | Three causal-evidence indexes on the existing outbox | No business table, task row, receipt or event is rewritten |
+| 8 | Scoped recurrence rules, matching durable progress and immutable nominal slots | Original task, receipt, audit, outbox and historical records remain authoritative |
-Initialization accepts an empty owned destination or supported versions 1–6, verifies the application
+Initialization accepts an empty owned destination or supported versions 1–7, verifies the application
marker and installation/channel, then repeats inspection inside the bounded writer transaction.
Additive DDL, any required legacy-document upgrade and user_version advancement share that transaction.
An unsupported future version, mismatched identity or unrelated database is rejected. Normal runtime
@@ -47,19 +49,33 @@ acceptance. Its retained receipt proves that operation was processed before rest
apply the effect again. Queries distinguish current facts and historical outcomes. Current authority
is still required to recover a result. Old material/approval evidence never becomes a current grant.
-Rollback requires a compatible application/data pair. An older runtime must refuse schema 7 rather
-than attempt a downgrade. To reverse a restore, prepare from its retained source-checkpoint against
+Rollback requires a compatible application/data pair. A schema-7 runtime must refuse schema 8.
+To reverse a restore, prepare from its retained source-checkpoint against
the latest selected source and activate a new verified restore ID; do not reverse the pointer manually.
## Source evidence and limits
-Frozen SQL fixtures for versions 1–6 come from their original runtime checkpoints. In particular,
+Frozen SQL fixtures for versions 1–7 come from their original runtime checkpoints. In particular,
the v6 input comes from immutable 7beb63c and includes a reopened prerequisite, completed dependent
work, an approved closed cycle, confirmed material correction and restored-away settlement. The
schema-7 tests retain every original table row and prove failed index DDL rolls back exact database
bytes. Actual old-checkpoint inspection/activation tests preserve input/source bytes and upgrade
only the prepared target.
+The genuine schema-7 fixture was created under source 64908ee before recurrence DDL changes. It
+contains a household, members, a completed task/cycle and original operation evidence. Its SHA-256
+is c5ca7b2383e9f75561a59b9ea0bbbd98736ae400b19e735b5a6fdf90ca139ea5. Existing fixtures are unchanged.
+Schema-8 tests reject ordinary schema-7 read/write transactions before explicit initialization,
+retain original receipts during migration, and roll back new DDL plus version advancement after
+injected failures. Rule, matching pattern progress and exact slot command proof use deferred
+constraints within the same transaction. Snapshot inspection also checks the new documents,
+headers, installation identity, cursor binding and original effect references.
+
+The current-source CI image builds the checked-out backend and frontend for both architectures.
+The immutable Edge.5 image supplies real schema-7 upgrade and refusal controls; CI does not move
+its source pin or publish an owner package. Actual hosted image results remain a separate required
+gate. See [storage evidence](../evidence/recurrence-storage.json) for the current measured scope.
+
Those observations are retained in [task evidence](../evidence/task-execution-foundation.json).
They are distinct from image/cross-architecture adoption, real consumer ledgers and a restoration on
the owner's HA, which remain required before integrated release claims. No automatic backup schedule
diff --git a/specs/recurrence-storage-continuation.md b/specs/recurrence-storage-continuation.md
index d78cd47..ee8e67c 100644
--- a/specs/recurrence-storage-continuation.md
+++ b/specs/recurrence-storage-continuation.md
@@ -1,389 +1,203 @@
-# Durable recurrence execution and calendar continuation
+# Recurrence execution continuation and owned storage boundaries
- Status: Draft
- Date: 2026-11-01
- Catalog capability ID: recurrence-calendar
-- Last verified: 2026-10-29 (catalog evidence); implementation baseline: develop commit `3131810082760a98ee4af66f11bb65eaac49cc3b`
-- Owners: Domus maintainers (review assignment pending)
-- Scope: Complete durable recurrence execution and bounded calendar queries after the approved planning checkpoint, preserving all existing owner and companion requirements.
-- Related issues/PRs: [Issue #20](https://github.com/vypdev/domus/issues/20), issue #17, PRs #18 and #19
-- Required review gates: product UX, architecture, testing, documentation, security/operations
-- Open decisions blocking readiness: None identified by the owner. Status remains Draft until implementation evidence and required review gates are complete.
+- Related issue: https://github.com/vypdev/domus/issues/20
+- Retained execution owner: specs/recurrence-execution-foundation.md
+- Publication path: specs/recurrence-storage-continuation.md
+- Blocking product decisions: none; implementation and acceptance remain outstanding.
## 1. Executive summary
-Domus will turn authorized recurring responsibilities into durable household tasks and expose truthful, bounded calendar facts across its native and standalone persistent runtimes. The recommendation is to keep the existing planning policy and add transactional execution, lifecycle handling, and protected calendar queries under the recurrence-calendar capability. The safety rule is that a nominal occurrence is committed at most once, with its task/progress, receipt, audit, and outbox facts atomically consistent; outages and stale previews remain explicit states and never silently broaden authorization.
+Continue the complete existing recurrence execution contract after its deliberately partial planning checkpoint. This is an explicitly approved companion under the same recurrence-calendar capability. It retains the existing execution companion and original owner verbatim. Preserve the retained eight RCE requirements, 52 companion cases and 37 original owning cases verbatim. Four additional cases below refine independent storage boundaries; they do not replace, recount or certify those retained journeys.
-```text
-Authorized schedule -> bounded materialization -> durable task and receipt -> lifecycle progress -> protected calendar query
-```
-This companion adds four storage-contract refinements. It does not replace or redefine `specs/recurrence-calendar.md` or `specs/recurrence-execution-foundation.md`. All eight RCE requirements, all 52 companion cases, and all 37 original owner cases remain required. Existing cases are retained evidence and are not recounted in this companion's new case inventory. No planned acceptance case below is claimed as an executed test.
-## 2. Problem, current behavior, and evidence
-
-### 2.1 Problem
+### Review correction of the first publication
-Households and operators cannot yet rely on planned responsibilities surviving restart, concurrent workers, lifecycle changes, or compatible restoration. The planning foundation can describe occurrences, but it does not prove that execution committed them. Calendar consumers also lack an owned bounded query contract that distinguishes planned start, deadline, and actual completion.
+The first Action publication at 0806a38f8a2de1f102a017bff25ad6856fb17bcd generated four broader integration scenarios already required by the retained execution owner. They remain required through that owner; they are not four additional independent journeys. This explicit contributor revision replaces their duplicate counting with the four independently refined storage boundaries below. Original publication bytes remain in Git history. Its broad REC-CONT requirements remain summaries of retained RCE scope, not deleted product behavior. Neither retained owning document is edited.
-### 2.2 Current behavior
+| First-publication case reference | Retained owning coverage | Treatment |
+|---|---|---|
+| recurrence-storage-continuation-C01 | recurrence-execution-C45, recurrence-execution-C46 | Migration/rollback integration alias; required, not an additional counted journey. |
+| recurrence-storage-continuation-C02 | recurrence-execution-C17, recurrence-execution-C18, recurrence-execution-C19 | Concurrent atomic/restart integration alias; required, not an additional counted journey. |
+| recurrence-storage-continuation-C03 | recurrence-execution-C21, recurrence-execution-C31, recurrence-execution-C32, recurrence-execution-C35 | Reviewed schedule/state integration alias; required, not an additional counted journey. |
+| recurrence-storage-continuation-C04 | recurrence-execution-C37, recurrence-execution-C38, recurrence-execution-C39, recurrence-execution-C43 | Protected calendar integration alias; required, not an additional counted journey. |
-1. The verified development tree includes approved merges #18 and #19 and is byte-identical to reviewed/tested synced head `580ab11`.
-2. The foundation has strict rule validation, finite original frequency periods, timezone resolution, and side-effect-free planning.
-3. Planning cursors are values, not durable execution proof. The database is schema 7 and has no recurrence tables, durable materializer, lifecycle consumer, calendar query/API, or composed runner.
-4. Current immutable owner Edge.5 images do not invoke these modules. Five initial SQLite source/test/fixture files are preserved privately, but have an expected missing-symbol failure and are not implemented, passing, or publishable evidence.
-5. The combined foundation's 3,398 backend tests across 117 authored modules report 100% statement and branch coverage. This is foundation evidence only, not execution acceptance.
+The four newly defined IDs are distinct from those published aliases. A runtime test may cover several retained obligations but remains one executed journey. English and Spanish UI with English fallback remain confirmed under the owning product decisions. This storage increment does not introduce a new UI locale decision.
-### 2.3 Evidence
+## 2. Problem, current behavior, and evidence
-- Code and documentation: `backend/src/domus/application/fixed_recurrence.py`, `backend/src/domus/application/recurrence_ports.py`, `backend/src/domus/domain/recurrence_rules.py`, `backend/src/domus/domain/recurrence_planning.py`, `backend/src/domus/domain/nominal_recurrence.py`, `backend/src/domus/infrastructure/recurrence_time.py`, `backend/src/domus/infrastructure/sqlite.py`, and the existing task/source/restore seams.
-- Concrete example: after a planner returns a nominal occurrence, a restart or competing worker currently has no durable recurrence receipt to prove whether materialization already happened.
-- External primary sources: not applicable; this contract is repository-specific.
-- Unknowns: no new owner decision is pending. Exact implementation details remain subject to architecture review; no framework execution support is implied by current catalog paths.
+The verified immutable development base is commit 3131810082760a98ee4af66f11bb65eaac49cc3b, tree c18c57b60e20ccbc83d61464ab5fe623c8ed1713. It contains 453 tracked files and both approved foundation merges. The same tree passed 3,398 backend tests across 117 authored modules, with 100% statement and branch coverage (8,410 statements and 2,406 branches). These are foundation results, not durable recurrence acceptance.
-### 2.4 Retrospective classification (as-built baselines only)
+Pure rule validation, finite original-period expansion, pinned time resolution and bounded side-effect-free planning now exist. SQLite is still schema 7. There is no recurrence repository, durable materializer, lifecycle consumer, calendar query/API or composed recurrence runner. The earlier 430-blob archive and bootstrap baseline in the retained companion are historical evidence only.
-Not applicable: this is a prospective companion. The current behavior above is evidence for the baseline, not a reconstruction of an implemented execution capability.
+Five preliminary source/test/fixture files are privately retained with hashes and a Git stash. Their initial missing-symbol collection failure is not a passing test or completed implementation. Resume them only after this issue's normal companion publication and exact Action-managed branch admission. The genuine schema-7 fixture was generated from the prior schema-7 source before new DDL; existing historical fixtures remain immutable.
## 3. Actors, surfaces, and terminology
-| Actor | Goal | Entry point | Visible surfaces |
-|---|---|---|---|
-| Household member | Understand upcoming responsibilities and truthful task dates | Existing task/calendar consumer | Task and calendar views, later UI/HA surfaces |
-| Authorized application caller | Create or attach a schedule without creating a duplicate first task | Current authorized application flow | Task result and explicit conflict/unavailable result |
-| Native runtime | Materialize and consume durable work | Composed application runner | Operational status and logs |
-| Standalone runtime | Perform equivalent bounded durable work | Composed application runner | Operational status and logs |
-| Operator | Recover, inspect, or restore without recreating processed work | Maintenance and restore seams | Logs, durable receipts, audit evidence |
-
-A **schedule** is a persistent fixed or completion-relative recurrence definition. A **nominal slot** is a stable scheduled occurrence identity. A **receipt** records the durable result for a slot. **Actual work** is the completion fact used as the relative anchor; it is distinct from planned start and deadline. **Schema 8** is the recurrence execution migration. A **preview** is a bounded proposed operation that can become stale. Existing technical maintenance capability does not establish current human proof or permissions. Ownership/channel/generation fences and original receipts remain authoritative.
+Human configuration and protected result recovery retain current HA and household authority. Local maintenance receives a separately composed technical capability bound to the actual installation, channel and household; a record constructor or submitted selector grants no authority. A nominal slot identifies one obligation under a pattern revision. A record revision supports current-state concurrency; pausing alone must not change nominal identity.
## 4. Goals
| Requirement | Behavior |
|---|---|
-| REC-CONT-01 | Persist fixed and completion-relative schedules, unique nominal slots, lifecycle inbox/cycle closure, and transactional task/progress/receipt/audit/outbox facts; support bounded materialization in native and standalone runtimes. |
-| REC-CONT-02 | Preserve authorized create/reviewed attachment semantics without duplicating an existing first task; provide pause, resume, skip, reschedule, and preview/apply with stale-preview conflict behavior. |
-| REC-CONT-03 | Preserve schema 1–7 compatibility and fixture bytes; introduce recurrence schema 8 with atomic migration/rollback, restart/concurrency/crash safety, and historical restore without recreation. |
-| REC-CONT-04 | Expose bounded protected calendar queries and strict HTTP contracts with genuine task classification, date-only, planned-start, deadline, and actual-work facts; retain configured fold, first-valid gap/whole-date adjustment, and collision-review behavior. |
+| recurrence-storage-R1 | Enforce the aggregate canonical storage/receipt payload bound independently of each accepted field and batch count. |
+| recurrence-storage-R2 | Reject noncanonical, unknown-version or header/body-inconsistent stored recurrence records without defaulting or coercion. |
+| recurrence-storage-R3 | Refuse ordinary operations on an owned schema-7 source until explicit atomic schema-8 initialization succeeds. |
+| recurrence-storage-R4 | Bind each committed current nominal slot to its exact scoped original command evidence, independently of task-row existence. |
-### 4.1 Goals
-
-1. Deliver all four requirements above while retaining every requirement and case in both owning specifications.
-2. Ensure concurrent materialization, restart, crash recovery, and compatible restoration do not duplicate processed slots.
-3. Keep the first all-domain release, complete UI/HA calendar surfaces, and both deployments required; this continuation does not declare any accepted.
-
-### 4.2 Non-goals
-
-1. Changing the existing gateway or modifying Copilot resources/configuration; report Copilot defects upstream.
-2. Selecting a principal, installation, channel, or human permission on behalf of a request.
-3. Declaring an owner HA update, deployment, release/tag, or first all-domain release complete.
-4. Replacing either existing specification or treating closed/100% lifecycle labels on issue #17 as full feature acceptance.
-
-### 4.3 Fixed product/safety invariants
-
-1. Authorization remains current and explicit. Technical maintenance capability is not human proof or permission.
-2. Provider outages, stale previews, historical results, and original-result recovery are explicit unavailable/conflict/historical outcomes.
-3. No duplicate task or slot may result from retries, races, restart, or restore. A transaction cannot expose a task without its matching progress/receipt/audit/outbox facts.
-4. Original receipts, ownership/channel/generation fences, compatible whole application/database rollback, immutable historical fixture bytes, and existing time-resolution rules are preserved.
+All retained RCE-01 through RCE-08 work remains required: authorized scheduled creation/reviewed attachment, fixed and completion-relative persistence, atomic materialization/source processing, restore non-recreation, previews/exceptions, classification/calendar queries, strict HTTP and both persistent runners. The full first all-domain UI/HA/Stable-Edge release remains required. This appendix neither closes those requirements nor claims consumer delivery.
## 5. Current versus proposed product journey
-| Stage | Current | Proposed | User/operator effect |
-|---|---|---|---|
-| Authorize schedule | Planning foundation exists; execution is absent | Current authorization gates scheduled creation or reviewed attachment | Caller sees created, attached, unavailable, or conflict result |
-| Plan occurrence | Side-effect-free planner returns values | Bounded materializer claims stable nominal slots | Work remains bounded and inspectable |
-| Commit occurrence | No recurrence receipt proves durable processing | Atomic task/progress/receipt/audit/outbox transaction | Retry and concurrent workers converge on one result |
-| Process lifecycle | No durable lifecycle consumer | Inbox and cycle closure process duplicate/out-of-order events safely | Operators can distinguish pending, completed, and recoverable work |
-| Query calendar | No owned calendar query/API | Protected bounded query returns truthful date facts | Consumers share one scheduling model |
-
-```mermaid
-flowchart LR
- A[Authorized schedule action] --> B[Bounded planning and slot claim]
- B --> C[Atomic task and receipt commit]
- C --> D[Lifecycle inbox and cycle closure]
- D --> E[Protected bounded calendar query]
-```
-
-Text equivalent: authorized schedule action -> bounded slot planning and atomic durable commit -> lifecycle processing -> protected calendar facts.
+Current planning returns bounded values without saving tasks. The intended complete journey remains: current-authorized configuration, durable rule, bounded atomic execution and truthful protected calendar facts. Storage tests first establish owned persistence invariants; they cannot stand in for the actual human journey, HTTP authorization, runner composition or browser acceptance.
## 6. Functional behavior and state model
-### 6.1 Happy path
+Persist typed fixed-rule, progress and nominal-slot records through the existing unit of work. Separate mutable record revision from pattern revision. Preserve canonical date-only versus naive local-time tags, full time-policy snapshots and original resolved facts; date-only values do not acquire a fabricated midnight instant. Fixed cursor binding includes the complete pattern and time policy.
-1. The application verifies current authorization and the selected schedule operation. It does not infer a principal, installation, channel, or permission.
-2. Creation or reviewed attachment checks whether the first task already exists and avoids duplication.
-3. A bounded materializer computes eligible nominal slots and atomically claims each unique slot with task, progress, receipt, audit, and outbox facts.
-4. Lifecycle events enter a durable inbox. Duplicate delivery is idempotent; cycle closure advances completion-relative scheduling from actual work.
-5. Calendar queries enforce authorization and bounds and return task classification plus date-only, planned-start, deadline, and actual-work facts.
+Use explicit versioned closed encodings and a maximum 65,536-byte canonical JSON document. A 1,000-slot run is a work limit, not permission to put 1,000 arbitrarily large results into one receipt. Commit bounded per-slot results with stable original command keys; retain exact receipt, audit and outbox evidence in the same transaction. Validate scoped rule, definition, occurrence, installation and channel relationships. Deferred evidence constraints must hold at commit, including independently opened connections.
-### 6.2 Alternative paths
+Supported source migration adds recurrence tables atomically as schema 8 without replacing existing tables or receipts. Normal read/write transactions reject schema 7 before constructing usable recurrence repositories. Migration failure preserves original business rows and version. Unknown or future layouts and malformed records fail closed. Full snapshot verification must validate the new physical and semantic records before prepared activation.
-- Pause prevents new materialization; resume continues from durable state without recreating receipts.
-- Skip and reschedule operate on a reviewed slot and preserve audit history. Preview/apply rejects a stale preview as conflict.
-- Provider outage returns unavailable with retained durable facts; retry is bounded and does not widen authorization.
-- Historical restore preserves original receipts and does not recreate already processed work.
-- Fixed and completion-relative schedules use the existing configured single fold, first-valid gap/whole-date adjustment, and collision-review behavior.
+## 7. Non-goals and retained scope
-### 6.3 State machine
-
-| State | Entered when | User-visible meaning | Allowed next states | Recovery/owner |
-|---|---|---|---|---|
-| Authorized | Current authorization is verified | Requested schedule action may proceed | Previewed, attached, rejected | Application use case |
-| Previewed | Bounded operation is computed | Proposed changes await apply | Committed, stale conflict, unavailable | Application and operator |
-| Due | Nominal slot is eligible | Work is ready for bounded materialization | Committed, paused, skipped | Materializer |
-| Committed | Atomic facts and unique receipt exist | Occurrence was processed once | Lifecycle pending, closed | Database/application |
-| Lifecycle pending | Inbox event awaits processing | Completion/progress may be pending | Closed, retryable failure | Lifecycle consumer |
-| Closed | Cycle completion recorded | Relative schedule may use actual-work anchor | Due, paused | Application |
-| Historical | Restore identifies prior result | Prior processing is retained | Historical only | Restore seam |
-
-Duplicate events return the original result. Out-of-order events are retained/handled according to lifecycle ordering and cannot overwrite a newer generation. Canceled or paused schedules produce no new slots. Partial transaction failure rolls back the whole transaction; a committed transaction's receipt remains authoritative after response loss.
-
-## 7. User-facing configuration
-
-| Input | Type | Recommended default | Allowed values/range | Scope/persistence |
-|---|---|---|---|---|
-| Materialization batch limit | Positive integer | Small bounded batch selected by runtime policy | Positive, implementation-defined hard maximum; never unbounded | Per invocation; reread for next batch |
-| Calendar query limit | Positive integer | Conservative bounded page | Positive, implementation-defined hard maximum | Per request |
-| Time-zone ambiguity policy | Existing rule value | Existing configured single fold | Existing supported fold/gap policy only | Persisted schedule snapshot |
-| Relative anchor | Enum | Actual work completion | Actual work only | Durable cycle fact |
-
-Validation rejects nonpositive or over-limit bounds, invalid cross-field date ranges, unsupported time rules, and schedule data that violates existing strict rule validation. Operation authorization is reread at execution; a preview does not authorize later apply. Persisted schedules retain the validated rule snapshot. Schema migration treats absent legacy recurrence data as no recurrence state and preserves historical schema 1–7 data. Unknown future values fail closed and remain diagnosable; they are not interpreted as executable instructions. Safety invariants are not configurable.
-
-Recommended example: materialize the next bounded page of a weekly household schedule using its persisted timezone policy. Meaningful alternative: pause that schedule, review a reschedule preview, then apply only if its revision is current.
+No Copilot code, configuration, credential, bound or lifecycle change is authorized. The existing gateway remains integrated. No release, owner HA update or deployment is implied. Storage constructors and synthetic repository fixtures do not impersonate HA accounts. Unfinished execution, relative lifecycle, preview, calendar/API, native/standalone, UI, HA and launch acceptance remain explicit work under the retained owners, not exclusions from the product.
## 8. Clean Architecture
-### 8.1 Responsibilities and dependency direction
-
-| Layer/boundary | Owns | Must not own/import |
-|---|---|---|
-| Domain/pure policy | Recurrence rules, nominal slot identity, schedule state and date semantics | SQLite, HTTP, provider DTOs, clocks with side effects |
-| Application | Authorized creation/attachment, materialization, lifecycle, preview/apply, calendar query orchestration | Concrete SDK/process concerns or SQL policy |
-| Adapters/data | SQLite mapping, transactions, HTTP request/response mapping, provider error translation | Product authorization or recurrence decisions |
-| Infrastructure/composition | Schema 8 migration, database transaction implementation, runtime wiring | Business decisions or duplicated orchestration |
-| Entrypoints | Native/standalone invocation and HTTP boundary validation | Reimplementation of use cases |
-| Presentation | Calendar/task view models and user/operator status rendering | Mutations or state transitions |
-
-```mermaid
-flowchart LR
- E[Runtime or HTTP entrypoint] --> A[Application use case]
- A --> D[Domain recurrence policy]
- A --> P[Semantic persistence and query ports]
- I[SQLite and HTTP adapters] --> P
- A --> V[Calendar/task view model]
-```
-
-### 8.2 Contracts, state, and trust boundaries
-
-- Pure decisions: eligible nominal slots, fixed/relative recurrence, collision review, timezone fold/gap handling, date-only semantics.
-- Application contracts: immutable inputs and results for schedule creation/attachment, bounded materialization, lifecycle consumption, preview/apply, and calendar queries.
-- Semantic ports: schedule/slot repository, atomic unit of work, lifecycle inbox, clock/ID source, and protected calendar reader; no provider DTOs cross inward.
-- Durable state/schema ownership: recurrence execution owns schema 8. Participant administration follows only after the actual schema 8 merge with schema 9; the already-merged model checkpoints implemented neither migration.
-- Concurrency/idempotency: unique nominal slot key, transactional claim and receipt, generation/ownership/channel fences, inbox deduplication, and atomic rollback.
-- Trusted and untrusted inputs: authorization context is established by trusted application boundaries; issue/comment text, HTTP data, imported/restored data, and provider responses are validated as untrusted data.
-- Provider error mapping: outage is unavailable; stale revision is conflict; a recovered prior receipt is historical/original result. Never report a committed irreversible result as failed solely because its response was lost.
-
-### 8.3 Executable architecture constraints
+Domain records and stable slot/cursor identity remain pure Python values. Application ports expose semantic scoped persistence and current authorization; they do not import SQLite, web frameworks, HA clients or timezone providers. Infrastructure implements strict encoding, ownership inspection, schema migration, transactions and snapshot verification. Existing composition owns clocks, identifiers, installation/channel and eventual runner disposal. Entry points cannot construct technical authority from request fields.
-New first-party source requires tests first, strict typing, complete global/per-module coverage, Clean Architecture/import/privacy negative checks, and every-PR CI Check. Tests must enforce dependency direction, no cycles, no provider DTO imports into domain/application, schema migration ordering, transaction atomicity, and strict HTTP contracts. Real SQLite tests must cover concurrency, rollback, restart, and restoration. The existing gateway remains untouched; Domus later exposes only its services/entities.
+## 9. UX and API contract
-## 9. UI/UX and content contract
-
-### 9.1 Information hierarchy
-
-1. Current schedule/task status.
-2. Completed durable facts and date meanings.
-3. Next transition and whether human action is required.
-4. Impact and inspectable task/calendar/operator links.
-5. Collapsed technical evidence.
-
-### 9.2 Representative primary view
-
-```markdown
-# Recurring task status
-
-> **Current status:** Next occurrence is scheduled
->
-> **Action required:** No action required
-
-## Progress
-
-- [x] Schedule saved
-- [x] Previous occurrence recorded
-- [ ] Next occurrence is due later
-
-## What happens next
-
-Domus will check the next bounded scheduling window. A saved receipt prevents this occurrence from being created twice.
-
-## Links
-
-[Open task](task destination) · [View calendar](calendar destination)
-
-
-Technical details
-Schedule revision, nominal slot, receipt, and sanitized runtime evidence.
-
-```
-
-State variants:
-
-- Pending/no action: “Status: Schedule paused. Action required: No action required. Existing tasks remain; no new occurrence will be created until resumed.”
-- Action required: “Status: Reschedule preview is ready. Action required: Review and apply this preview before its revision changes.”
-- Blocked/failed: “Status: Calendar data is unavailable. Cause: The storage/provider request failed. Action: Retry when available. Retained state: Previously committed receipts and tasks remain inspectable.”
-- Partial success: “Status: Occurrence committed; response was interrupted. Action required: No action required. Retained state: The receipt and task are durable; reopening the task shows the original result.”
-- Completed: “Status: Cycle closed. Action required: No action required. Actual completion is recorded and anchors the next relative occurrence.”
-
-### 9.3 Issue, PR, and comment behavior
-
-- Deterministic titles identify recurrence execution and calendar query work; the canonical issue is linked directly.
-- Merge enables code through the approved branch/CI lifecycle; closing an issue does not establish feature acceptance. Retry reuses the nominal slot and returns its durable original result.
-- Durable database receipts and audit facts are authoritative; labels/checks are supplemental.
-- No recurring comment or notification is required for materialization. PR summaries update idempotently, with one primary status and descriptive links. No new comment budget is needed for runtime behavior; operational notifications must be bounded to one per state transition and deduplicated by durable event identity.
-
-### 9.4 Accessibility, localization, and responsive behavior
-
-Use the repository's supported locale conventions and fallback; exact additional locales are not established by supplied evidence. Text carries all status meaning without color or icons. Narrow layouts keep status and action first; tables may become stacked lists. Use logical headings, descriptive links, and semantic controls in later UI/HA surfaces. Sanitize untrusted Markdown, mentions, commands, markers, URLs, and diagrams before rendering. HTTP examples use `Content-Type: application/json` notation; actual runtime URL semantics are defined by the API contract.
+Retain the owner's truthful pending, incomplete, conflict, unavailable and historical-effect-absent states. There is no new user-visible endpoint in a storage-only checkpoint. Later EN/ES interfaces use English fallback and the same actual protected use cases; a stored fixture is not evidence of an interactive or HA calendar surface. Recovery requires current authority before original protected results are disclosed.
## 10. Failure, recovery, and cleanup
-| Failure/partial state | User impact | Retained facts | Automatic retry | Required action | Cleanup |
-|---|---|---|---|---|---|
-| Provider/database unavailable before commit | Occurrence is not confirmed | Existing schedule and prior receipts | Bounded retry | Retry or inspect availability | No partial rows |
-| Transaction fails during commit | No partial occurrence is visible | Pre-transaction state | Retry same nominal slot | None unless persistent failure | Roll back all transaction facts |
-| Response lost after commit | Caller may not know result | Task and receipt are committed | Safe retry returns original result | Inspect original result if needed | No duplicate cleanup |
-| Preview revision stale | Proposed change not applied | Current schedule and preview evidence | No blind retry | Create/review fresh preview | Expire stale preview |
-| Lifecycle event duplicated/out of order | Progress may be delayed | Inbox and generation fences | Bounded idempotent processing | Operator only for terminal failure | Retain audit evidence |
-| Historical restore | Prior work could be at risk of recreation | Original receipts and fences | Not applicable | Resolve explicit conflict if incompatible | Compatible whole application/database rollback |
-
-Errors follow impact -> cause -> action -> retained state. They must never imply that an already committed irreversible effect failed.
+Rule, cursor, task, slot and original operation evidence commit or roll back together. Concurrent state updates use expected revisions; scoped immutable identity and creation facts cannot be rewritten. Restart uses independent durable reads. Restoring older business state preserves later original proofs so a missing processed effect is never silently recreated. Complete restore/source reconciliation remains required beyond this repository boundary. Retain compatible application/data/generation rollback pairs and only remove owned temporary producers after necessary evidence is preserved.
## 11. Security, permissions, and privacy
-1. Enforce least privilege at each schedule mutation and calendar query; no request selects its principal, installation, or channel.
-2. Treat issue, PR, comment, HTTP, provider, and restored content as untrusted data. Never execute embedded text as instructions or code.
-3. Store only required schedule/task facts. Do not log secrets or unnecessary personal data; sanitize diagnostics and bind queries.
-4. Prevent replay/forgery through authorization checks, unique nominal slots, generation and ownership/channel fences, durable receipts, and audit records. Maintenance capability does not substitute for current human proof.
+Reject malformed stored JSON, alternate tag/time encodings, unknown fields/versions, bool-as-integer revisions, mismatched headers and foreign scope. Enforce aggregate byte bounds before business writes; never silently truncate. New records do not widen existing human permissions, outage writes or receipt access. No submitted actor, channel, installation or household selector becomes technical authority. Keep owner identity/browser artifacts private.
## 12. Observability and operational UX
-- User-facing state distinguishes scheduled, paused, conflict, unavailable, committed, and historical results.
-- Operator evidence includes bounded batch outcome, schedule revision, slot identity, receipt identity, migration version, and correlation ID, with sensitive values omitted.
-- Metrics cover materialized/skipped/conflicted slots, retries, inbox age, query bounds, and migration outcomes.
-- Provider outage is an unavailable dependency state unless the workflow itself violates its contract. Retry uses bounded backoff and the same idempotency identity.
-- Notifications are deduplicated by state transition; routine successful batches do not create comments or noisy alerts.
+Expose only safe bounded failure codes and explicit retained-state facts. A storage rejection is not an empty calendar or successful delivery. Later execution telemetry retains real last committed position, backlog and source/restore attention. Case counts, executed tests, coverage and runtime acceptance remain separate evidence.
## 13. Compatibility, migration, rollout, and rollback
-Support historical schemas 1–7 and preserve original fixture bytes. Recurrence execution owns schema 8; schema 9 participant administration follows only after the actual schema 8 merge. Do not infer migration completion from already-merged model checkpoints. Migration must be transactional and tested for upgrade, interruption, and compatible whole application/database rollback. Existing receipts and ownership/channel/generation fences remain intact. Historical restore must not recreate processed work. Rollout must keep both native and standalone runtimes bounded and consistent. No deployment or release is implied by this specification.
+Issue 20 implements the original issue 17 schema-8 contract. Issue 21 continues the original issue 16 administration contract only after the actual schema-8 merge, then uses schema 9. The model-only checkpoint merges left schema 7 and did not perform either migration.
+
+Preserve historical schemas 1 through 7 and the unchanged existing fixture bytes. Add the genuine schema-7 fixture rather than rewriting prior fixtures to resemble a later layout. Verify explicit migration, failure rollback, strict current-schema transactions and semantic snapshots. Actual old schema-7 images, including retained Edge.5, refuse schema 8. Rollback selects the compatible complete application/database/generation pair; never edit a version number to downgrade. Both actual image architectures and persistent compositions need current-source tests before claiming a new package. No automatic backup retention policy is invented.
-## 14. Testing strategy
+## 14. Testing strategy and numeric budget
-The continuation has four new independent storage refinements, each requiring a distinct executable case below. The retained 52 companion cases and 37 original owner cases, plus all eight RCE requirements, remain mandatory under their owning specifications and are not counted as new cases here. The numeric budget is a floor for this companion inventory; broader retained requirements keep their original budgets.
+The retained 52 companion cases and 37 original owning cases remain required. The following 4 distinct cases add storage-specific boundaries only. Do not count a parameterization or a test that also covers a retained scenario as another independent journey. Begin with meaningful failing tests and require 100% supported first-party statement/branch coverage globally and per module, strict typing, architecture/privacy negatives and every-PR CI Check.
| Area | Minimum distinct cases | Behaviors/risks covered |
|---|---:|---|
-| Integration/security/migration | 1 | Observable scenarios in the case inventory. |
-| State/application/idempotency/races | 1 | Observable scenarios in the case inventory. |
-| Domain/configuration/pure planning | 1 | Observable scenarios in the case inventory. |
-| Adapters/provider contracts | 1 | Observable scenarios in the case inventory. |
+| Storage boundary | 1 | Observable scenarios in the case inventory. |
+| Record integrity | 1 | Observable scenarios in the case inventory. |
+| Migration entry | 1 | Observable scenarios in the case inventory. |
+| Evidence binding | 1 | Observable scenarios in the case inventory. |
| Total | 4 | Distinct inventory cases; repeated executions do not add cases. |
| Case ID | Area | Requirements | Scenario |
|---|---|---|---|
-| recurrence-storage-continuation-C01 | Integration/security/migration | REC-CONT-03 | Starting from schema 7, migrate to schema 8, interrupt and roll back the whole transaction, then restart; supported schemas 1–7 remain readable and original fixture bytes and receipts are unchanged. |
-| recurrence-storage-continuation-C02 | State/application/idempotency/races | REC-CONT-01, REC-CONT-03 | Two SQLite-backed workers concurrently materialize one nominal slot; exactly one task/progress/receipt/audit/outbox set commits, and restart/retry returns that original result. |
-| recurrence-storage-continuation-C03 | Domain/configuration/pure planning | REC-CONT-02, REC-CONT-04 | Pause, resume, skip, and reschedule a previewed occurrence; applying a stale revision conflicts, while the valid path preserves actual-work anchoring and configured fold/gap/collision behavior. |
-| recurrence-storage-continuation-C04 | Adapters/provider contracts | REC-CONT-04 | An authorized bounded HTTP calendar query returns strict task classification, date-only, planned-start, deadline, and actual-work facts; invalid bounds and unauthorized requests fail without leaking protected data. |
+| recurrence-storage-C01 | Storage boundary | recurrence-storage-R1 | Individually valid fields combine into a canonical document above 65,536 UTF-8 bytes; serialization rejects it before any business write, and a bounded run does not pack oversized aggregate results into one receipt. |
+| recurrence-storage-C02 | Record integrity | recurrence-storage-R2 | A stored tagged date/time or revision has an alternate encoding, unknown field/version or header/body mismatch; a fresh repository read fails safely without coercion, defaults or data replacement. |
+| recurrence-storage-C03 | Migration entry | recurrence-storage-R3 | An owned genuine schema-7 database is opened through ordinary read and write transactions before initialize; both refuse it without changes, while explicit successful migration enables schema-8 transactions. |
+| recurrence-storage-C04 | Evidence binding | recurrence-storage-R4 | A current nominal slot names a real same-household occurrence but its exact scoped recurrence command receipt is absent or replaced by another command namespace; commit rejects it and leaves no slot or unrelated evidence changes. |
-Define the complete repository threshold as 100% statement and branch coverage for backend source, including every changed module, with strict typing and no untested first-party source. New pure policy has 100% branch coverage. Test categories include table-driven boundary cases, replay, real SQLite race/rollback/restart/restoration, adapter and strict HTTP contracts, migration, architecture/import/privacy negatives, browser/OpenAPI/image tests where applicable, and CI Check on every PR. Use deterministic clocks/IDs and fakes; no real waits or live services. Golden UI fixtures require semantic assertions for status, action, links, sanitization, and narrow layout, not snapshot-only approval. Manual UX evidence must confirm a household member and operator can distinguish planned start, deadline, actual completion, conflict, unavailable, and historical result without logs. No test results are asserted by this draft.
+Use real independent SQLite connections and exact before/after version/row checks. These case descriptions are planned, not executed. The complete retained contract still needs concurrency/crash/restoration, actual source lifecycle, HTTP/security, both runtimes/images and browser/HA acceptance where applicable.
## 15. Documentation and discoverability
| Path | Change |
|---|---|
-| `specs/recurrence-storage-continuation.md` | create |
-| `specs/recurrence-calendar.md` | reference |
-| `specs/recurrence-execution-foundation.md` | reference |
-| `docs/architecture-recurrence.md` | update |
-| `docs/user-guide.md` | update |
-| `docs/operations.md` | update |
-| `docs/contracts/home-assistant.md` | update |
-| `docs/evidence/recurrence-execution.json` | update |
-| `docs/evidence/recurrence-rules.json` | update |
-| `backend/src/domus/infrastructure/sqlite.py` | update |
-| `backend/src/domus/application/fixed_recurrence.py` | reference |
-| `backend/src/domus/application/recurrence_ports.py` | reference |
-| `backend/src/domus/domain/recurrence_rules.py` | reference |
-| `backend/src/domus/domain/recurrence_planning.py` | reference |
-| `backend/src/domus/domain/nominal_recurrence.py` | reference |
-| `backend/src/domus/infrastructure/recurrence_time.py` | reference |
-| `docs/product-vision.md` | update |
-| `docs/architecture.md` | update |
-
-| Audience | Artifact/page | Required content | Validation/navigation |
-|---|---|---|---|
-| User | `docs/user-guide.md` | Schedule, pause/resume, calendar date meanings, examples | Links and examples checked against contract fixtures |
-| Setup owner | `docs/contracts/home-assistant.md` | Later owned service/entity contract and prerequisites | Contract validation; no claim that surfaces are implemented |
-| Operator | `docs/operations.md` | Migration, outage, stale preview, restore and recovery | Recovery decision tree and migration fixtures |
-| Contributor | `docs/architecture-recurrence.md`, `docs/architecture.md` | Ports, transaction ownership, trust boundaries, test commands | Architecture/import checks |
-
-Documentation changes must distinguish proposed behavior from current evidence, include migration and troubleshooting guidance, and keep examples synchronized with implementation fixtures. Paths declared as update are existing immutable-base files; proposed new implementation files, if any, must be declared as create before committing them.
+| specs/recurrence-storage-continuation.md | update |
+| specs/recurrence-execution-foundation.md | reference |
+| specs/recurrence-calendar.md | reference |
+| specs/household-administration.md | reference |
+| specs/catalog.json | update |
+| specs/CATALOG.md | update |
+| .dockerignore | create |
+| .github/workflows/ci_check.yml | update |
+| tests/fixtures/current-app/Dockerfile | create |
+| tests/containers/test_app_image.py | update |
+| tests/containers/test_app_compatibility.py | update |
+| tests/containers/restore_fixture.py | update |
+| tests/containers/test_recurrence_image.py | create |
+| addon-edge/Dockerfile | reference |
+| docs/evidence/recurrence-storage.json | create |
+| backend/src/domus/application/fixed_recurrence.py | update |
+| backend/src/domus/application/ports.py | update |
+| backend/src/domus/application/recurrence_ports.py | update |
+| backend/src/domus/domain/recurrence_planning.py | update |
+| backend/src/domus/domain/recurrence_records.py | create |
+| backend/src/domus/infrastructure/sqlite.py | update |
+| backend/src/domus/infrastructure/sqlite_snapshots.py | update |
+| backend/src/domus/infrastructure/sqlite_recurrence.py | create |
+| backend/src/domus/infrastructure/sqlite_recurrence_schema.py | create |
+| backend/src/domus/infrastructure/sqlite_recurrence_records.py | create |
+| tests/backend/test_sqlite_recurrence.py | create |
+| tests/backend/test_recurrence_records.py | create |
+| tests/backend/test_sqlite_recurrence_records.py | create |
+| tests/backend/test_sqlite_recurrence_integrity.py | create |
+| backend/src/domus/infrastructure/sqlite_recurrence_integrity.py | create |
+| tests/fixtures/sqlite/v7.sql | create |
+| tests/backend/test_sqlite_transactions.py | update |
+| tests/backend/test_sqlite_snapshots.py | update |
+| tests/backend/test_activation_compatibility.py | update |
+| tests/backend/test_restore_source.py | update |
+| tests/backend/test_sqlite_history.py | update |
+| tests/backend/test_sqlite_identity.py | update |
+| tests/backend/test_sqlite_recovery.py | update |
+| tests/backend/test_sqlite_task_integrity.py | update |
+| tests/backend/test_sqlite_task_materials.py | update |
+| tests/backend/test_sqlite_task_reviews.py | update |
+| tests/backend/test_task_restoration.py | update |
+| docs/architecture-recurrence.md | update |
+| docs/tasks/migration.md | update |
+| docs/evidence/recurrence-execution.json | update |
+
+Existing retained path declarations describe their original publication baseline; the table above identifies this continuation's actual new and existing storage paths at the immutable base. Declare any additional real implementation/test path before committing it. Update exact evidence and current acceptance limits without relabeling historical results.
## 16. Acceptance
-1. Given a currently authorized schedule creation request and an existing first task, when reviewed attachment proceeds, then the existing task is attached without duplication and the result is inspectable.
-2. Given a due nominal slot, when two workers materialize concurrently, then exactly one atomic task/progress/receipt/audit/outbox set is durable and both observe the same original result.
-3. Given a completion-relative schedule, when lifecycle closure records actual work, then the next occurrence uses that actual-work anchor; planned dates are not substituted.
-4. Given pause, skip, reschedule, or resume, when a valid operation is applied, then durable state and audit facts reflect it; applying a stale preview returns conflict without mutation.
-5. Given a database outage before commit, when materialization is attempted, then the result is unavailable, no partial facts remain, and bounded retry is safe.
-6. Given a response interruption after commit or a historical restore, when the operation is retried/restored, then the original receipt is retained and no occurrence is recreated.
-7. Given an unauthorized or malformed calendar request, when it reaches the HTTP boundary, then strict validation rejects it and protected household data is not disclosed.
-8. Given a valid bounded query, when a household member views results, then task classification, date-only, planned start, deadline, and actual completion remain distinct and accessible in text.
-9. Given schema 1–7 data, when schema 8 migration and compatible rollback are exercised, then prior data and fixture bytes are preserved and recurrence state is consistent.
-10. Given the proposed change, when CI and review gates run, then strict typing, global/per-module coverage, architecture/privacy negatives, applicable browser/OpenAPI/image checks, every-PR CI Check, and zero unresolved Bugbot findings are required before merge.
-11. Given the documentation and catalog publication, when validation runs, then every proposed path is declared, existing companion specifications remain intact, and Draft/readiness blockers remain visible until real acceptance.
+1. Given independently valid input fields whose canonical combined representation exceeds the byte bound, when persistence is attempted, then it fails before business changes and does not truncate the result.
+2. Given a corrupt/noncanonical recurrence document, when an independent repository reads it, then the read returns a safe storage failure and preserves the original source bytes.
+3. Given an owned schema-7 source, when ordinary transactions run before explicit initialization, then they refuse the old schema; successful explicit migration enables the same source under schema 8.
+4. Given a nominal slot with a valid occurrence but missing or wrong-namespace command proof, when its transaction commits, then deferred evidence validation rejects the transaction with no partial slot.
+
+Execute and retain current-source results before marking these scenarios accepted. Passing them alone does not complete retained RCE requirements or release acceptance.
## 17. Requirements traceability
| Requirement | Cases |
|---|---|
-| REC-CONT-01 | recurrence-storage-continuation-C02 |
-| REC-CONT-02 | recurrence-storage-continuation-C03 |
-| REC-CONT-03 | recurrence-storage-continuation-C01, recurrence-storage-continuation-C02 |
-| REC-CONT-04 | recurrence-storage-continuation-C03, recurrence-storage-continuation-C04 |
+| recurrence-storage-R1 | recurrence-storage-C01 |
+| recurrence-storage-R2 | recurrence-storage-C02 |
+| recurrence-storage-R3 | recurrence-storage-C03 |
+| recurrence-storage-R4 | recurrence-storage-C04 |
-The retained requirements and cases remain governed by `specs/recurrence-calendar.md` and `specs/recurrence-execution-foundation.md`: eight RCE requirements, `recurrence-execution-C01` through `recurrence-execution-C52`, and `recurrence-calendar-C01` through `recurrence-calendar-C37`. They are referenced, not redefined or included in this companion's new case inventory.
+The retained owner's reciprocal mappings remain unchanged. New mappings above cover only this companion's four independent refinements, not another execution count for the retained cases.
## 18. Implementation sequence
-1. Update this companion through the Action-managed admission workflow; preserve existing specifications verbatim and keep Draft status.
-2. Add tests first for schema 8, slot uniqueness, atomicity, migration, rollback, and SQLite concurrency.
-3. Implement pure execution/lifecycle/calendar contracts and verify strict typing and complete coverage.
-4. Implement application use cases and semantic ports, then SQLite adapters and bounded native/standalone composition.
-5. Add strict HTTP contracts and later presentation/service/entity consumers without changing the gateway.
-6. Add restore, restart, crash, security, migration, and provider failure evidence; update user, operator, setup, and contributor documentation alongside behavior.
-7. Run architecture/import/privacy negatives, applicable browser/OpenAPI/image tests, full coverage, every-PR CI Check, and resolve all Bugbot findings before merge.
-8. Update catalog evidence and generated catalog through the normal validation/publication workflow. Do not claim deployment, release, or first all-domain acceptance from this sequence alone.
+After normal branch admission: restore the privately preserved test-first draft; implement pure records and scoped SQLite adapter; implement schema-8 migration and complete snapshot verification; exercise negative boundaries and real transactions; then continue all retained authorized execution, relative lifecycle, restoration, preview, calendar/API and runner work. Update evidence, documentation and applicable images before review. Administration schema 9 follows the actual recurrence merge.
## 19. Definition of Done
-- [ ] All four companion requirements and every normative requirement in both retained specifications have observable acceptance and traceability.
-- [ ] `specs/recurrence-calendar.md` and `specs/recurrence-execution-foundation.md` remain verbatim; all 8 RCE requirements, 52 companion cases, and 37 original owner cases remain required.
-- [ ] Architecture boundaries, strict typing, import/privacy negatives, and application/database transaction ownership are enforced automatically.
-- [ ] Schema 8 supports schemas 1–7, immutable fixtures, transactional rollback, restart, concurrency, and historical restore without recreation.
-- [ ] Native and standalone materialization is bounded; lifecycle, authorization, time resolution, and calendar facts meet contract.
-- [ ] Numeric test budget, global/per-module coverage, real SQLite scenarios, and applicable browser/OpenAPI/image checks pass.
-- [ ] UI states, accessibility, localization conventions, responsive layout, sanitization, and notification budgets are reviewed with manual evidence.
-- [ ] User, setup, operator, contributor, migration, and troubleshooting documentation is complete and discoverable.
-- [ ] Failure, partial success, retry, idempotency, security, and cleanup pass; no unresolved Bugbot finding remains.
-- [ ] Every-PR CI Check passes. Copilot defects are reported upstream; no Copilot configuration/resource change or scope/coverage relaxation is made.
-- [ ] `specs/catalog.json` evidence and `specs/CATALOG.md` are current and `pnpm run validate:specifications` passes.
-- [ ] All retained specifications and this companion remain Draft until real implementation acceptance; first all-domain release, complete UI/HA surfaces, and both deployments remain unaccepted until separately evidenced.
+- [ ] The four additional cases and every retained requirement/case have truthful current-source evidence; no checkpoint is described as full feature acceptance.
+- [ ] Supported first-party coverage is 100% globally/per module; strict types, real architecture contracts and all applicable current-head CI Check suites pass.
+- [ ] Original format, consistency and catalog checks pass without changing Copilot resources or weakening limits.
+- [ ] Actual production composition, source/restore reconciliation and runtime/UI/HA acceptance are recorded when implemented.
+- [ ] No unresolved Bugbot finding remains before merge; reported Copilot limitations keep their actual scope.
+- [ ] Necessary evidence and rollback pairs are retained and owned temporary producers are cleaned.
## 20. References and decisions
-- Primary sources: immutable development base `3131810082760a98ee4af66f11bb65eaac49cc3b`, tree `c18c57b60e20ccbc83d61464ab5fe623c8ed1713`, reviewed/tested synced head `580ab11`, and the supplied validated recurrence-calendar catalog record.
-- Related specs/issues/PRs: [Issue #20](https://github.com/vypdev/domus/issues/20), issue #17, PRs #18 and #19, `specs/recurrence-calendar.md`, `specs/recurrence-execution-foundation.md`.
-- Decisions: use the explicitly approved companion path `specs/recurrence-storage-continuation.md`; preserve the two existing specifications and their requirements/cases; schema 8 belongs to recurrence execution; participant administration follows only after actual schema 8 merge with schema 9.
-- Follow-up outside this spec: first all-domain release, complete UI/HA surfaces, both deployments, owner HA update, deployment/release/tag, and any Copilot resource/configuration changes. No such work is authorized by starting this continuation.
+This companion follows the same immutable retained owners and confirmed product decisions. New record format, byte bound, scoped evidence binding and sequential schema ownership are maintainer engineering proposals, not new owner answers. No product choice is pending. The Action owns SDD publication and remote branch admission. The submitted companion is review input; local validation does not publish it or establish runtime acceptance.
diff --git a/tests/backend/test_activation_compatibility.py b/tests/backend/test_activation_compatibility.py
index 7defc10..f8d9e40 100644
--- a/tests/backend/test_activation_compatibility.py
+++ b/tests/backend/test_activation_compatibility.py
@@ -79,7 +79,7 @@ def test_real_activation_upgrades_only_target_from_frozen_source_and_checkpoint(
selected = open_storage(root, "edge")
try:
with selected.database._connection() as connection:
- assert connection.execute("PRAGMA user_version").fetchone()[0] == 7
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 8
assert connection.execute("SELECT COUNT(*) FROM retained_operations").fetchone()[0] == (
2 if version == 4 else 1
)
diff --git a/tests/backend/test_recurrence_records.py b/tests/backend/test_recurrence_records.py
new file mode 100644
index 0000000..102e6dc
--- /dev/null
+++ b/tests/backend/test_recurrence_records.py
@@ -0,0 +1,179 @@
+"""Persistable recurrence facts retain scope and nominal identity independently of authority."""
+
+from dataclasses import FrozenInstanceError, replace
+from datetime import date, datetime
+
+import pytest
+
+from domus.domain.errors import InvalidInput
+from domus.domain.recurrence_planning import FixedCursor, PlannedFixedSlot, cursor_binding
+from domus.domain.recurrence_records import (
+ FixedProgress,
+ FixedSchedule,
+ ScheduledSlot,
+ ScheduleState,
+)
+from domus.domain.recurrence_rules import FixedPattern, Frequency, NominalDate
+from domus.domain.recurrence_time import NominalTime, ResolvedTime, TimePolicy, TimeResolution
+from domus.domain.storage import Installation
+
+
+def values(timed=False):
+ start = NominalTime(datetime(2026, 1, 1, 12)) if timed else NominalDate(date(2026, 1, 1))
+ rule = FixedSchedule(
+ "rule",
+ "home",
+ "definition",
+ FixedPattern(start, Frequency.DAILY),
+ TimePolicy("UTC", "fixture:1"),
+ 100,
+ )
+ resolved = (
+ ResolvedTime(start, rule.policy, start.local, 0, TimeResolution.EXACT, "a" * 64)
+ if timed
+ else None
+ )
+ slot = ScheduledSlot(
+ Installation("00000000-0000-0000-0000-000000000001", "edge"),
+ rule,
+ PlannedFixedSlot(start, resolved),
+ "work",
+ 100,
+ )
+ progress = FixedProgress(
+ "rule", "home", 1, FixedCursor(cursor_binding(rule.pattern, rule.policy))
+ )
+ return rule, progress, slot
+
+
+@pytest.mark.parametrize("timed", [False, True])
+def test_nominal_identity_survives_pause_but_not_pattern_or_ownership_change(timed):
+ rule, progress, slot = values(timed)
+ assert slot.command.actor_id == "system:recurrence"
+ assert slot.command.action == "recurrence.materialize"
+ assert slot.command.key == slot.key and slot.command.household_id == "home"
+ assert slot.key == replace(slot, occurrence_id="other", created_at=101).key
+ assert (
+ slot.key
+ == replace(slot, schedule=replace(rule, revision=2, state=ScheduleState.PAUSED)).key
+ )
+ assert slot.key != replace(slot, schedule=replace(rule, revision=2, pattern_revision=2)).key
+ assert slot.key != replace(slot, installation=replace(slot.installation, channel="stable")).key
+ assert slot.key != replace(slot, schedule=replace(rule, household_id="other")).key
+ assert slot.key != replace(slot, schedule=replace(rule, schedule_id="other")).key
+ assert replace(progress, cursor=replace(progress.cursor, closed=True), range_complete=True)
+ with pytest.raises(FrozenInstanceError):
+ rule.revision = 2
+
+
+@pytest.mark.parametrize(
+ "field,value",
+ [
+ ("schedule_id", ""),
+ ("household_id", ""),
+ ("definition_id", ""),
+ ("pattern", None),
+ ("policy", None),
+ ("state", "active"),
+ ("revision", True),
+ ("pattern_revision", 2),
+ ("created_at", -1),
+ ],
+)
+def test_invalid_rule_facts_are_rejected(field, value):
+ with pytest.raises(InvalidInput):
+ replace(values()[0], **{field: value})
+
+
+@pytest.mark.parametrize(
+ "field,value",
+ [
+ ("schedule_id", ""),
+ ("household_id", ""),
+ ("pattern_revision", True),
+ ("revision", 0),
+ ("cursor", None),
+ ("last_run_at", -1),
+ ("range_complete", 1),
+ ],
+)
+def test_invalid_progress_facts_are_rejected(field, value):
+ with pytest.raises(InvalidInput):
+ replace(values()[1], **{field: value})
+
+
+def test_closed_progress_requires_explicit_range_completion():
+ progress = values()[1]
+ with pytest.raises(InvalidInput):
+ replace(progress, cursor=replace(progress.cursor, closed=True))
+ assert replace(progress, last_run_at=101)
+
+
+@pytest.mark.parametrize(
+ "field,value",
+ [
+ ("occurrence_id", ""),
+ ("created_at", -1),
+ ("created_at", 99),
+ ("installation", None),
+ ("schedule", None),
+ ("planned", None),
+ ],
+)
+def test_invalid_slot_facts_are_rejected(field, value):
+ with pytest.raises(InvalidInput):
+ replace(values()[2], **{field: value})
+
+
+def test_slot_cannot_retag_its_rule_or_replace_the_resolved_policy():
+ dated = values()[2]
+ timed = values(True)[2]
+ with pytest.raises(InvalidInput):
+ replace(dated, planned=timed.planned)
+ with pytest.raises(InvalidInput):
+ replace(
+ timed, schedule=replace(timed.schedule, policy=TimePolicy("Europe/Madrid", "fixture:1"))
+ )
+
+
+def test_record_relationships_bind_scope_count_and_current_or_historical_generations():
+ rule, progress, slot = values()
+ assert progress.matches_schedule(rule) and slot.consistent_with(rule)
+ for different in [
+ replace(rule, schedule_id="other"),
+ replace(rule, household_id="other"),
+ replace(rule, pattern_revision=2, revision=2),
+ replace(rule, policy=TimePolicy("Europe/Madrid", "fixture:1")),
+ ]:
+ assert not progress.matches_schedule(different)
+ limited = replace(rule, pattern=replace(rule.pattern, count=1))
+ end = replace(
+ progress,
+ cursor=replace(
+ progress.cursor,
+ binding=cursor_binding(limited.pattern, limited.policy),
+ nominal_count=1,
+ ),
+ )
+ assert end.matches_schedule(limited)
+ assert not replace(end, cursor=replace(end.cursor, nominal_count=2)).matches_schedule(limited)
+ assert slot.consistent_with(replace(rule, revision=2, state=ScheduleState.PAUSED))
+ assert slot.consistent_with(
+ replace(rule, revision=2, pattern_revision=2, pattern=replace(rule.pattern, interval=2))
+ )
+ for different in [
+ replace(rule, schedule_id="other"),
+ replace(rule, household_id="other"),
+ replace(rule, definition_id="other"),
+ replace(rule, created_at=101),
+ replace(rule, pattern=replace(rule.pattern, interval=2)),
+ replace(rule, policy=TimePolicy("Europe/Madrid", "fixture:1")),
+ ]:
+ assert not slot.consistent_with(different)
+ assert not replace(slot, schedule=replace(rule, revision=2)).consistent_with(rule)
+ assert not replace(
+ slot, schedule=replace(rule, revision=2, pattern_revision=2)
+ ).consistent_with(rule)
+ assert not replace(
+ slot, schedule=replace(rule, revision=2, pattern_revision=2)
+ ).consistent_with(replace(rule, revision=2))
diff --git a/tests/backend/test_restore_source.py b/tests/backend/test_restore_source.py
index edcc34d..7e8852b 100644
--- a/tests/backend/test_restore_source.py
+++ b/tests/backend/test_restore_source.py
@@ -19,7 +19,7 @@ def test_bare_source_and_managed_generation_source_are_explicit_and_roundtrip(tm
bare_root.mkdir(mode=0o700)
adapter, store, _, _ = restoration_fixture(bare_root)
result = adapter.prepare("restore-bare")
- assert result.source == RestoreSource(store.installation, 7, None, None)
+ assert result.source == RestoreSource(store.installation, 8, None, None)
assert result.document().decode()["format_version"] == 2
assert PreparedRestoration.from_value(result.document().decode()) == result
root, slot, selection = generation_fixture(tmp_path)
@@ -39,7 +39,7 @@ def test_bare_source_and_managed_generation_source_are_explicit_and_roundtrip(tm
).prepare("restore-generation")
assert prepared.source.generation_id == selection.generation_id
assert prepared.source.identity_sha256 == selection.document().fingerprint
- assert prepared.source.schema_version == 7
+ assert prepared.source.schema_version == 8
assert PreparedRestoration.from_value(prepared.document().decode()) == prepared
finally:
storage.close()
diff --git a/tests/backend/test_sqlite_history.py b/tests/backend/test_sqlite_history.py
index 7c0917c..b1fd3a0 100644
--- a/tests/backend/test_sqlite_history.py
+++ b/tests/backend/test_sqlite_history.py
@@ -81,7 +81,7 @@ def test_frozen_old_schema_migrates_without_losing_existing_evidence(tmp_path, v
store = SQLiteDatabase(path, Installation("047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6", "edge"))
store.initialize()
with store._connection() as connection:
- assert connection.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION == 7
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION == 8
assert connection.execute("SELECT count(*) FROM retained_operations").fetchone()[0] == len(
before.get("retained_operations", [])
)
diff --git a/tests/backend/test_sqlite_identity.py b/tests/backend/test_sqlite_identity.py
index 53c9ad8..6a1d87b 100644
--- a/tests/backend/test_sqlite_identity.py
+++ b/tests/backend/test_sqlite_identity.py
@@ -177,7 +177,7 @@ def test_v1_upgrade_preserves_household_receipts_audit_and_pending_outbox(tmp_pa
unit.members.save(member(), 0)
unit.policies.save(policy(), 0)
with closing(sqlite3.connect(path)) as connection:
- assert connection.execute("PRAGMA user_version").fetchone()[0] == 7
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 8
assert connection.execute("PRAGMA foreign_key_check").fetchall() == []
diff --git a/tests/backend/test_sqlite_recovery.py b/tests/backend/test_sqlite_recovery.py
index 68aca01..1bfa113 100644
--- a/tests/backend/test_sqlite_recovery.py
+++ b/tests/backend/test_sqlite_recovery.py
@@ -160,7 +160,7 @@ def test_compatible_migration_preserves_identity_history_and_adds_empty_recovery
assert unit.outbox.pending()[0].event_id == "old-operation"
assert unit.recoveries.pending("home", 3000) == ()
with closing(sqlite3.connect(path)) as connection:
- assert connection.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION == 7
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION == 8
def test_failed_v2_upgrade_rolls_back_all_new_schema_and_replay_data(
diff --git a/tests/backend/test_sqlite_recurrence.py b/tests/backend/test_sqlite_recurrence.py
new file mode 100644
index 0000000..43365ac
--- /dev/null
+++ b/tests/backend/test_sqlite_recurrence.py
@@ -0,0 +1,236 @@
+"""Owned SQLite schedule records share the real task/receipt transaction boundary."""
+
+import sqlite3
+from contextlib import closing
+from dataclasses import replace
+from datetime import date
+
+import pytest
+from test_task_commands import fixture, resource
+
+from domus.application.task_commands import CreateTask
+from domus.domain.errors import Conflict, InvalidInput, StorageFailure
+from domus.domain.recurrence_planning import FixedCursor, PlannedFixedSlot, cursor_binding
+from domus.domain.recurrence_records import (
+ FixedProgress,
+ FixedSchedule,
+ ScheduledSlot,
+ ScheduleState,
+)
+from domus.domain.recurrence_rule_text import parse_fixed_pattern
+from domus.domain.recurrence_rules import NominalDate
+from domus.domain.recurrence_time import TimePolicy
+from domus.domain.storage import AuditEntry, JsonDocument, OutboxEvent, Receipt
+from domus.domain.task_values import TaskContent
+from domus.infrastructure.recurrence_time import TIMEZONE_DATA_IDENTITY
+from domus.infrastructure.sqlite import SQLiteDatabase
+
+
+def prepared(tmp_path):
+ commands, store, _, _ = fixture(tmp_path)
+ receipt = commands.create("manual", CreateTask(TaskContent("Water plants")))
+ definition_id, occurrence_id = resource(receipt)
+ rule = FixedSchedule(
+ "rule",
+ "home",
+ definition_id,
+ parse_fixed_pattern("FREQ=DAILY", NominalDate(date(2026, 1, 1))),
+ TimePolicy("UTC", TIMEZONE_DATA_IDENTITY),
+ 100,
+ )
+ progress = FixedProgress(
+ "rule", "home", 1, FixedCursor(cursor_binding(rule.pattern, rule.policy))
+ )
+ with store.write() as unit:
+ unit.recurrence.save_schedule(rule, 0)
+ unit.recurrence.save_progress(progress, 0)
+ return store, rule, progress, occurrence_id
+
+
+def slot(store, rule, occurrence_id):
+ return ScheduledSlot(
+ store.installation, rule, PlannedFixedSlot(rule.pattern.start, None), occurrence_id, 100
+ )
+
+
+def save_slot(unit, value):
+ """Repository fixture records full synthetic technical evidence, not human authorization."""
+ unit.recurrence.add_slot(value)
+ command = value.command
+ result = JsonDocument.from_value({"slot_key": value.key, "occurrence_id": value.occurrence_id})
+ unit.receipts.add(
+ Receipt(command, JsonDocument.from_value({}).fingerprint, "slot-operation", result, 100)
+ )
+ unit.audit.append(AuditEntry("slot-operation", command, JsonDocument.from_value({}), 100))
+ unit.outbox.append(OutboxEvent("slot-operation", "home", command.action, result, 100))
+
+
+def test_schedule_progress_and_slot_survive_an_independent_database_reopen(tmp_path):
+ store, rule, progress, work = prepared(tmp_path)
+ original = slot(store, rule, work)
+ with store.write() as unit:
+ save_slot(unit, original)
+ reopened = SQLiteDatabase(store.path, store.installation)
+ with reopened.read() as unit:
+ assert unit.recurrence.installation == store.installation
+ assert unit.recurrence.schedule("home", "rule") == rule
+ assert unit.recurrence.progress("home", "rule") == progress
+ assert unit.recurrence.slot("home", original.key) == original
+ assert unit.recurrence.schedules("home") == (rule,)
+ assert unit.recurrence.slots("home", "rule") == (original,)
+ assert unit.receipts.get(original.command).result.decode()["occurrence_id"] == work
+ assert unit.recurrence.schedule("other", "rule") is None
+ assert unit.recurrence.progress("other", "rule") is None
+ assert unit.recurrence.slot("other", original.key) is None
+
+
+def test_rule_progress_slot_and_task_work_roll_back_as_one_transaction(tmp_path):
+ store, rule, progress, work = prepared(tmp_path)
+ original = replace(slot(store, rule, "generated-work"), created_at=110)
+ with pytest.raises(RuntimeError, match="injected"):
+ with store.write() as unit:
+ generated = replace(
+ unit.tasks.occurrence("home", work), occurrence_id="generated-work", created_at=110
+ )
+ unit.tasks.save_occurrence(generated, 0)
+ unit.recurrence.save_schedule(replace(rule, state=ScheduleState.PAUSED, revision=2), 1)
+ unit.recurrence.save_progress(replace(progress, revision=2, last_run_at=110), 1)
+ save_slot(unit, original)
+ raise RuntimeError("injected")
+ with store.read() as unit:
+ assert unit.recurrence.schedule("home", "rule") == rule
+ assert unit.recurrence.progress("home", "rule") == progress
+ assert unit.recurrence.slot("home", original.key) is None
+ assert unit.receipts.get(original.command) is None
+ assert unit.tasks.occurrence("home", "generated-work") is None
+ assert unit.tasks.occurrence("home", work) is not None
+
+
+def test_rule_and_progress_compare_and_swap_preserve_immutable_scope_and_creation(tmp_path):
+ store, rule, progress, _ = prepared(tmp_path)
+ with store.write() as unit:
+ changed = replace(rule, state=ScheduleState.PAUSED, revision=2)
+ unit.recurrence.save_schedule(changed, 1)
+ unit.recurrence.save_progress(replace(progress, revision=2, last_run_at=110), 1)
+ for value, expected in [
+ (rule, 0),
+ (changed, 1),
+ (replace(changed, created_at=101, revision=3), 2),
+ (replace(changed, pattern_revision=2, revision=3), 2),
+ ]:
+ with pytest.raises(Conflict):
+ unit.recurrence.save_schedule(value, expected)
+ with pytest.raises(Conflict):
+ unit.recurrence.save_progress(progress, 0)
+ with pytest.raises(Conflict):
+ unit.recurrence.save_progress(replace(progress, revision=4), 3)
+ with pytest.raises(InvalidInput):
+ unit.recurrence.save_schedule(changed, True)
+ with pytest.raises(InvalidInput):
+ unit.recurrence.save_progress(progress, -1)
+
+
+def test_slot_identity_is_unique_and_requires_its_exact_task_and_receipt(tmp_path):
+ store, rule, _, work = prepared(tmp_path)
+ original = slot(store, rule, work)
+ with pytest.raises(StorageFailure):
+ with store.write() as unit:
+ unit.recurrence.add_slot(original)
+ with store.write() as unit:
+ save_slot(unit, original)
+ with pytest.raises(Conflict):
+ with store.write() as unit:
+ unit.recurrence.add_slot(original)
+ with store.read() as unit:
+ assert len(unit.recurrence.slots("home", "rule")) == 1
+
+
+@pytest.mark.parametrize("kind", ["rule", "progress", "slot"])
+def test_corrupt_or_future_stored_documents_never_gain_default_meanings(tmp_path, kind):
+ store, rule, _, work = prepared(tmp_path)
+ original = slot(store, rule, work)
+ with store.write() as unit:
+ save_slot(unit, original)
+ table = {
+ "rule": "recurrence_rules",
+ "progress": "recurrence_progress",
+ "slot": "recurrence_slots",
+ }[kind]
+ with store._connection() as connection:
+ connection.execute(f"UPDATE {table} SET document='{{}}'")
+ with store.read() as unit:
+ query = {
+ "rule": lambda: unit.recurrence.schedule("home", "rule"),
+ "progress": lambda: unit.recurrence.progress("home", "rule"),
+ "slot": lambda: unit.recurrence.slot("home", original.key),
+ }[kind]
+ with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
+ query()
+
+
+def test_scoped_lists_are_exact_bounded_keysets_and_never_interpolate_caller_sql(tmp_path):
+ store, rule, _, _ = prepared(tmp_path)
+ with store.read() as unit:
+ assert unit.recurrence.schedules("home", after="rule") == ()
+ assert unit.recurrence.schedules("home", state=ScheduleState.ACTIVE) == (rule,)
+ assert unit.recurrence.schedules("home", state=ScheduleState.PAUSED) == ()
+ assert unit.recurrence.slots("other", "rule") == ()
+ for bad in [0, 102, True, 1.5]:
+ with pytest.raises(InvalidInput):
+ unit.recurrence.schedules("home", limit=bad)
+ with pytest.raises(InvalidInput):
+ unit.recurrence.schedules("home", state="active")
+ with pytest.raises(InvalidInput):
+ unit.recurrence.schedules("home", after="x' OR 1=1")
+
+
+def source7(tmp_path):
+ from pathlib import Path
+
+ from domus.domain.storage import Installation
+
+ path = tmp_path / "schema7.sqlite3"
+ with closing(sqlite3.connect(path, autocommit=True)) as connection:
+ connection.executescript((Path(__file__).parents[1] / "fixtures/sqlite/v7.sql").read_text())
+ identity = connection.execute("SELECT instance_id,channel FROM installation").fetchone()
+ return SQLiteDatabase(path, Installation(*identity))
+
+
+def test_normal_transactions_refuse_schema7_until_explicit_atomic_upgrade(tmp_path):
+ from domus.domain.errors import DatabaseRejected
+
+ store = source7(tmp_path)
+ before = store.path.read_bytes()
+ for transaction in (store.read, store.write):
+ with pytest.raises(DatabaseRejected, match="unsupported_schema"):
+ with transaction():
+ pytest.fail("Previous schema must not yield a current unit of work")
+ assert store.path.read_bytes() == before
+ with store._connection() as connection:
+ original = tuple(connection.execute("SELECT * FROM receipts ORDER BY operation_id"))
+ store.initialize(require_existing=True)
+ with store.read() as unit:
+ assert unit.recurrence.schedules("home") == ()
+ with store._connection() as connection:
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 8
+ assert tuple(connection.execute("SELECT * FROM receipts ORDER BY operation_id")) == original
+ assert connection.execute("PRAGMA foreign_key_check").fetchall() == []
+
+
+@pytest.mark.parametrize("field", ["actor_id", "action", "key"])
+def test_current_slot_requires_exact_original_evidence_namespace(tmp_path, field):
+ store, rule, _, work = prepared(tmp_path)
+ value = slot(store, rule, work)
+ wrong = replace(value.command, **{field: "other"})
+ result = JsonDocument.from_value({"slot_key": value.key, "occurrence_id": work})
+ with pytest.raises(StorageFailure):
+ with store.write() as unit:
+ unit.recurrence.add_slot(value)
+ unit.receipts.add(
+ Receipt(wrong, JsonDocument.from_value({}).fingerprint, "wrong-proof", result, 100)
+ )
+ unit.audit.append(AuditEntry("wrong-proof", wrong, JsonDocument.from_value({}), 100))
+ unit.outbox.append(OutboxEvent("wrong-proof", "home", wrong.action, result, 100))
+ with store.read() as unit:
+ assert unit.recurrence.slot("home", value.key) is None
+ assert unit.receipts.get(wrong) is None
diff --git a/tests/backend/test_sqlite_recurrence_integrity.py b/tests/backend/test_sqlite_recurrence_integrity.py
new file mode 100644
index 0000000..9bbf1e9
--- /dev/null
+++ b/tests/backend/test_sqlite_recurrence_integrity.py
@@ -0,0 +1,360 @@
+"""Real corrupted owned records must fail repository and checkpoint verification safely."""
+
+import json
+from concurrent.futures import ThreadPoolExecutor
+from dataclasses import replace
+from threading import Barrier
+
+import pytest
+from test_sqlite_recurrence import prepared, save_slot, slot, source7
+from test_sqlite_snapshots import Clock
+
+from domus.domain.errors import Conflict, DatabaseRejected, StorageFailure
+from domus.domain.recurrence_planning import FixedCursor, cursor_binding
+from domus.infrastructure.sqlite import SQLiteDatabase
+from domus.infrastructure.sqlite_recurrence_integrity import verify_recurrence_records
+from domus.infrastructure.sqlite_recurrence_records import (
+ progress_document,
+ schedule_document,
+ slot_document,
+)
+from domus.infrastructure.sqlite_snapshots import SQLiteSnapshots
+
+
+def committed(tmp_path):
+ store, rule, progress, work = prepared(tmp_path)
+ value = slot(store, rule, work)
+ with store.write() as unit:
+ save_slot(unit, value)
+ return store, rule, progress, value
+
+
+def test_checkpoint_verifies_and_reopens_real_rule_progress_slot_and_original_receipt(tmp_path):
+ store, rule, progress, value = committed(tmp_path)
+ snapshots = SQLiteSnapshots(store, tmp_path / "snapshot", store.installation, Clock())
+ before = store.path.read_bytes()
+ checkpoint = snapshots.create("recurrence")
+ assert checkpoint.schema_version == 8 and snapshots.inspect() == checkpoint
+ assert store.path.read_bytes() == before
+ reopened = SQLiteDatabase(tmp_path / "snapshot/domus.sqlite3", store.installation)
+ with reopened.read() as unit:
+ assert unit.recurrence.schedule("home", "rule") == rule
+ assert unit.recurrence.progress("home", "rule") == progress
+ assert unit.recurrence.slot("home", value.key) == value
+ assert unit.receipts.get(value.command).result.decode()["slot_key"] == value.key
+
+
+@pytest.mark.parametrize(
+ "table,column,value,kind",
+ [
+ ("recurrence_rules", "state", "paused", "rule"),
+ ("recurrence_progress", "revision", 2, "progress"),
+ ("recurrence_slots", "nominal_value", "2026-01-02", "slot"),
+ ],
+)
+def test_header_body_mismatch_is_rejected_by_independent_repository_and_checkpoint(
+ tmp_path, table, column, value, kind
+):
+ store, _, _, original = committed(tmp_path)
+ with store._connection() as connection:
+ connection.execute(f"UPDATE {table} SET {column}=?", (value,))
+ with store.read() as unit:
+ query = {
+ "rule": lambda: unit.recurrence.schedule("home", "rule"),
+ "progress": lambda: unit.recurrence.progress("home", "rule"),
+ "slot": lambda: unit.recurrence.slot("home", original.key),
+ }[kind]
+ with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
+ query()
+ with pytest.raises(DatabaseRejected, match="invalid_snapshot_records"):
+ SQLiteSnapshots(store, tmp_path / "snapshot", store.installation, Clock()).create("bad")
+
+
+def test_record_with_self_consistent_foreign_installation_cannot_be_read_as_local(tmp_path):
+ store, _, _, original = committed(tmp_path)
+ foreign = replace(original, installation=replace(original.installation, channel="stable"))
+ with store._connection() as connection:
+ connection.execute(
+ "UPDATE recurrence_slots SET slot_key=?,document=?",
+ (foreign.key, slot_document(foreign).encoded),
+ )
+ with store.read() as unit:
+ with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
+ unit.recurrence.slot("home", foreign.key)
+
+
+@pytest.mark.parametrize(
+ "change",
+ [
+ "missing-progress",
+ "pattern-revision",
+ "binding",
+ "missing-receipt",
+ "wrong-slot",
+ "wrong-occurrence",
+ ],
+)
+def test_semantic_probe_rejects_missing_source_binding_or_original_effect(tmp_path, change):
+ store, _, progress, value = committed(tmp_path)
+ with store._connection() as connection:
+ if change == "missing-progress":
+ connection.execute("DELETE FROM recurrence_progress")
+ elif change == "pattern-revision":
+ changed = replace(progress, pattern_revision=2)
+ connection.execute(
+ "UPDATE recurrence_progress SET pattern_revision=2,document=?",
+ (progress_document(changed).encoded,),
+ )
+ elif change == "binding":
+ changed = replace(progress, cursor=replace(progress.cursor, binding="b" * 64))
+ connection.execute(
+ "UPDATE recurrence_progress SET document=?", (progress_document(changed).encoded,)
+ )
+ elif change == "missing-receipt":
+ connection.execute("DELETE FROM receipts WHERE command_key=?", (value.key,))
+ else:
+ result = {"slot_key": value.key, "occurrence_id": value.occurrence_id}
+ result["slot_key" if change == "wrong-slot" else "occurrence_id"] = "other"
+ connection.execute(
+ "UPDATE receipts SET result=? WHERE command_key=?", (json.dumps(result), value.key)
+ )
+ with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
+ verify_recurrence_records(connection, store.installation)
+
+
+def test_stale_or_foreign_plans_cannot_write_slots_or_progress(tmp_path):
+ store, rule, progress, work = prepared(tmp_path)
+ original = slot(store, rule, work)
+ invalid = [
+ replace(original, installation=replace(store.installation, channel="stable")),
+ replace(original, schedule=replace(rule, schedule_id="missing")),
+ replace(original, schedule=replace(rule, definition_id="other")),
+ ]
+ with store.write() as unit:
+ for value in invalid:
+ with pytest.raises(Conflict):
+ unit.recurrence.add_slot(value)
+ for value, expected in [
+ (replace(progress, revision=3), 1),
+ (replace(progress, schedule_id="missing"), 0),
+ (replace(progress, pattern_revision=2, revision=2), 1),
+ (replace(progress, cursor=replace(progress.cursor, binding="b" * 64)), 0),
+ ]:
+ with pytest.raises(Conflict):
+ unit.recurrence.save_progress(value, expected)
+ with pytest.raises(Conflict):
+ unit.recurrence.save_schedule(replace(rule, revision=3), 1)
+ with pytest.raises(Conflict):
+ unit.recurrence.save_schedule(replace(rule, definition_id="other", revision=2), 1)
+ assert unit.recurrence.slots("home", "rule", after=original.key) == ()
+
+
+@pytest.mark.parametrize("failure_at", [0, -1])
+def test_schema7_ddl_failure_rolls_back_tables_version_and_original_business_records(
+ tmp_path, monkeypatch, failure_at
+):
+ import domus.infrastructure.sqlite as implementation
+
+ store = source7(tmp_path)
+ with store._connection() as connection:
+ original = tuple(
+ tuple(row) for row in connection.execute("SELECT * FROM receipts ORDER BY operation_id")
+ )
+ statements = list(implementation.RECURRENCE_STATEMENTS)
+ statements.insert(failure_at, "INVALID RECURRENCE DDL")
+ monkeypatch.setattr(implementation, "RECURRENCE_STATEMENTS", tuple(statements))
+ with pytest.raises(StorageFailure):
+ store.initialize(require_existing=True)
+ with store._connection() as connection:
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 7
+ assert (
+ tuple(
+ tuple(row)
+ for row in connection.execute("SELECT * FROM receipts ORDER BY operation_id")
+ )
+ == original
+ )
+ assert (
+ connection.execute(
+ "SELECT name FROM sqlite_master WHERE name LIKE 'recurrence_%'"
+ ).fetchall()
+ == []
+ )
+
+
+def test_pattern_edit_requires_matching_progress_and_preserves_existing_slot_snapshot(tmp_path):
+ store, rule, progress, value = committed(tmp_path)
+ changed = replace(
+ rule, revision=2, pattern_revision=2, pattern=replace(rule.pattern, interval=2)
+ )
+ with pytest.raises(StorageFailure):
+ with store.write() as unit:
+ unit.recurrence.save_schedule(changed, 1)
+ with store.read() as unit:
+ assert unit.recurrence.schedule("home", "rule") == rule
+ assert unit.recurrence.progress("home", "rule") == progress
+ next_progress = replace(
+ progress,
+ revision=2,
+ pattern_revision=2,
+ cursor=FixedCursor(cursor_binding(changed.pattern, changed.policy)),
+ )
+ with store.write() as unit:
+ unit.recurrence.save_schedule(changed, 1)
+ unit.recurrence.save_progress(next_progress, 1)
+ with store.read() as unit:
+ assert unit.recurrence.schedule("home", "rule") == changed
+ assert unit.recurrence.progress("home", "rule") == next_progress
+ assert unit.recurrence.slot("home", value.key) == value
+ assert unit.recurrence.slots("home", "rule", after=value.key) == ()
+ assert (
+ SQLiteSnapshots(store, tmp_path / "edited", store.installation, Clock())
+ .create("edited")
+ .schema_version
+ == 8
+ )
+
+
+def test_rule_list_limit_and_keyset_return_only_the_requested_owned_rows(tmp_path):
+ store, rule, progress, _ = prepared(tmp_path)
+ with store.write() as unit:
+ for key in ["a", "z"]:
+ unit.recurrence.save_schedule(replace(rule, schedule_id=key), 0)
+ unit.recurrence.save_progress(replace(progress, schedule_id=key), 0)
+ with store.read() as unit:
+ first = unit.recurrence.schedules("home", limit=1)
+ assert [value.schedule_id for value in first] == ["a"]
+ assert [
+ value.schedule_id for value in unit.recurrence.schedules("home", after="a", limit=1)
+ ] == ["rule"]
+ assert [
+ value.schedule_id
+ for value in unit.recurrence.schedules("home", after="rule", limit=101)
+ ] == ["z"]
+ assert unit.recurrence.schedules("other", limit=101) == ()
+
+
+@pytest.mark.parametrize("operation", ["rule", "slot"])
+def test_independent_concurrent_writers_commit_one_revision_or_one_slot_with_its_proof(
+ tmp_path, operation
+):
+ store, rule, _, work = prepared(tmp_path)
+ original = slot(store, rule, work)
+ barrier = Barrier(2, timeout=5)
+
+ def write():
+ independent = SQLiteDatabase(store.path, store.installation, busy_timeout=5)
+ with independent.read() as unit:
+ observed = unit.recurrence.schedule("home", "rule")
+ barrier.wait()
+ try:
+ with independent.write() as unit:
+ if operation == "rule":
+ unit.recurrence.save_schedule(replace(observed, revision=2), 1)
+ else:
+ save_slot(unit, original)
+ return "committed"
+ except Conflict:
+ return "conflict"
+
+ with ThreadPoolExecutor(max_workers=2) as pool:
+ assert sorted(pool.map(lambda _: write(), range(2))) == ["committed", "conflict"]
+ with store.read() as unit:
+ if operation == "rule":
+ assert unit.recurrence.schedule("home", "rule").revision == 2
+ else:
+ assert unit.recurrence.slots("home", "rule") == (original,)
+ assert unit.receipts.get(original.command).result.decode()["occurrence_id"] == work
+ assert (
+ len(
+ [
+ value
+ for value in unit.audit.entries("home")
+ if value.command == original.command
+ ]
+ )
+ == 1
+ )
+ assert (
+ len(
+ [
+ value
+ for value in unit.outbox.pending()
+ if value.event_type == original.command.action
+ ]
+ )
+ == 1
+ )
+
+
+def test_bounded_count_refuses_impossible_progress_before_its_transaction_commits(tmp_path):
+ store, rule, progress, _ = prepared(tmp_path)
+ limited = replace(rule, revision=2, pattern_revision=2, pattern=replace(rule.pattern, count=1))
+ invalid = replace(
+ progress,
+ revision=2,
+ pattern_revision=2,
+ cursor=replace(
+ progress.cursor,
+ binding=cursor_binding(limited.pattern, limited.policy),
+ nominal_count=2,
+ ),
+ )
+ with pytest.raises(Conflict, match="recurrence_cursor_mismatch"):
+ with store.write() as unit:
+ unit.recurrence.save_schedule(limited, 1)
+ unit.recurrence.save_progress(invalid, 1)
+ with store.read() as unit:
+ assert unit.recurrence.schedule("home", "rule") == rule
+
+
+@pytest.mark.parametrize("change", ["count", "future-slot", "different-pattern", "missing-rule"])
+def test_impossible_source_relationships_cannot_pass_semantic_verification(tmp_path, change):
+ store, rule, progress, value = committed(tmp_path)
+ with store._connection() as connection:
+ if change == "missing-rule":
+ connection.execute("DELETE FROM recurrence_rules")
+ elif change == "count":
+ limited = replace(rule, pattern=replace(rule.pattern, count=1))
+ invalid = replace(
+ progress,
+ cursor=replace(
+ progress.cursor,
+ binding=cursor_binding(limited.pattern, limited.policy),
+ nominal_count=2,
+ ),
+ )
+ connection.execute(
+ "UPDATE recurrence_rules SET document=?", (schedule_document(limited).encoded,)
+ )
+ connection.execute(
+ "UPDATE recurrence_progress SET document=?", (progress_document(invalid).encoded,)
+ )
+ connection.execute(
+ "UPDATE recurrence_slots SET document=?",
+ (slot_document(replace(value, schedule=limited)).encoded,),
+ )
+ else:
+ source = (
+ replace(rule, revision=2, pattern_revision=2)
+ if change == "future-slot"
+ else replace(rule, pattern=replace(rule.pattern, interval=2))
+ )
+ changed = replace(value, schedule=source)
+ result = json.dumps({"slot_key": changed.key, "occurrence_id": changed.occurrence_id})
+ connection.execute(
+ "UPDATE recurrence_slots SET slot_key=?,pattern_revision=?,document=?",
+ (changed.key, source.pattern_revision, slot_document(changed).encoded),
+ )
+ connection.execute(
+ "UPDATE receipts SET command_key=?,result=? WHERE command_key=?",
+ (changed.key, result, value.key),
+ )
+ connection.execute(
+ "UPDATE audit SET command_key=? WHERE command_key=?", (changed.key, value.key)
+ )
+ connection.execute(
+ "UPDATE outbox SET payload=? WHERE event_type='recurrence.materialize'", (result,)
+ )
+ with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
+ verify_recurrence_records(connection, store.installation)
diff --git a/tests/backend/test_sqlite_recurrence_records.py b/tests/backend/test_sqlite_recurrence_records.py
new file mode 100644
index 0000000..bebcea7
--- /dev/null
+++ b/tests/backend/test_sqlite_recurrence_records.py
@@ -0,0 +1,123 @@
+"""Closed persistence codecs preserve every nominal/time-policy fact and reject corrupt bytes."""
+
+import json
+from dataclasses import replace
+from datetime import date
+
+import pytest
+from test_recurrence_records import values
+
+from domus.domain.errors import StorageFailure
+from domus.domain.recurrence_planning import cursor_binding
+from domus.domain.recurrence_rules import ByDay, FixedPattern, Frequency, NominalDate, Weekday
+from domus.domain.recurrence_time import MAX_INSTANT_MS
+from domus.infrastructure.sqlite_recurrence_records import (
+ progress_document,
+ read_progress,
+ read_schedule,
+ read_slot,
+ schedule_document,
+ slot_document,
+)
+
+CODECS = [
+ (0, schedule_document, read_schedule),
+ (1, progress_document, read_progress),
+ (2, slot_document, read_slot),
+]
+
+
+@pytest.mark.parametrize("timed", [False, True])
+def test_closed_codecs_round_trip_every_pattern_selector_and_time_snapshot(timed):
+ rule, progress, slot = values(timed)
+ pattern = FixedPattern(
+ rule.pattern.start,
+ Frequency.YEARLY,
+ 2,
+ Weekday.SU,
+ (ByDay(Weekday.MO, 1), ByDay(Weekday.FR)),
+ (1, 12),
+ (-1, 1),
+ (-1, 1),
+ until=MAX_INSTANT_MS if timed else NominalDate(date(2027, 1, 1)),
+ )
+ rule = replace(rule, pattern=pattern)
+ progress = replace(
+ progress,
+ cursor=replace(progress.cursor, binding=cursor_binding(pattern, rule.policy)),
+ last_run_at=101,
+ )
+ slot = replace(slot, schedule=rule)
+ for value, encode, decode in [
+ (rule, schedule_document, read_schedule),
+ (progress, progress_document, read_progress),
+ (slot, slot_document, read_slot),
+ ]:
+ document = encode(value)
+ assert decode(document.encoded) == value
+ assert encode(decode(document.encoded)) == document
+ limited = replace(rule, pattern=replace(pattern, until=None, count=5))
+ assert read_schedule(schedule_document(limited).encoded) == limited
+
+
+@pytest.mark.parametrize("index,encode,decode", CODECS)
+@pytest.mark.parametrize("field,value", [("version", True), ("version", 2), ("unexpected", None)])
+def test_unknown_or_extra_record_fields_never_receive_defaults(index, encode, decode, field, value):
+ row = encode(values()[index]).decode()
+ row[field] = value
+ with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
+ decode(json.dumps(row))
+
+
+@pytest.mark.parametrize("index,encode,decode", CODECS)
+@pytest.mark.parametrize(
+ "document",
+ ["null", "[]", "{", '{"version":1,"version":1}', '{"payload":"' + "a" * 65536 + '"}'],
+)
+def test_nonobjects_duplicate_keys_and_oversized_input_fail_safely(index, encode, decode, document):
+ with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
+ decode(document)
+
+
+@pytest.mark.parametrize(
+ "timed,local",
+ [
+ (False, None),
+ (False, "2026-W01-4"),
+ (False, "20260101"),
+ (False, "2026-01-01T00:00:00"),
+ (True, "2026-01-01 12:00:00"),
+ (True, "2026-01-01T12:00:00+00:00"),
+ (True, "2026-01-01T12:00:00.001"),
+ ],
+)
+def test_stored_nominal_values_require_canonical_tagged_date_or_naive_time(timed, local):
+ row = schedule_document(values(timed)[0]).decode()
+ row["pattern"]["start"]["local"] = local
+ with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
+ read_schedule(json.dumps(row))
+
+
+@pytest.mark.parametrize(
+ "field,value",
+ [
+ ("by_day", None),
+ ("by_month", "1"),
+ ("frequency", "HOURLY"),
+ ("interval", True),
+ ("start", {"kind": "other", "local": "2026-01-01"}),
+ ("until", {"kind": "date", "local": "2025-01-01", "unknown": True}),
+ ],
+)
+def test_corrupt_nested_pattern_values_are_not_coerced(field, value):
+ row = schedule_document(values()[0]).decode()
+ row["pattern"][field] = value
+ with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
+ read_schedule(json.dumps(row))
+
+
+def test_a_resolved_time_cannot_be_retagged_as_a_date():
+ row = slot_document(values(True)[2]).decode()
+ row["planned"]["resolved"]["nominal"] = {"kind": "date", "local": "2026-01-01"}
+ with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
+ read_slot(json.dumps(row))
diff --git a/tests/backend/test_sqlite_snapshots.py b/tests/backend/test_sqlite_snapshots.py
index 7c70885..d158ee5 100644
--- a/tests/backend/test_sqlite_snapshots.py
+++ b/tests/backend/test_sqlite_snapshots.py
@@ -37,7 +37,7 @@ def test_snapshot_contains_verified_private_identity_checkpoint_and_all_atomic_r
before = store.path.read_bytes()
result = snapshots.create("snapshot-1")
assert (
- result.schema_version == 7
+ result.schema_version == 8
and result.captured_at == 2000
and result.last_committed_at == 100
)
diff --git a/tests/backend/test_sqlite_task_integrity.py b/tests/backend/test_sqlite_task_integrity.py
index 11934d1..0a1fdc9 100644
--- a/tests/backend/test_sqlite_task_integrity.py
+++ b/tests/backend/test_sqlite_task_integrity.py
@@ -158,7 +158,7 @@ def test_actual_unit_snapshot_and_reopen_keep_task_cycles_and_one_atomic_receipt
before = store.path.read_bytes()
snapshots = SQLiteSnapshots(store, tmp_path / "snapshot", store.installation, Clock())
checkpoint = snapshots.create("tasks")
- assert checkpoint.schema_version == 7 and snapshots.inspect() == checkpoint
+ assert checkpoint.schema_version == 8 and snapshots.inspect() == checkpoint
assert store.path.read_bytes() == before
copy = SQLiteDatabase(tmp_path / "snapshot/domus.sqlite3", store.installation)
with copy.read() as unit:
@@ -433,5 +433,5 @@ def test_schema4_upgrade_failure_preserves_the_exact_old_database_and_history(
assert unit.tasks.policy("home") is None and unit.tasks.definitions("home") == ()
assert unit.receipts.get_operation("home", "human", "operation-a").historical
with store._connection() as connection:
- assert connection.execute("PRAGMA user_version").fetchone()[0] == 7
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 8
assert connection.execute("SELECT count(*) FROM retained_operations").fetchone()[0] == 2
diff --git a/tests/backend/test_sqlite_task_materials.py b/tests/backend/test_sqlite_task_materials.py
index b0973a2..6ab10a3 100644
--- a/tests/backend/test_sqlite_task_materials.py
+++ b/tests/backend/test_sqlite_task_materials.py
@@ -58,7 +58,7 @@ def test_original_schema5_migration_preserves_history_and_legacy_request_fingerp
store = SQLiteDatabase(path, Installation("047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6", "edge"))
store.initialize(require_existing=True)
with store._connection() as connection:
- assert connection.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION == 7
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION == 8
for table, rows in before.items():
assert [tuple(row) for row in connection.execute(f"SELECT * FROM {table}")] == rows
with store.read() as unit:
@@ -134,7 +134,7 @@ def test_real_file_material_cas_preserves_source_pending_and_confirmed_totals(tm
checkpoint = SQLiteSnapshots(store, tmp_path / "snapshot", store.installation, Clock()).create(
"materials"
)
- assert checkpoint.schema_version == 7
+ assert checkpoint.schema_version == 8
copy = SQLiteDatabase(tmp_path / "snapshot/domus.sqlite3", store.installation)
with copy.read() as unit:
assert unit.task_materials.get("home", "intent") == final
@@ -441,7 +441,7 @@ def test_schema5_real_offline_activation_upgrades_only_target_and_keeps_original
SQLiteSnapshots(selected.database, tmp_path / "new-snapshot", installation, Clock())
.create("new")
.schema_version
- == 7
+ == 8
)
finally:
selected.close()
diff --git a/tests/backend/test_sqlite_task_reviews.py b/tests/backend/test_sqlite_task_reviews.py
index a6af098..87a435c 100644
--- a/tests/backend/test_sqlite_task_reviews.py
+++ b/tests/backend/test_sqlite_task_reviews.py
@@ -36,7 +36,7 @@ def rows(connection):
}
-def test_original_schema6_migration_adds_only_indexes_and_projects_existing_cause(tmp_path):
+def test_original_schema6_migration_preserves_rows_and_projects_existing_cause(tmp_path):
store = frozen(tmp_path / "old.sqlite3")
with store._connection() as connection:
original = rows(connection)
@@ -44,8 +44,12 @@ def test_original_schema6_migration_adds_only_indexes_and_projects_existing_caus
pytest.fail("Old storage requires explicit migration")
store.initialize(require_existing=True)
with store._connection() as connection:
- assert rows(connection) == original
- assert connection.execute("PRAGMA user_version").fetchone()[0] == 7
+ upgraded = rows(connection)
+ added = {"recurrence_rules", "recurrence_progress", "recurrence_slots"}
+ assert set(upgraded) == set(original) | added
+ assert all(upgraded[table] == values for table, values in original.items())
+ assert all(upgraded[table] == [] for table in added)
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 8
service = queries(store, HumanIdentity("ha_admin", True, True, False))
detail = service.occurrence("identity-15")
assert detail.dependency_review_required and len(detail.dependency_reviews.items) == 1
@@ -315,7 +319,7 @@ def test_original_schema6_checkpoint_activation_migrates_only_the_prepared_targe
SQLiteSnapshots(selected.database, tmp_path / "new-checkpoint", INSTALLATION, Clock())
.create("new")
.schema_version
- == 7
+ == 8
)
finally:
selected.close()
diff --git a/tests/backend/test_task_restoration.py b/tests/backend/test_task_restoration.py
index d5628d2..16d51fb 100644
--- a/tests/backend/test_task_restoration.py
+++ b/tests/backend/test_task_restoration.py
@@ -32,7 +32,7 @@ def test_restore_keeps_original_task_state_and_retains_later_completion_as_proce
)
adapter = SQLiteRestorations(store, archive, target, store.installation, Clock(), checkpoint)
prepared = adapter.prepare("restore-tasks")
- assert prepared.source.schema_version == checkpoint.schema_version == 7
+ assert prepared.source.schema_version == checkpoint.schema_version == 8
assert prepared.operation_count == 3 and prepared.current_operations == 2
assert adapter.inspect() == prepared and store.path.read_bytes() == source_bytes
assert (archive / "domus.sqlite3").read_bytes() == checkpoint_bytes
diff --git a/tests/containers/restore_fixture.py b/tests/containers/restore_fixture.py
index 6f9310e..84aa1eb 100644
--- a/tests/containers/restore_fixture.py
+++ b/tests/containers/restore_fixture.py
@@ -109,7 +109,7 @@ def terminate(source, target, **kwargs):
else:
raise AssertionError("Unknown fixture operation")
with storage.database._connection() as connection:
- assert connection.execute("PRAGMA user_version").fetchone()[0] == 7
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 8
private_layout()
print(
json.dumps(
diff --git a/tests/containers/test_app_compatibility.py b/tests/containers/test_app_compatibility.py
index 2b0c103..38a0cd1 100644
--- a/tests/containers/test_app_compatibility.py
+++ b/tests/containers/test_app_compatibility.py
@@ -33,6 +33,11 @@ def previous_image(request):
"faa1654a5c9cddb562bfcd4c02d4cebfeb78e9f5",
"0.0.0-edge.3",
),
+ "edge5": (
+ "DOMUS_SCHEMA7_IMAGE",
+ "271a3a07feb1535f0a5ec8b55abb3de9aac049a9",
+ "0.0.0-edge.5",
+ ),
}[version]
value = os.environ.get(variable)
assert value, "Set " + variable + " to the reviewed immutable " + label + " image"
@@ -85,7 +90,7 @@ def refuse_old_image(image, previous, volume):
@pytest.mark.parametrize("restore_runtime", ["native", "standalone"], indirect=True)
-@pytest.mark.parametrize("previous_image", ["edge2", "edge3"], indirect=True)
+@pytest.mark.parametrize("previous_image", ["edge2", "edge3", "edge5"], indirect=True)
def test_actual_older_image_refuses_selected_generation_without_changing_any_file(
image, previous_image, restore_runtime
):
@@ -112,7 +117,9 @@ def test_actual_older_image_refuses_selected_generation_without_changing_any_fil
@pytest.mark.parametrize(
- "previous_image,old_schema", [("edge2", 3), ("edge3", 4)], indirect=["previous_image"]
+ "previous_image,old_schema",
+ [("edge2", 3), ("edge3", 4), ("edge5", 7)],
+ indirect=["previous_image"],
)
def test_real_older_image_upgrades_replays_and_restores_its_pre_upgrade_snapshot(
image, previous_image, data_volume, old_schema
@@ -207,7 +214,7 @@ def test_real_older_image_upgrades_replays_and_restores_its_pre_upgrade_snapshot
"/data/snapshots/current",
)
)
- assert current["snapshot"]["schema_version"] == 7
+ assert current["snapshot"]["schema_version"] == 8
assert current["snapshot"]["instance_id"] == instance_id
inspected = successful(
console(
diff --git a/tests/containers/test_app_image.py b/tests/containers/test_app_image.py
index 92b39dc..61c0e02 100644
--- a/tests/containers/test_app_image.py
+++ b/tests/containers/test_app_image.py
@@ -32,13 +32,10 @@ def test_image_pins_the_private_launcher_and_installs_a_nonroot_readable_runtime
assert config["User"] == "0:0"
assert config["Entrypoint"] == ["/app/.venv/bin/domus-ha-app", "--channel", "edge"]
assert config["Labels"]["io.hass.type"] == "app"
- expected = json.loads(
- (
- Path(__file__).resolve().parents[2] / "docs/evidence/household-entry-interface.json"
- ).read_text()
- )["imageCandidate"]
- assert config["Labels"]["org.opencontainers.image.revision"] == expected["sourceCommit"]
- assert config["Labels"]["io.hass.version"] == expected["version"]
+ expected = os.environ.get("DOMUS_TEST_SOURCE_COMMIT")
+ assert expected, "Set DOMUS_TEST_SOURCE_COMMIT to the exact checked-out CI revision"
+ assert config["Labels"]["org.opencontainers.image.revision"] == expected
+ assert config["Labels"]["io.hass.version"] == "ci-current"
assert not any(value.startswith("SUPERVISOR_TOKEN=") for value in config["Env"])
probe = docker(
"run",
@@ -258,7 +255,7 @@ def snapshot_roundtrip(name: str, instance_id: str) -> None:
assert created.returncode == 0, created.stderr
original = json.loads(created.stdout)
assert original["state"] == "created"
- assert original["snapshot"]["schema_version"] == 7
+ assert original["snapshot"]["schema_version"] == 8
assert original["snapshot"]["last_committed_at"] == 100
inspected = docker(
"exec",
@@ -534,7 +531,7 @@ def test_packaged_console_restore_restart_and_latest_history_rollback(
)
)
assert activated["state"] == "active"
- assert activated["activation"]["rollback_snapshot"]["schema_version"] == 7
+ assert activated["activation"]["rollback_snapshot"]["schema_version"] == 8
assert docker("start", name).returncode == 0
ready(name, standalone=mode == "standalone")
active = successful(packaged_fixture(image, volume, "active", delivery))
diff --git a/tests/containers/test_recurrence_image.py b/tests/containers/test_recurrence_image.py
new file mode 100644
index 0000000..affe9fe
--- /dev/null
+++ b/tests/containers/test_recurrence_image.py
@@ -0,0 +1,53 @@
+"""Both real CPU images must execute the newly packaged recurrence and pinned timezone code."""
+
+from test_app_image import docker
+from test_app_image import image as image
+
+
+def test_current_image_executes_recurrence_planning_and_closed_record_codecs(image):
+ result = docker(
+ "run",
+ "--rm",
+ "--network",
+ "none",
+ "--user",
+ "65532:65532",
+ "--entrypoint",
+ "/app/.venv/bin/python",
+ image,
+ "-c",
+ """
+from datetime import datetime
+from domus.application.fixed_recurrence import FixedRecurrencePlanner
+from domus.domain.nominal_recurrence import CalendarNominalExpansion
+from domus.domain.recurrence_records import FixedProgress, FixedSchedule, ScheduledSlot
+from domus.domain.recurrence_rule_text import parse_fixed_pattern
+from domus.domain.recurrence_time import NominalTime, TimePolicy, TimeResolution
+from domus.domain.storage import Installation
+from domus.infrastructure.recurrence_time import PinnedRecurrenceTime, TIMEZONE_DATA_IDENTITY
+from domus.infrastructure.sqlite import SCHEMA_VERSION
+from domus.infrastructure.sqlite_recurrence_records import (
+ schedule_document, read_schedule, progress_document, read_progress, slot_document, read_slot,
+)
+assert SCHEMA_VERSION == 8
+policy = TimePolicy('Europe/Madrid', TIMEZONE_DATA_IDENTITY)
+pattern = parse_fixed_pattern('FREQ=DAILY;COUNT=2', NominalTime(datetime(2026, 3, 29, 2, 30)))
+times = PinnedRecurrenceTime()
+planned = FixedRecurrencePlanner(CalendarNominalExpansion(), times).expand(pattern, policy)
+assert len(planned.slots) == 2 and planned.range_complete and planned.cursor.closed
+first = planned.slots[0]
+assert first.resolved.resolved_local == datetime(2026, 3, 29, 3)
+assert first.resolved.resolution is TimeResolution.GAP_FIRST_VALID
+rule = FixedSchedule('rule', 'home', 'definition', pattern, policy, 100)
+progress = FixedProgress('rule', 'home', 1, planned.cursor, range_complete=True)
+installation = Installation('00000000-0000-0000-0000-000000000001', 'edge')
+slot = ScheduledSlot(installation, rule, first, 'work', 100)
+assert read_schedule(schedule_document(rule).encoded) == rule
+assert read_progress(progress_document(progress).encoded) == progress
+assert read_slot(slot_document(slot).encoded) == slot
+assert progress.matches_schedule(rule) and slot.consistent_with(rule)
+print('Current-source recurrence codecs and pinned timezone planning passed.')
+""",
+ )
+ assert result.returncode == 0, result.stderr
+ assert "Current-source recurrence codecs and pinned timezone planning passed." in result.stdout
diff --git a/tests/fixtures/current-app/Dockerfile b/tests/fixtures/current-app/Dockerfile
new file mode 100644
index 0000000..ccf2dcb
--- /dev/null
+++ b/tests/fixtures/current-app/Dockerfile
@@ -0,0 +1,40 @@
+# CI builds the actual checked-out source. This is never a Supervisor release folder.
+FROM ghcr.io/astral-sh/uv:0.11.29@sha256:eb2843a1e56fd9e30c7276ce1a52cba86e64c7b385f5e3279a0e08e02dd058fc AS uv
+FROM python:3.14.6-slim-trixie@sha256:7bec7ddcddeff7975d6ba9b4be7dd6f6b2f55e7491539145e2978f7f97ce9144 AS source
+WORKDIR /app
+COPY pyproject.toml uv.lock ./
+COPY backend ./backend
+COPY tooling ./tooling
+
+FROM node:24.20.0-bookworm-slim@sha256:ba849c60be29959425b8734d57b8b4b7d56f98edd9504c9af091d5281095a71e AS frontend
+RUN npm install --global --ignore-scripts --no-audit --no-fund pnpm@10.34.0
+WORKDIR /app
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
+COPY frontend ./frontend
+COPY tests/fixtures/frontend-toolchain/package.json ./tests/fixtures/frontend-toolchain/package.json
+RUN pnpm install --frozen-lockfile --filter @domus/frontend... \
+ && pnpm build:frontend
+
+FROM source AS builder
+COPY --from=uv /uv /usr/local/bin/uv
+COPY --from=frontend /app/backend/src/domus/ui /app/backend/src/domus/ui
+ENV UV_PYTHON_DOWNLOADS=never UV_NO_CACHE=1
+RUN uv sync --frozen --no-dev --package domus-server --no-editable
+RUN .venv/bin/python -c "from pathlib import Path; import domus; from domus.entrypoints.ui_bundle import load_bundle; assert load_bundle(Path(domus.__file__).parent / 'ui') is not None"
+
+FROM python:3.14.6-slim-trixie@sha256:7bec7ddcddeff7975d6ba9b4be7dd6f6b2f55e7491539145e2978f7f97ce9144
+ARG BUILD_VERSION=ci-current
+ARG BUILD_ARCH
+ARG DOMUS_SOURCE_COMMIT
+RUN python -c "import re,sys; assert re.fullmatch('[a-f0-9]{40}',sys.argv[1])" "${DOMUS_SOURCE_COMMIT}"
+LABEL io.hass.type="app" \
+ io.hass.version="${BUILD_VERSION}" \
+ io.hass.arch="${BUILD_ARCH}" \
+ org.opencontainers.image.source="https://github.com/vypdev/domus" \
+ org.opencontainers.image.revision="${DOMUS_SOURCE_COMMIT}"
+COPY --from=builder /app/.venv /app/.venv
+ENV PATH="/app/.venv/bin:${PATH}" PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
+USER 0:0
+HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8099/health', timeout=3)"
+ENTRYPOINT ["/app/.venv/bin/domus-ha-app", "--channel", "edge"]
diff --git a/tests/fixtures/sqlite/v7.sql b/tests/fixtures/sqlite/v7.sql
new file mode 100644
index 0000000..35bc4bd
--- /dev/null
+++ b/tests/fixtures/sqlite/v7.sql
@@ -0,0 +1,54 @@
+-- Frozen synthetic schema7 input from verified source64908ee59705e6e9dc6e4a06a8d828bf5eea6d4f, before recurrence schema8.
+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('identity-2','home','ha_admin','household.setup','setup','{"administrator_member_id":"identity-1","mapping_mode":"all_ha_administrators","transition":"household.configured"}',100);
+INSERT INTO "audit" VALUES('identity-5','home','ha_admin','task.create','create-synthetic','{"request":{"content":{"description":"","priority":"normal","tags":[],"title":"Schema seven responsibility"},"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"},"result":{"assignee_ids":[],"definition_id":"identity-3","definition_revision":1,"occurrence_id":"identity-4","prerequisite_ids":[],"required_subtask_ids":[],"revision":1,"state":"available","state_reason":""},"transition":"task.created"}',100);
+INSERT INTO "audit" VALUES('identity-7','home','ha_admin','task.complete','complete-synthetic','{"request":{"expected_revision":1,"occurrence_id":"identity-4","performed_at":100,"performer_ids":["identity-1"]},"result":{"assignee_ids":[],"consumer_delivery":"unconfirmed","cycle":{"closed_at":null,"cycle_id":"identity-6","definition_revision":1,"household_id":"home","identity_policy_revision":1,"occurrence_id":"identity-4","performed_at":100,"performers":[{"member_id":"identity-1","role":"administrator"}],"recorded_at":100,"recorded_by":"identity-1","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"},"task_policy_revision":1,"verification":"none","verification_reason":"","verified_at":null,"verified_by":null},"definition_id":"identity-3","definition_revision":1,"occurrence_id":"identity-4","prerequisite_ids":[],"required_subtask_ids":[],"revision":2,"state":"completed","state_reason":""},"transition":"task.completed"}',100);
+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,'Home','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,'047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6','edge');
+CREATE TABLE members(member_id TEXT PRIMARY KEY, household_id TEXT NOT NULL REFERENCES households(household_id), ha_user_id TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('administrator','adult','restricted','viewer')), status TEXT NOT NULL CHECK(status IN ('pending','active','suspended','archived')), revision INTEGER NOT NULL CHECK(revision>=1), exceptions TEXT NOT NULL, UNIQUE(household_id,ha_user_id));
+INSERT INTO "members" VALUES('identity-1','home','ha_admin','viewer','active',1,'{"rules":[]}');
+INSERT INTO "members" VALUES('child','home','ha_child','restricted','active',1,'{"rules":[]}');
+INSERT INTO "members" VALUES('other','home','ha_other','restricted','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,'identity-2','home','household.setup','{"household_id":"home","revision":1}',100,0);
+INSERT INTO "outbox" VALUES(2,'identity-5','home','task.create','{"assignee_ids":[],"definition_id":"identity-3","definition_revision":1,"kind":"task.created","occurrence_id":"identity-4","prerequisite_ids":[],"required_subtask_ids":[],"revision":1,"state":"available","state_reason":"","version":1}',100,0);
+INSERT INTO "outbox" VALUES(3,'identity-7','home','task.complete','{"assignee_ids":[],"consumer_delivery":"unconfirmed","cycle":{"closed_at":null,"cycle_id":"identity-6","definition_revision":1,"household_id":"home","identity_policy_revision":1,"occurrence_id":"identity-4","performed_at":100,"performers":[{"member_id":"identity-1","role":"administrator"}],"recorded_at":100,"recorded_by":"identity-1","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"},"task_policy_revision":1,"verification":"none","verification_reason":"","verified_at":null,"verified_by":null},"definition_id":"identity-3","definition_revision":1,"kind":"task.completed","occurrence_id":"identity-4","prerequisite_ids":[],"required_subtask_ids":[],"revision":2,"state":"completed","state_reason":"","version":1}',100,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','ha_admin','household.setup','setup','fd81d9a903eff49c605ff960bfae80f61f684b2f1f12ff15fb363cd7bdea3c51','identity-2','{"administrator_member_id":"identity-1","household_id":"home","mapping_mode":"all_ha_administrators","member_id":"identity-1","policy_revision":1,"revision":1,"state":"configured"}',100);
+INSERT INTO "receipts" VALUES('home','ha_admin','task.create','create-synthetic','8cd89be09a6d5b4536a47ea1edb18532d2dba9abfc8526a54b20a332fb1edf3a','identity-5','{"assignee_ids":[],"definition_id":"identity-3","definition_revision":1,"occurrence_id":"identity-4","prerequisite_ids":[],"required_subtask_ids":[],"revision":1,"state":"available","state_reason":""}',100);
+INSERT INTO "receipts" VALUES('home','ha_admin','task.complete','complete-synthetic','e1e4817e34bd0eee31acf5fed88f47fdbc50109dc399a0177ad71ec7cf657a34','identity-7','{"assignee_ids":[],"consumer_delivery":"unconfirmed","cycle":{"closed_at":null,"cycle_id":"identity-6","definition_revision":1,"household_id":"home","identity_policy_revision":1,"occurrence_id":"identity-4","performed_at":100,"performers":[{"member_id":"identity-1","role":"administrator"}],"recorded_at":100,"recorded_by":"identity-1","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"},"task_policy_revision":1,"verification":"none","verification_reason":"","verified_at":null,"verified_by":null},"definition_id":"identity-3","definition_revision":1,"occurrence_id":"identity-4","prerequisite_ids":[],"required_subtask_ids":[],"revision":2,"state":"completed","state_reason":""}',100);
+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 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);
+INSERT INTO "task_cycles" VALUES('identity-6','home','identity-4','identity-4','identity-1',NULL,1,NULL,'{"closed_at":null,"cycle_id":"identity-6","definition_revision":1,"household_id":"home","identity_policy_revision":1,"occurrence_id":"identity-4","performed_at":100,"performers":[{"member_id":"identity-1","role":"administrator"}],"recorded_at":100,"recorded_by":"identity-1","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"},"task_policy_revision":1,"verification":"none","verification_reason":"","verified_at":null,"verified_by":null,"version":1}');
+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('identity-3','home',1,'active','{"content":{"description":"","priority":"normal","tags":[],"title":"Schema seven responsibility"},"created_at":100,"definition_id":"identity-3","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('identity-4','home','identity-3',2,'completed','identity-6',1,'{"assignee_ids":[],"created_at":100,"current_cycle_id":"identity-6","current_cycle_revision":1,"definition_snapshot":{"content":{"description":"","priority":"normal","tags":[],"title":"Schema seven responsibility"},"created_at":100,"definition_id":"identity-3","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":null,"household_id":"home","occurrence_id":"identity-4","prerequisite_ids":[],"required_subtask_ids":[],"revision":2,"state":"completed","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);
+DELETE FROM "sqlite_sequence";
+INSERT INTO "sqlite_sequence" VALUES('outbox',3);
+COMMIT;
+PRAGMA application_id=1146047829;
+PRAGMA user_version=7;
From 71c54ab92cc099d65fa6681c1a09ec61fc5656c1 Mon Sep 17 00:00:00 2001
From: Efra Espada
Date: Sun, 1 Nov 2026 23:47:56 +0100
Subject: [PATCH 03/16] test: bind image checks to the current source and
retain schema-7 controls
---
docs/evidence/recurrence-storage.json | 76 ++++++++++++++++++++++--
specs/recurrence-storage-continuation.md | 1 +
tests/python/test_ci_contract.py | 13 +++-
3 files changed, 83 insertions(+), 7 deletions(-)
diff --git a/docs/evidence/recurrence-storage.json b/docs/evidence/recurrence-storage.json
index 51f27ee..aea3d4b 100644
--- a/docs/evidence/recurrence-storage.json
+++ b/docs/evidence/recurrence-storage.json
@@ -1,6 +1,6 @@
{
"status": "partial; storage boundary implemented, complete recurrence feature remains in progress",
- "recordedAtUTC": "2026-11-01T22:18:53.593225+00:00",
+ "recordedAtUTC": "2026-11-01T22:47:25.560889+00:00",
"issue": "https://github.com/vypdev/domus/issues/20",
"sourceBase": "0806a38f8a2de1f102a017bff25ad6856fb17bcd",
"developmentBase": "3131810082760a98ee4af66f11bb65eaac49cc3b",
@@ -85,13 +85,34 @@
"policyModulesByteVerified": 7,
"immutableBaseOwners": 18,
"retainedOwnersUnchanged": true,
- "currentCompanionSHA256": "8f933d1d31842a92bdb9061fdb2dc54cc6b67f3d34cde9c1f9c6124515fc0cb1",
+ "currentCompanionSHA256": "b56cfc4be7260bfa56cb1f3d392d40b7a814a8d4a488ce5225d9d17d507a96c6",
"scope": "Explicit contributor integration; original first publication remains in Git. Format, reciprocal inventory/path consistency and current catalog pass."
},
"packageAndArchitectureTests": {
"result": "passed",
"executions": 52,
"requires": "Fresh frontend build and current backend wheel; both completed before acceptance."
+ },
+ "pythonTooling": {
+ "result": "passed locally after updating the old-image CI contract assertion",
+ "executions": 148,
+ "coverageTotals": {
+ "covered_lines": 214,
+ "num_statements": 214,
+ "percent_covered": 100.0,
+ "percent_covered_display": "100.00",
+ "missing_lines": 0,
+ "excluded_lines": 0,
+ "percent_statements_covered": 100.0,
+ "percent_statements_covered_display": "100.00",
+ "num_branches": 98,
+ "num_partial_branches": 0,
+ "covered_branches": 98,
+ "missing_branches": 0,
+ "percent_branches_covered": 100.0,
+ "percent_branches_covered_display": "100.00"
+ },
+ "sourceChanged": "Only test_ci_contract assertions; no tooling implementation or coverage threshold changed."
}
},
"migration": {
@@ -117,14 +138,32 @@
"recurrence-storage-C04": "A current slot without the exact scoped actor/action/key receipt cannot commit; rollback and valid evidence are tested."
},
"images": {
- "currentSource": "CI builds checked-out backend/frontend with ci-current label on amd64 and aarch64; actual hosted results are pending.",
+ "currentSource": "Actual hosted amd64/aarch64 current-source image tests passed on f835775; a fresh run is required for subsequent commits.",
"schema7Control": {
"version": "0.0.0-edge.5",
"sourceCommit": "271a3a07feb1535f0a5ec8b55abb3de9aac049a9",
"scope": "Immutable owner-package fixture used for actual schema7 upgrade/refusal checks, without changing its pin."
},
- "smoke": "Current recurrence/time/codec program passes a host control; actual CPU-image execution remains pending CI.",
- "localDocker": "Not run; no local Docker engine was started."
+ "smoke": "Pinned recurrence planning, closed record codecs and source relationships execute successfully in both actual CPU images; host control is separate.",
+ "localDocker": "Not run; no local Docker engine was started.",
+ "hosted": {
+ "headRevision": "f835775b2f230db956750a224f8ebd6cc8bbbb5f",
+ "checkoutRevision": "11170de5541bc045f2ec16fd76a08100576c8b0b",
+ "checkoutTree": "81c7125fddf509c319889cfe76381e95b20e32ba",
+ "checkoutTreeEqualsReviewedHeadTree": true,
+ "runId": 41357396927,
+ "amd64": {
+ "jobId": 125000308389,
+ "executions": 23,
+ "result": "passed"
+ },
+ "aarch64": {
+ "jobId": 125000308453,
+ "executions": 23,
+ "result": "passed"
+ },
+ "aggregate": "Failed only on the old-image tooling contract assertion at this head; that test is corrected locally. No aggregate success is claimed until the fresh current-head run passes."
+ }
},
"specification": {
"publishedActionRun": 41351649126,
@@ -136,7 +175,9 @@
},
"copilotReports": [
"https://github.com/vypdev/copilot/issues/763#issuecomment-6563925226",
- "https://github.com/vypdev/copilot/issues/835#issuecomment-6564066025"
+ "https://github.com/vypdev/copilot/issues/835#issuecomment-6564066025",
+ "https://github.com/vypdev/copilot/issues/851",
+ "https://github.com/vypdev/copilot/issues/843#issuecomment-6565011301"
],
"retainedEvidence": {
"directory": "/private/tmp/domus-recurrence-storage-64908ee",
@@ -155,5 +196,28 @@
"before": "Six targeted tests failed before the additional source/count checks; original output is retained.",
"after": "Progress above COUNT, future/missing sources and same-generation pattern contradictions are refused. Legitimate older slot snapshots remain valid.",
"domainPolicy": "Pure matches_schedule/consistent_with relationships are shared by persistence and snapshot inspection; they grant no authority."
+ },
+ "nativeReview": {
+ "headRevision": "f835775b2f230db956750a224f8ebd6cc8bbbb5f",
+ "runId": 41357396827,
+ "jobId": 125000308666,
+ "checkRunId": 125001100160,
+ "codePartitionsCompleted": 4,
+ "codePartitionsAssigned": 4,
+ "filesAssigned": 39,
+ "diffFragments": 42,
+ "candidateFindings": 0,
+ "publishedFindings": 0,
+ "reviewThreads": 0,
+ "overall": "partial; neutral, not a complete review",
+ "humanConversation": {
+ "fetched": 2,
+ "retained": 1,
+ "omitted": 1,
+ "truncated": 0,
+ "limitReached": true
+ },
+ "issueComments": "Separately complete:6 fetched/6 retained; no overall prompt-text omission is inferred from one dimension.",
+ "manualSupplement": "Both maintainer-account proposed-SDD comments were manually inspected. They are consumer-agent review inputs, not fresh owner product answers. Full retained scope/constraints remain explicit."
}
}
diff --git a/specs/recurrence-storage-continuation.md b/specs/recurrence-storage-continuation.md
index ee8e67c..3aa77cf 100644
--- a/specs/recurrence-storage-continuation.md
+++ b/specs/recurrence-storage-continuation.md
@@ -125,6 +125,7 @@ Use real independent SQLite connections and exact before/after version/row check
| specs/CATALOG.md | update |
| .dockerignore | create |
| .github/workflows/ci_check.yml | update |
+| tests/python/test_ci_contract.py | update |
| tests/fixtures/current-app/Dockerfile | create |
| tests/containers/test_app_image.py | update |
| tests/containers/test_app_compatibility.py | update |
diff --git a/tests/python/test_ci_contract.py b/tests/python/test_ci_contract.py
index a20fc5c..608fde7 100644
--- a/tests/python/test_ci_contract.py
+++ b/tests/python/test_ci_contract.py
@@ -55,9 +55,17 @@ def test_native_app_images_are_built_and_exercised_on_both_cpu_families() -> Non
assert container["runs-on"] == "${{ matrix.runner }}"
commands = [step["run"] for step in container["steps"] if "run" in step]
assert (
- "docker build --build-arg BUILD_ARCH=${{ matrix.arch }} -t domus-app-ci addon-edge"
+ "docker build --build-arg BUILD_ARCH=${{ matrix.arch }} -t domus-app-edge5 addon-edge"
in commands
)
+ assert any(
+ "docker build --file tests/fixtures/current-app/Dockerfile" in command
+ and "--build-arg BUILD_ARCH=${{ matrix.arch }}" in command
+ and "--build-arg DOMUS_SOURCE_COMMIT=${{ github.sha }}" in command
+ and "--build-arg BUILD_VERSION=ci-current" in command
+ and command.rstrip().endswith("-t domus-app-ci .")
+ for command in commands
+ )
assert "uv run pytest tests/containers --no-cov" in commands
assert any(
"DOMUS_SOURCE_COMMIT=d013cf2c8b932d0e00f5858053cbc0e663d5eee1" in command
@@ -74,6 +82,9 @@ def test_native_app_images_are_built_and_exercised_on_both_cpu_families() -> Non
)
assert verification["env"]["DOMUS_PREVIOUS_IMAGE"] == "domus-app-previous"
assert verification["env"]["DOMUS_UPGRADE_IMAGE"] == "domus-app-installed"
+ assert verification["env"]["DOMUS_TEST_IMAGE"] == "domus-app-ci"
+ assert verification["env"]["DOMUS_TEST_SOURCE_COMMIT"] == "${{ github.sha }}"
+ assert verification["env"]["DOMUS_SCHEMA7_IMAGE"] == "domus-app-edge5"
assert any("-t domus-app-installed tests/fixtures/edge3-app" in command for command in commands)
assert "containers" in jobs["aggregate"]["needs"]
From ec6a7198baf867b4b4392b5202d8345e90fcf2fd Mon Sep 17 00:00:00 2001
From: Efra Espada
Date: Mon, 2 Nov 2026 00:05:00 +0100
Subject: [PATCH 04/16] test: Verify bounded setup contention and
current-authority replay
---
docs/evidence/recurrence-storage.json | 58 ++++++++++++++++++-
frontend/tests/e2e/entry.spec.ts | 72 ++++++++++++++++++++++--
specs/recurrence-storage-continuation.md | 2 +
tests/browser/entry_server.py | 19 +++++++
4 files changed, 142 insertions(+), 9 deletions(-)
diff --git a/docs/evidence/recurrence-storage.json b/docs/evidence/recurrence-storage.json
index aea3d4b..32cd6f7 100644
--- a/docs/evidence/recurrence-storage.json
+++ b/docs/evidence/recurrence-storage.json
@@ -1,6 +1,6 @@
{
"status": "partial; storage boundary implemented, complete recurrence feature remains in progress",
- "recordedAtUTC": "2026-11-01T22:47:25.560889+00:00",
+ "recordedAtUTC": "2026-11-01T23:04:54.042145+00:00",
"issue": "https://github.com/vypdev/domus/issues/20",
"sourceBase": "0806a38f8a2de1f102a017bff25ad6856fb17bcd",
"developmentBase": "3131810082760a98ee4af66f11bb65eaac49cc3b",
@@ -85,7 +85,7 @@
"policyModulesByteVerified": 7,
"immutableBaseOwners": 18,
"retainedOwnersUnchanged": true,
- "currentCompanionSHA256": "b56cfc4be7260bfa56cb1f3d392d40b7a814a8d4a488ce5225d9d17d507a96c6",
+ "currentCompanionSHA256": "eeeaa3703ac2dd58fa1428e8ff274e0cc84b089241e3b26637a2b3587ffda34b",
"scope": "Explicit contributor integration; original first publication remains in Git. Format, reciprocal inventory/path consistency and current catalog pass."
},
"packageAndArchitectureTests": {
@@ -177,7 +177,8 @@
"https://github.com/vypdev/copilot/issues/763#issuecomment-6563925226",
"https://github.com/vypdev/copilot/issues/835#issuecomment-6564066025",
"https://github.com/vypdev/copilot/issues/851",
- "https://github.com/vypdev/copilot/issues/843#issuecomment-6565011301"
+ "https://github.com/vypdev/copilot/issues/843#issuecomment-6565011301",
+ "https://github.com/vypdev/copilot/issues/851#issuecomment-6565229213"
],
"retainedEvidence": {
"directory": "/private/tmp/domus-recurrence-storage-64908ee",
@@ -219,5 +220,56 @@
},
"issueComments": "Separately complete:6 fetched/6 retained; no overall prompt-text omission is inferred from one dimension.",
"manualSupplement": "Both maintainer-account proposed-SDD comments were manually inspected. They are consumer-agent review inputs, not fresh owner product answers. Full retained scope/constraints remain explicit."
+ },
+ "browserContentionRepair": {
+ "baselineHead": "71c54ab92cc099d65fa6681c1a09ec61fc5656c1",
+ "failedRun": 41358941452,
+ "failedJob": 125004927727,
+ "observed": "The concurrent native different-account setup received one committed 200 and one bounded storage_busy 503 with no second household. The prior browser assertion required immediate403 and omitted the supported contention outcome.",
+ "change": "Retain the real concurrent writer test and require exact storage_busy/Retry-After plus explicit identical request replay and current403/409. Add four deterministic independent SQLite lock scenarios across native/standalone and same/different HA accounts. Verify unchanged rejected journal/draft, zero partial effects, no automatic resubmission and exactly one complete winner.",
+ "productSourcesChanged": false,
+ "storageTimeoutChanged": false,
+ "copilotChanged": false,
+ "before": "The new deterministic browser case failed on missing test-only fixture control (expected200, actual404); original log retained.",
+ "after": {
+ "entryBrowserTests": 49,
+ "passed": 49,
+ "durationSeconds": 31.9,
+ "typescript": "runtime and catalog passed",
+ "fixtureRuff": "check and format passed",
+ "originalPinnedSdd": "passed"
+ },
+ "coverage": "Backend and frontend implementation bytes are unchanged; the prior full100% supported coverage campaigns remain applicable to those same bytes. A fresh hosted fullCI run is required for the test-only correction.",
+ "retainedEvidence": "/private/tmp/domus-race-71c54ab"
+ },
+ "nativeReview71": {
+ "headRevision": "71c54ab92cc099d65fa6681c1a09ec61fc5656c1",
+ "runId": 41358941454,
+ "jobId": 125004927845,
+ "checkRunId": 125005208606,
+ "codePartitionsCompleted": 4,
+ "codePartitionsAssigned": 4,
+ "filesAssigned": 40,
+ "candidateFindings": 0,
+ "publishedFindings": 0,
+ "overall": "partial; neutral, human-conversation limitation remains reported and manually supplemented",
+ "humanConversation": {
+ "fetched": 2,
+ "retained": 1,
+ "omitted": 1,
+ "truncated": 0,
+ "limitReached": true
+ },
+ "issueComments": "Separately complete:6/6; diff complete40/40."
+ },
+ "hosted71": {
+ "headRevision": "71c54ab92cc099d65fa6681c1a09ec61fc5656c1",
+ "runId": 41358941452,
+ "backend": "passed",
+ "pythonTooling": "passed",
+ "amd64": "passed",
+ "aarch64": "passed",
+ "frontend": "failed on the independently investigated contention expectation; local repair49pass is not yet hostedCI success",
+ "aggregate": "failed"
}
}
diff --git a/frontend/tests/e2e/entry.spec.ts b/frontend/tests/e2e/entry.spec.ts
index 817cdf1..827ae1c 100644
--- a/frontend/tests/e2e/entry.spec.ts
+++ b/frontend/tests/e2e/entry.spec.ts
@@ -1,5 +1,5 @@
import {expect, test} from '@playwright/test';
-import type {APIRequestContext, BrowserContext, Page} from '@playwright/test';
+import type {APIRequestContext, BrowserContext, Page, Response} from '@playwright/test';
const prefix = '/api/hassio_ingress/fixture_domus_supervisor_1234567890';
const journalKey = 'domus.entry.journal.v1';
@@ -27,6 +27,14 @@ async function create(page: Page, name = 'Fictional household') {
await page.getByLabel('Timezone', {exact: true}).fill('Europe/Madrid');
await page.getByRole('button', {name: 'Create household', exact: true}).click();
}
+const setupResponse = (page: Page, mode: Mode) => page.waitForResponse(response =>
+ response.url() === base(mode) + '/api/v1/household' && response.request().method() === 'POST');
+async function replaySetup(page: Page, response: Response) {
+ const original = response.request(), headers = await original.allHeaders();
+ // An explicit raw-API replay keeps every command assertion; no UI retry or new key is inferred.
+ return page.request.post(original.url(), {data: original.postData()!, headers: Object.fromEntries(
+ ['content-type', 'origin', 'idempotency-key', 'x-csrf-token', 'x-domus-context'].map(name => [name, headers[name]]))});
+}
for (const mode of modes) test.describe(mode, () => {
test.beforeEach(async ({request}) => {
@@ -303,14 +311,11 @@ for (const mode of modes) test.describe(mode, () => {
let release!: () => void, arrived!: () => void, count = 0;
const gate = new Promise(resolve => {release = resolve;});
const ready = new Promise(resolve => {arrived = resolve;});
- const statuses: number[] = [], keys: string[] = [];
+ const keys: string[] = [];
try {
if (mode === 'standalone' && actors === 'different_accounts') await request.post(origin(mode) + '/__fixture__/identity', {data: {subject: 'c'.repeat(32)}});
await open(peerPage, peer, mode, true, (actors === 'different_accounts' ? 'e' : 'd').repeat(128));
for (const candidate of [page, peerPage]) {
- candidate.on('response', response => {
- if (response.url() === base(mode) + '/api/v1/household' && response.request().method() === 'POST') statuses.push(response.status());
- });
await candidate.route(base(mode) + '/api/v1/household', async route => {
if (route.request().method() !== 'POST') return route.continue();
keys.push(route.request().headers()['idempotency-key']);
@@ -318,10 +323,25 @@ for (const mode of modes) test.describe(mode, () => {
await gate; await route.continue();
});
}
+ const responses = Promise.all([page, peerPage].map(candidate => setupResponse(candidate, mode)));
await Promise.all([create(page, 'Fictional administrator A'), create(peerPage, 'Fictional administrator C')]);
await ready; release();
+ const replies = await responses, statuses: number[] = [];
// An active mapped member gets a conflict; a new unlinked HA admin gains no implicit membership.
- await expect.poll(() => statuses.slice().sort()).toEqual([200, actors === 'same_account' ? 409 : 403]);
+ for (const [index, reply] of replies.entries()) {
+ if (reply.status() === 503) {
+ expect((await reply.json()).code).toBe('storage_busy');
+ expect(reply.headers()['retry-after']).toBe('1');
+ // The bounded writer may time out under CI load. After the winner completes, the exact
+ // original request still needs current authority and cannot create a second household.
+ await expect([page, peerPage][index].getByRole('heading', {name: 'Request not accepted', exact: true})).toBeVisible();
+ const retried = await replaySetup([page, peerPage][index], reply);
+ expect(retried.status()).toBe(actors === 'same_account' ? 409 : 403);
+ statuses.push(retried.status());
+ } else statuses.push(reply.status());
+ }
+ expect(replies.filter(reply => reply.status() === 200)).toHaveLength(1);
+ expect(statuses.sort()).toEqual([200, actors === 'same_account' ? 409 : 403]);
expect(new Set(keys).size).toBe(2);
const final = await stats(request, mode);
for (const table of ['households', 'members', 'receipts', 'audit', 'outbox']) expect(final.counts[table], table).toBe(1);
@@ -336,6 +356,46 @@ for (const mode of modes) test.describe(mode, () => {
} finally {release(); await peer.close();}
});
+ for (const actors of ['same_account', 'different_accounts'] as const) test(`bounded writer rejection retains the original ${actors} request and rechecks authority after another setup`, async ({page, context, browser, request}) => {
+ await open(page, context, mode);
+ const peer = await browser.newContext(), peerPage = await peer.newPage();
+ let posts = 0;
+ page.on('request', item => {if (item.url() === base(mode) + '/api/v1/household' && item.method() === 'POST') posts++;});
+ try {
+ if (mode === 'standalone' && actors === 'different_accounts') await request.post(origin(mode) + '/__fixture__/identity', {data: {subject: 'c'.repeat(32)}});
+ await open(peerPage, peer, mode, true, (actors === 'different_accounts' ? 'e' : 'd').repeat(128));
+ expect((await request.post(origin(mode) + '/__fixture__/writer', {data: {action: 'acquire'}})).status()).toBe(200);
+ const pending = setupResponse(page, mode);
+ await create(page, 'Retained fictional draft');
+ const rejected = await pending;
+ expect(rejected.status()).toBe(503); expect((await rejected.json()).code).toBe('storage_busy');
+ expect(rejected.headers()['retry-after']).toBe('1');
+ await expect(page.getByRole('heading', {name: 'Request not accepted', exact: true})).toBeVisible();
+ await expect(page.getByLabel('Household name', {exact: true})).toHaveValue('Retained fictional draft');
+ const original = await page.evaluate(key => JSON.parse(sessionStorage.getItem(key)!).records[0], journalKey);
+ expect(original.stage).toBe('rejected'); expect(original.operationId).toBeNull();
+ expect(original.key).toBe(rejected.request().headers()['idempotency-key']);
+ const empty = await stats(request, mode);
+ for (const table of ['households', 'members', 'receipts', 'audit', 'outbox']) expect(empty.counts[table], table).toBe(0);
+ expect((await request.post(origin(mode) + '/__fixture__/writer', {data: {action: 'release'}})).status()).toBe(200);
+ await create(peerPage);
+ await expect(peerPage.getByRole('heading', {name: 'Request recorded', exact: true})).toBeVisible();
+ const before = await stats(request, mode);
+ const retried = await replaySetup(page, rejected);
+ expect(retried.status()).toBe(actors === 'same_account' ? 409 : 403);
+ expect((await retried.json()).code).toBe(actors === 'same_account' ? 'household_exists' : 'permission_denied');
+ expect(await stats(request, mode)).toEqual(before);
+ await page.getByRole('button', {name: 'Refresh status', exact: true}).click();
+ await expect(page.getByRole('heading', {name: actors === 'same_account' ? 'Your household' : 'Join your household', level: 1, exact: true})).toBeVisible();
+ expect(posts).toBe(1); // Neither refresh nor rejection automatically resends the command.
+ expect(await page.evaluate(key => JSON.parse(sessionStorage.getItem(key)!).records[0], journalKey)).toEqual(original);
+ for (const table of ['households', 'members', 'receipts', 'audit', 'outbox']) expect(before.counts[table], table).toBe(1);
+ } finally {
+ await request.post(origin(mode) + '/__fixture__/writer', {data: {action: 'release'}});
+ await peer.close();
+ }
+ });
+
test('failed owned-storage clearing closes local access and reports uncertainty without canceling sign-out', async ({page, context, request}) => {
await open(page, context, mode); await create(page);
await expect(page.getByRole('heading', {name: 'Request recorded', exact: true})).toBeVisible();
diff --git a/specs/recurrence-storage-continuation.md b/specs/recurrence-storage-continuation.md
index 3aa77cf..c0c2f91 100644
--- a/specs/recurrence-storage-continuation.md
+++ b/specs/recurrence-storage-continuation.md
@@ -126,6 +126,8 @@ Use real independent SQLite connections and exact before/after version/row check
| .dockerignore | create |
| .github/workflows/ci_check.yml | update |
| tests/python/test_ci_contract.py | update |
+| frontend/tests/e2e/entry.spec.ts | update |
+| tests/browser/entry_server.py | update |
| tests/fixtures/current-app/Dockerfile | create |
| tests/containers/test_app_image.py | update |
| tests/containers/test_app_compatibility.py | update |
diff --git a/tests/browser/entry_server.py b/tests/browser/entry_server.py
index 5a11cb9..062ed89 100644
--- a/tests/browser/entry_server.py
+++ b/tests/browser/entry_server.py
@@ -100,6 +100,7 @@ class Fixture:
def __init__(self, mode: str, port: int) -> None:
self.mode, self.origin = mode, f"http://127.0.0.1:{port}"
self.runtime = self.directory = None
+ self.writer = None
self.patch = pytest.MonkeyPatch()
self.patch.setattr(ingress, "HomeAssistantWebSocket", lambda endpoint: self.ha)
self.patch.setattr(server, "HomeAssistantWebSocket", lambda endpoint: self.ha)
@@ -108,6 +109,7 @@ def __init__(self, mode: str, port: int) -> None:
def reset(self, channel: str = "edge") -> None:
assert channel in ("edge", "stable")
+ self.release_writer()
if self.runtime is not None:
self.runtime.close()
if self.directory is not None:
@@ -124,6 +126,11 @@ def reset(self, channel: str = "edge") -> None:
)
self.start()
+ def release_writer(self) -> None:
+ if self.writer is not None:
+ self.writer.close() # Closing the independent fixture connection rolls back its lock.
+ self.writer = None
+
def start(self) -> None:
if self.mode == "native":
self.runtime = ingress.create_owned_runtime(
@@ -188,6 +195,7 @@ async def __call__(self, scope, receive, send) -> None:
if event["type"] == "lifespan.startup":
await send({"type": "lifespan.startup.complete"})
elif event["type"] == "lifespan.shutdown":
+ self.release_writer()
self.runtime.close()
self.directory.cleanup()
self.patch.undo()
@@ -216,6 +224,17 @@ async def __call__(self, scope, receive, send) -> None:
response = JSONResponse({"state": "completed", "fixture": True})
elif path == "/__fixture__/stats":
response = JSONResponse(self.stats())
+ elif path == "/__fixture__/writer" and request.method == "POST":
+ values = await request.json()
+ assert set(values) == {"action"}
+ assert values["action"] in ("acquire", "release")
+ if values["action"] == "acquire":
+ assert self.writer is None
+ self.writer = sqlite3.connect(self.database, autocommit=True)
+ self.writer.execute("BEGIN IMMEDIATE")
+ else:
+ self.release_writer()
+ response = JSONResponse({"state": values["action"], "fixture": True})
elif path == "/__fixture__/identity" and request.method == "POST":
values = await request.json()
assert set(values) <= {"subject", "outage", "rejected"}
From 9c12953232c0fb967663c1853e5dc48112379623 Mon Sep 17 00:00:00 2001
From: Efra Espada
Date: Mon, 2 Nov 2026 00:19:11 +0100
Subject: [PATCH 05/16] feat: Create fixed schedules atomically and reject
invalid nominal slots
---
.../domus/application/recurrence_commands.py | 138 ++++++++
.../src/domus/application/recurrence_ports.py | 2 +
.../domus/application/task_authorization.py | 1 +
.../src/domus/application/task_commands.py | 44 +--
.../src/domus/application/task_creation.py | 61 ++++
.../src/domus/domain/recurrence_membership.py | 92 ++++++
.../src/domus/domain/recurrence_records.py | 2 +
backend/src/domus/domain/recurrence_rules.py | 13 +-
.../domus/infrastructure/recurrence_time.py | 3 +
docs/architecture-recurrence.md | 33 +-
docs/evidence/recurrence-creation.json | 146 +++++++++
docs/evidence/recurrence-storage.json | 12 +-
specs/recurrence-storage-continuation.md | 14 +
tests/backend/test_recurrence_creation.py | 300 ++++++++++++++++++
.../test_recurrence_slot_membership.py | 175 ++++++++++
.../test_sqlite_recurrence_integrity.py | 54 +++-
.../backend/test_sqlite_recurrence_records.py | 2 +-
tests/containers/test_recurrence_image.py | 54 +++-
18 files changed, 1094 insertions(+), 52 deletions(-)
create mode 100644 backend/src/domus/application/recurrence_commands.py
create mode 100644 backend/src/domus/application/task_creation.py
create mode 100644 backend/src/domus/domain/recurrence_membership.py
create mode 100644 docs/evidence/recurrence-creation.json
create mode 100644 tests/backend/test_recurrence_creation.py
create mode 100644 tests/backend/test_recurrence_slot_membership.py
diff --git a/backend/src/domus/application/recurrence_commands.py b/backend/src/domus/application/recurrence_commands.py
new file mode 100644
index 0000000..c513452
--- /dev/null
+++ b/backend/src/domus/application/recurrence_commands.py
@@ -0,0 +1,138 @@
+"""Current-authorized schedule creation shares task validation and the durable command boundary."""
+
+import json
+from collections.abc import Callable
+from dataclasses import asdict, dataclass
+
+from domus.application.commands import AtomicCommands, Change
+from domus.application.fixed_recurrence import FixedRecurrencePlanner
+from domus.application.identity_ports import CurrentHuman
+from domus.application.ports import Clock, Identifiers, Transactions, UnitOfWork
+from domus.application.task_authorization import TaskAccess, TaskCommandAuthorization
+from domus.application.task_creation import TaskCreation
+from domus.application.task_material_ports import TaskInventory
+from domus.application.task_requests import CreateTask
+from domus.domain.errors import IntegrationUnavailable, InvalidInput
+from domus.domain.recurrence_planning import FixedCursor, cursor_binding
+from domus.domain.recurrence_records import FixedProgress, FixedSchedule
+from domus.domain.recurrence_rules import FixedPattern
+from domus.domain.recurrence_time import TimePolicy
+from domus.domain.storage import CommandKey, JsonDocument, Receipt, validate_identifier
+from domus.domain.task_values import DefinitionState, TaskOverrides
+
+
+@dataclass(frozen=True, slots=True)
+class CreateFixedTask:
+ task: CreateTask
+ pattern: FixedPattern
+ policy: TimePolicy
+
+ def __post_init__(self) -> None:
+ if (
+ not isinstance(self.task, CreateTask)
+ or self.task.state is not DefinitionState.ACTIVE
+ or not isinstance(self.pattern, FixedPattern)
+ or not isinstance(self.policy, TimePolicy)
+ ):
+ raise InvalidInput("invalid_recurrence_create")
+
+ def document(self) -> JsonDocument:
+ return JsonDocument(
+ json.dumps(
+ {
+ "version": 1,
+ "task": self.task.document().decode(),
+ "pattern": self.pattern.document().decode(),
+ "policy": asdict(self.policy),
+ }
+ )
+ )
+
+
+@dataclass
+class RecurrenceCommands:
+ transactions: Transactions
+ actor: CurrentHuman
+ clock: Clock
+ identifiers: Identifiers
+ planner: FixedRecurrencePlanner
+ household_id: str = "home"
+ inventory_factory: Callable[[UnitOfWork], TaskInventory] | None = None
+
+ def __post_init__(self) -> None:
+ validate_identifier(self.household_id)
+
+ def create_fixed(self, key: str, request: CreateFixedTask) -> Receipt:
+ payload = request.document()
+ access = TaskAccess(self.actor.current())
+ command = CommandKey(
+ self.household_id, access.human.ha_user_id, "recurrence.fixed.create", key
+ )
+ authority = TaskCommandAuthorization(
+ access,
+ self.household_id,
+ command.action,
+ needs_configuration=request.task.overrides != TaskOverrides(),
+ needs_material_link=request.task.materials is not None,
+ )
+ commands = AtomicCommands(self.transactions, authority, self.clock, self.identifiers)
+ previous = commands.replay(command, payload)
+ if previous is not None:
+ return previous
+ # Provider work cannot hold the writer lock; execute checks fresh local grants again.
+ self.planner.times.require_policy(request.policy)
+ check = self.planner.check_start(request.pattern, request.policy)
+ if not check.matches:
+ if not check.search_complete:
+ raise IntegrationUnavailable("recurrence_start_unresolved")
+ raise InvalidInput("recurrence_start_mismatch")
+
+ def apply(unit: UnitOfWork) -> Change:
+ item = TaskCreation(self.clock, self.identifiers, self.inventory_factory).definition(
+ unit, access, self.household_id, request.task
+ )
+ schedule = FixedSchedule(
+ self.identifiers.new(),
+ self.household_id,
+ item.definition_id,
+ request.pattern,
+ request.policy,
+ item.created_at,
+ )
+ # Validation's lookahead never consumes the first real obligation.
+ progress = FixedProgress(
+ schedule.schedule_id,
+ self.household_id,
+ 1,
+ FixedCursor(cursor_binding(schedule.pattern, schedule.policy)),
+ )
+ unit.recurrence.save_schedule(schedule, 0)
+ unit.recurrence.save_progress(progress, 0)
+ result = JsonDocument.from_value(
+ {
+ "schedule_id": schedule.schedule_id,
+ "definition_id": item.definition_id,
+ "revision": 1,
+ "pattern_revision": 1,
+ "state": schedule.state.value,
+ "processing": "pending",
+ "first_occurrence_id": None,
+ }
+ )
+ detail = JsonDocument.from_value(
+ {
+ "transition": "recurrence.fixed.created",
+ "request": payload.decode(),
+ "result": result.decode(),
+ }
+ )
+ event = JsonDocument.from_value(
+ {
+ "version": 1,
+ "kind": "recurrence.fixed.created",
+ **result.decode(),
+ }
+ )
+ return Change(result, detail, event)
+
+ return commands.execute(command, payload, apply)
diff --git a/backend/src/domus/application/recurrence_ports.py b/backend/src/domus/application/recurrence_ports.py
index 632c33e..5ce6735 100644
--- a/backend/src/domus/application/recurrence_ports.py
+++ b/backend/src/domus/application/recurrence_ports.py
@@ -17,6 +17,8 @@ class RecurrenceTimes(Protocol):
@property
def data_identity(self) -> str: ...
+ def require_policy(self, policy: TimePolicy) -> None: ...
+
def resolve(self, nominal: NominalTime, policy: TimePolicy) -> ResolvedTime: ...
def after_local_days(
diff --git a/backend/src/domus/application/task_authorization.py b/backend/src/domus/application/task_authorization.py
index eec8fa5..e7b8ddd 100644
--- a/backend/src/domus/application/task_authorization.py
+++ b/backend/src/domus/application/task_authorization.py
@@ -193,6 +193,7 @@ def approver(
}
_GLOBAL_PERMISSIONS = {
"task.create": Permission.TASKS_CREATE,
+ "recurrence.fixed.create": Permission.TASKS_CREATE,
"task.policy.update": Permission.TASKS_CONFIGURE,
}
_DEFINITION_ACTIONS = frozenset({"task.definition.update", "task.occurrence.create"})
diff --git a/backend/src/domus/application/task_commands.py b/backend/src/domus/application/task_commands.py
index 99339ea..ca509d3 100644
--- a/backend/src/domus/application/task_commands.py
+++ b/backend/src/domus/application/task_commands.py
@@ -11,8 +11,8 @@
from domus.application.task_authorization import (
TaskAccess,
TaskCommandAuthorization,
- TaskMaterialAccess,
)
+from domus.application.task_creation import TaskCreation
from domus.application.task_facts import (
current_cycle,
definition_result,
@@ -20,7 +20,7 @@
resolved_rules,
)
from domus.application.task_material_commands import TaskMaterialCommands, material_result
-from domus.application.task_material_ports import MissingTaskInventory, TaskInventory
+from domus.application.task_material_ports import TaskInventory
from domus.application.task_material_scope import MaterialScope
from domus.application.task_material_views import material_outcome
from domus.application.task_requests import (
@@ -65,7 +65,7 @@
VerifyTask as VerifyTask,
)
from domus.application.task_reviews import review_cycle, review_items
-from domus.domain.errors import Conflict, Forbidden, IntegrationUnavailable, NotFound
+from domus.domain.errors import Conflict, Forbidden, NotFound
from domus.domain.identity import MemberStatus, Permission
from domus.domain.storage import CommandKey, JsonDocument, Receipt, validate_identifier
from domus.domain.task_material_values import validate_material_usages
@@ -75,7 +75,6 @@
from domus.domain.task_values import (
OccurrenceState,
TaskOverrides,
- TaskRules,
)
from domus.domain.tasks import HouseholdTaskPolicy, TaskCycle, TaskDefinition, TaskOccurrence
@@ -163,20 +162,8 @@ def __post_init__(self) -> None:
validate_identifier(self.household_id)
def _materials(self, unit: UnitOfWork, access: TaskAccess) -> TaskMaterialCommands:
- try:
- inventory = (
- MissingTaskInventory()
- if self.inventory_factory is None
- else self.inventory_factory(unit)
- )
- except IntegrationUnavailable:
- inventory = MissingTaskInventory()
- return TaskMaterialCommands(
- unit,
- TaskMaterialAccess(access, unit, self.household_id),
- inventory,
- self.clock,
- self.identifiers,
+ return TaskCreation(self.clock, self.identifiers, self.inventory_factory).materials(
+ unit, access, self.household_id
)
def _execute(
@@ -281,25 +268,10 @@ def apply(unit: UnitOfWork, access: TaskAccess) -> Change:
def create(self, key: str, request: CreateTask) -> Receipt:
def apply(unit: UnitOfWork, access: TaskAccess) -> Change:
- if unit.tasks.policy(self.household_id) is None:
- unit.tasks.save_policy(HouseholdTaskPolicy(self.household_id, TaskRules()), 0)
- materials = (
- ()
- if request.materials is None
- else self._materials(unit, access).link(request.materials)
- )
- now = self.clock.now()
- item = TaskDefinition(
- self.identifiers.new(),
- self.household_id,
- request.content,
- now,
- request.overrides,
- request.state,
- materials=materials,
+ item = TaskCreation(self.clock, self.identifiers, self.inventory_factory).definition(
+ unit, access, self.household_id, request
)
- work = item.new_occurrence(self.identifiers.new(), now)
- unit.tasks.save_definition(item, 0)
+ work = item.new_occurrence(self.identifiers.new(), item.created_at)
unit.tasks.save_occurrence(work, 0)
return _change("task.created", request, _result(work))
diff --git a/backend/src/domus/application/task_creation.py b/backend/src/domus/application/task_creation.py
new file mode 100644
index 0000000..7f649fe
--- /dev/null
+++ b/backend/src/domus/application/task_creation.py
@@ -0,0 +1,61 @@
+"""Shared authorized task-definition creation; the caller owns its transaction and work mode."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from domus.application.ports import Clock, Identifiers, UnitOfWork
+from domus.application.task_authorization import TaskAccess, TaskMaterialAccess
+from domus.application.task_material_commands import TaskMaterialCommands
+from domus.application.task_material_ports import MissingTaskInventory, TaskInventory
+from domus.application.task_requests import CreateTask
+from domus.domain.errors import IntegrationUnavailable
+from domus.domain.task_values import TaskRules
+from domus.domain.tasks import HouseholdTaskPolicy, TaskDefinition
+
+
+@dataclass
+class TaskCreation:
+ clock: Clock
+ identifiers: Identifiers
+ inventory_factory: Callable[[UnitOfWork], TaskInventory] | None = None
+
+ def materials(
+ self, unit: UnitOfWork, access: TaskAccess, household_id: str
+ ) -> TaskMaterialCommands:
+ try:
+ inventory = (
+ MissingTaskInventory()
+ if self.inventory_factory is None
+ else self.inventory_factory(unit)
+ )
+ except IntegrationUnavailable:
+ inventory = MissingTaskInventory()
+ return TaskMaterialCommands(
+ unit,
+ TaskMaterialAccess(access, unit, household_id),
+ inventory,
+ self.clock,
+ self.identifiers,
+ )
+
+ def definition(
+ self, unit: UnitOfWork, access: TaskAccess, household_id: str, request: CreateTask
+ ) -> TaskDefinition:
+ if unit.tasks.policy(household_id) is None:
+ unit.tasks.save_policy(HouseholdTaskPolicy(household_id, TaskRules()), 0)
+ materials = (
+ ()
+ if request.materials is None
+ else self.materials(unit, access, household_id).link(request.materials)
+ )
+ item = TaskDefinition(
+ self.identifiers.new(),
+ household_id,
+ request.content,
+ self.clock.now(),
+ request.overrides,
+ request.state,
+ materials=materials,
+ )
+ unit.tasks.save_definition(item, 0)
+ return item
diff --git a/backend/src/domus/domain/recurrence_membership.py b/backend/src/domus/domain/recurrence_membership.py
new file mode 100644
index 0000000..8f7e780
--- /dev/null
+++ b/backend/src/domus/domain/recurrence_membership.py
@@ -0,0 +1,92 @@
+"""Pattern membership and nominal COUNT rank are independent of stored headers or receipts."""
+
+from datetime import timedelta
+from functools import lru_cache
+from math import gcd
+
+from domus.domain.nominal_recurrence import CalendarNominalExpansion
+from domus.domain.recurrence_planning import PlannedFixedSlot
+from domus.domain.recurrence_rules import FixedPattern, Frequency, NominalDate, slot_datetime
+
+
+def _period_index(pattern: FixedPattern, planned: PlannedFixedSlot) -> int | None:
+ start, target = slot_datetime(pattern.start).date(), slot_datetime(planned.nominal).date()
+ if pattern.frequency is Frequency.DAILY:
+ distance = (target - start).days
+ elif pattern.frequency is Frequency.WEEKLY:
+ origin = start - timedelta(days=(start.weekday() - pattern.week_start.number) % 7)
+ distance = (target - origin).days // 7
+ elif pattern.frequency is Frequency.MONTHLY:
+ distance = (target.year - start.year) * 12 + target.month - start.month
+ else:
+ distance = target.year - start.year
+ index, remainder = divmod(distance, pattern.interval)
+ return None if remainder else index
+
+
+@lru_cache(maxsize=4096)
+def _block_count(pattern: FixedPattern, first: int, length: int) -> int:
+ # Pure bounded memoization changes no proof or result. Each block inspects <=64 periods;
+ # eviction merely recomputes the same facts, including when independent readers race.
+ expansion = CalendarNominalExpansion()
+ return sum(
+ len(expansion.period(pattern, index).slots) for index in range(first, first + length)
+ )
+
+
+def _full_periods(pattern: FixedPattern, length: int) -> int:
+ blocks, remaining = divmod(length, 64)
+ total = sum(_block_count(pattern, 1 + block * 64, 64) for block in range(blocks))
+ return total + (_block_count(pattern, 1 + blocks * 64, remaining) if remaining else 0)
+
+
+def _rank(pattern: FixedPattern, index: int, position: int) -> int:
+ if pattern.frequency is Frequency.DAILY and not (
+ pattern.by_month or pattern.by_month_day or pattern.by_day or pattern.by_set_position
+ ):
+ return index + 1
+ if index == 0:
+ return position + 1
+ # Gregorian selectors repeat every400 years. Account for the interval phase explicitly:
+ # at most146097 daily dates,20871 weeks,4800 months or400 years are examined, regardless
+ # of the target's distance. Period zero alone is truncated by DTSTART and is separate.
+ calendar_cycle = {
+ Frequency.DAILY: 146097,
+ Frequency.WEEKLY: 20871,
+ Frequency.MONTHLY: 4800,
+ Frequency.YEARLY: 400,
+ }[pattern.frequency]
+ cycle = calendar_cycle // gcd(calendar_cycle, pattern.interval)
+ repeats, remainder = divmod(index - 1, cycle)
+ first = len(CalendarNominalExpansion().period(pattern, 0).slots)
+ return (
+ first
+ + (repeats * _full_periods(pattern, cycle) if repeats else 0)
+ + _full_periods(pattern, remainder)
+ + position
+ + 1
+ )
+
+
+def accepts_fixed_slot(pattern: FixedPattern, planned: PlannedFixedSlot) -> bool:
+ nominal = planned.nominal
+ if type(nominal) is not type(pattern.start) or slot_datetime(nominal) < slot_datetime(
+ pattern.start
+ ):
+ return False
+ if isinstance(pattern.until, NominalDate):
+ if nominal.local > pattern.until.local:
+ return False
+ elif pattern.until is not None and (
+ planned.resolved is None or planned.resolved.instant_ms > pattern.until
+ ):
+ return False
+ index = _period_index(pattern, planned)
+ if index is None:
+ return False
+ candidates = CalendarNominalExpansion().period(pattern, index).slots
+ if nominal not in candidates:
+ return False
+ return (
+ pattern.count is None or _rank(pattern, index, candidates.index(nominal)) <= pattern.count
+ )
diff --git a/backend/src/domus/domain/recurrence_records.py b/backend/src/domus/domain/recurrence_records.py
index 44c84a8..1f1b045 100644
--- a/backend/src/domus/domain/recurrence_records.py
+++ b/backend/src/domus/domain/recurrence_records.py
@@ -5,6 +5,7 @@
from domus.domain.errors import InvalidInput
from domus.domain.identity import validate_revision
+from domus.domain.recurrence_membership import accepts_fixed_slot
from domus.domain.recurrence_planning import FixedCursor, PlannedFixedSlot, cursor_binding
from domus.domain.recurrence_rules import FixedPattern, NominalDate
from domus.domain.recurrence_time import TimePolicy
@@ -106,6 +107,7 @@ def __post_init__(self) -> None:
and self.planned.resolved.policy != self.schedule.policy
)
or self.created_at < self.schedule.created_at
+ or not accepts_fixed_slot(self.schedule.pattern, self.planned)
):
raise InvalidInput("invalid_recurrence_slot")
diff --git a/backend/src/domus/domain/recurrence_rules.py b/backend/src/domus/domain/recurrence_rules.py
index dd89f8d..3ce0db7 100644
--- a/backend/src/domus/domain/recurrence_rules.py
+++ b/backend/src/domus/domain/recurrence_rules.py
@@ -1,6 +1,5 @@
"""Closed calendar patterns and nominal period facts; no provider or scheduling authority."""
-import hashlib
import json
from dataclasses import dataclass
from datetime import date, datetime
@@ -8,6 +7,7 @@
from domus.domain.errors import InvalidInput
from domus.domain.recurrence_time import MAX_INSTANT_MS, NominalTime
+from domus.domain.storage import JsonDocument
class Frequency(StrEnum):
@@ -129,8 +129,7 @@ def __post_init__(self) -> None:
for name in ("by_month", "by_month_day", "by_set_position"):
object.__setattr__(self, name, tuple(sorted(getattr(self, name))))
- @property
- def fingerprint(self) -> str:
+ def document(self) -> JsonDocument:
value = {
"version": 1,
"start_kind": "date" if isinstance(self.start, NominalDate) else "time",
@@ -147,9 +146,11 @@ def fingerprint(self) -> str:
if isinstance(self.until, NominalDate)
else self.until,
}
- return hashlib.sha256(
- json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
- ).hexdigest()
+ return JsonDocument(json.dumps(value))
+
+ @property
+ def fingerprint(self) -> str:
+ return self.document().fingerprint
def slot_datetime(slot: NominalSlot) -> datetime:
diff --git a/backend/src/domus/infrastructure/recurrence_time.py b/backend/src/domus/infrastructure/recurrence_time.py
index 8243c3b..7bd3fef 100644
--- a/backend/src/domus/infrastructure/recurrence_time.py
+++ b/backend/src/domus/infrastructure/recurrence_time.py
@@ -96,6 +96,9 @@ class PinnedRecurrenceTime:
def data_identity(self) -> str:
return TIMEZONE_DATA_IDENTITY
+ def require_policy(self, policy: TimePolicy) -> None:
+ self._zone(policy)
+
def _zone(self, policy: TimePolicy) -> tuple[ZoneInfo, str]:
if policy.data_identity != self.data_identity:
raise IntegrationUnavailable("timezone_data_mismatch")
diff --git a/docs/architecture-recurrence.md b/docs/architecture-recurrence.md
index 4822f50..4f8c915 100644
--- a/docs/architecture-recurrence.md
+++ b/docs/architecture-recurrence.md
@@ -117,13 +117,40 @@ progress above the configured count, future slot generations and contradictory s
same generation. Legitimate older generations remain valid after edits. Incomplete or inconsistent
records are rejected without rewriting their source.
+The pure [membership check](../backend/src/domus/domain/recurrence_membership.py) also verifies that
+the nominal is a real candidate of the original pattern and fits COUNT/UNTIL. Matching headers and
+an exact receipt cannot turn an arbitrary date into scheduled work. COUNT uses actual nominal rank,
+including omitted invalid dates and the truncated initial period. Gregorian cycles preserve the
+original interval phase and bound distant rank calculation; bounded memoization only avoids
+recomputing pure period counts. Constructor, closed decoding and checkpoint inspection share this
+invariant. A corrupt current or historical slot is rejected rather than silently reinterpreted.
+
The [migration guide](tasks/migration.md) distinguishes current source schema 8 from the immutable
installed Edge.5 schema 7. CI builds a separate checked-out-source image for both CPU architectures
and retains Edge.5 as a genuine old-image compatibility control. Its version is ci-current and it
has no publication/deployment step. Actual hosted build/test outcomes remain required evidence.
-These are repository and migration capabilities. Scheduled human commands, actual fixed/relative
-materialization, lifecycle consumption, restore non-recreation, preview/apply, protected calendar
-queries and composed runners remain unfinished. The four additional storage refinements do not
+These are repository and migration capabilities. Actual fixed/relative materialization, lifecycle
+consumption, restore non-recreation, preview/apply, protected calendar queries and composed runners
+remain unfinished. The four additional storage refinements do not
recount the retained 52 companion or 37 original owner cases. See
[storage evidence](evidence/recurrence-storage.json) for exact tests and acceptance limits.
+
+## Authorized new fixed-task creation
+
+The application [creation command](../backend/src/domus/application/recurrence_commands.py) validates
+new active fixed tasks with current HA proof and the existing task-create, privileged override and
+inventory-link permissions. It shares [task definition creation](../backend/src/domus/application/task_creation.py)
+with the ordinary manual path. A definition, rule, unconsumed progress and original receipt/audit/outbox
+commit together. Manual creation retains its one initial occurrence; scheduled creation creates none
+until real materialization, so validation cannot consume or duplicate its first obligation.
+
+Pinned zone/data validation and bounded DTSTART preflight occur outside the writer transaction,
+including for date-only rules. The command returns an authorized original receipt before provider
+work and checks current local grants again before new writes. A rejected/unresolved start is not
+silently moved. The result explicitly reports pending processing and no first occurrence; it does
+not imply runner execution, task performance, points, inventory consumption or downstream delivery.
+
+This is an application use case, with no new public route or deployed interface yet. Draft/relative
+configuration, reviewed attachment, protected operation recovery queries and full runtime/HA
+acceptance remain required. See [creation evidence](evidence/recurrence-creation.json).
diff --git a/docs/evidence/recurrence-creation.json b/docs/evidence/recurrence-creation.json
new file mode 100644
index 0000000..8d758ed
--- /dev/null
+++ b/docs/evidence/recurrence-creation.json
@@ -0,0 +1,146 @@
+{
+ "status": "partial; active fixed-task application creation and nominal integrity implemented; full recurrence feature remains in progress",
+ "recordedAtUTC": "2026-11-01T23:17:57.649245+00:00",
+ "issue": "https://github.com/vypdev/domus/issues/20",
+ "pullRequest": "https://github.com/vypdev/domus/pull/22",
+ "sourceBase": "ec6a7198baf867b4b4392b5202d8345e90fcf2fd",
+ "backendSourceInventorySHA256": "cc1ba0c4e6abf3cc89b3aae1c92f8ef6cf4b9815fdff2986a1ec423064d159e8",
+ "sourceManifestMethod": "SHA256 of compact sorted JSON mapping all125 authored Python module paths, including package initializers, to their byte SHA256 values.",
+ "implemented": [
+ "Current-authorized active fixed-task creation using existing task-create/configuration/inventory-link grants",
+ "Shared manual/scheduled task-definition and material-link creation, with ordinary manual initial occurrence unchanged",
+ "Atomic definition/rule/unconsumed progress/original receipt/audit/outbox; scheduled creation does not create accidental manual work",
+ "Actual pinned date-only/instant policy validation and bounded DTSTART preflight outside writer lock; local authority rechecked at commit",
+ "Authorized original receipt before provider work; restart, payload conflict, permission loss and injected rollback tests",
+ "Pure stored nominal candidate/COUNT/UNTIL membership shared by constructor, strict decoder and checkpoint inspection"
+ ],
+ "testsFirst": {
+ "creation": "New collection failed with missing domus.application.recurrence_commands before implementation; original output retained.",
+ "bugbot": "Seventeen membership scenarios failed and one passed before the additional invariant; original output retained. The corrupted-header/evidence regression additionally checks independent read and checkpoint refusal."
+ },
+ "completeBackend": {
+ "executions": 3545,
+ "durationSeconds": 106.18,
+ "authoredModules": 125,
+ "coverageTotals": {
+ "covered_lines": 8838,
+ "num_statements": 8838,
+ "percent_covered": 100.0,
+ "percent_covered_display": "100.00",
+ "missing_lines": 0,
+ "excluded_lines": 269,
+ "percent_statements_covered": 100.0,
+ "percent_statements_covered_display": "100.00",
+ "num_branches": 2512,
+ "num_partial_branches": 0,
+ "covered_branches": 2512,
+ "missing_branches": 0,
+ "percent_branches_covered": 100.0,
+ "percent_branches_covered_display": "100.00"
+ },
+ "independentPerModuleAudit": "passed against the final125-module source inventory"
+ },
+ "focusedCreation": {
+ "executions": 249,
+ "scope": "Creation plus existing shared task/material/rule regressions; not249 independent product journeys.",
+ "coverageTotals": {
+ "covered_lines": 83,
+ "num_statements": 83,
+ "percent_covered": 100.0,
+ "percent_covered_display": "100.00",
+ "missing_lines": 0,
+ "excluded_lines": 0,
+ "percent_statements_covered": 100.0,
+ "percent_statements_covered_display": "100.00",
+ "num_branches": 10,
+ "num_partial_branches": 0,
+ "covered_branches": 10,
+ "missing_branches": 0,
+ "percent_branches_covered": 100.0,
+ "percent_branches_covered_display": "100.00"
+ },
+ "authoredNewModules": 2
+ },
+ "focusedMembership": {
+ "executions": 124,
+ "scope": "Nominal membership plus existing storage/codec/integrity regressions.",
+ "coverageTotals": {
+ "covered_lines": 52,
+ "num_statements": 52,
+ "percent_covered": 100.0,
+ "percent_covered_display": "100.00",
+ "missing_lines": 0,
+ "excluded_lines": 0,
+ "percent_statements_covered": 100.0,
+ "percent_statements_covered_display": "100.00",
+ "num_branches": 22,
+ "num_partial_branches": 0,
+ "covered_branches": 22,
+ "missing_branches": 0,
+ "percent_branches_covered": 100.0,
+ "percent_branches_covered_display": "100.00"
+ }
+ },
+ "bugbotFinding": {
+ "thread": "https://github.com/vypdev/domus/pull/22#discussion_r4437746192",
+ "findingId": "recurrence_records.py:99:slot-nominal-not-validated-against-pattern",
+ "assessment": "Valid product correctness finding, fixed locally; remote resolution is pending normal source publication and review.",
+ "invariant": "The actual nominal must be generated by the original frequency/interval/selectors and fit DTSTART, nominal COUNT and inclusive UNTIL. Correct headers/source/receipt alone are insufficient.",
+ "count": "Actual nominal rank preserves period-zero truncation, invalid-date omissions and Gregorian selector repetition with interval phase. Bounded pure block memoization never grants authority or changes facts.",
+ "codecFixtureCorrection": "The every-selector fixture now selects Thursday, the actual weekday of its January1 start, instead of constructing an impossible slot. Selector round trips remain covered.",
+ "snapshot": "A COUNT1 rule/progress/source and exact receipt with mutually consistent forged January2 slot headers still fail independent decoding and checkpoint semantic validation, preserving source bytes."
+ },
+ "checks": {
+ "strictTypes": {
+ "passed": true,
+ "files": 125
+ },
+ "ruff": "check and format passed",
+ "architecture": {
+ "contractsKept": 3,
+ "contractsBroken": 0,
+ "filesAnalyzed": 169,
+ "dependencies": 1058
+ },
+ "originalPinnedSdd": {
+ "upstream": "3e7ceea08e6131360e026937d1c0cc1b120472e0",
+ "base": "0806a38f8a2de1f102a017bff25ad6856fb17bcd",
+ "selected": "specs/recurrence-storage-continuation.md",
+ "originalPolicyModulesVerified": 7,
+ "format": "PASS",
+ "consistency": "PASS",
+ "composition": "explicit contributor integration; original publication retained in Git",
+ "unchangedCatalog": "PASS",
+ "immutableOwnerDocumentsUsed": 18,
+ "retainedCompanionCases": 52,
+ "newCases": 4,
+ "rawCharacters": 22468,
+ "renderedCharacters": 22468,
+ "composedCharacters": 22468,
+ "applicableDraftMaximumCharacters": 70000,
+ "sha256": "890d10fb2c9abd3717f984853f178e15976423bdc2e49371eb7ad0ac4b0ab341",
+ "scope": "Review input only; no gate admission, branch publication, source implementation or runtime acceptance.",
+ "retainedOwningFilesUnchanged": true,
+ "workingDocumentValidated": true
+ },
+ "imageControlOnHost": "Passed by executing the exact test image script under the local Python3.14.6 runtime; this is separate from actual CPU-image acceptance.",
+ "packageAndArchitectureTests": {
+ "passed": true,
+ "executions": 52,
+ "durationSeconds": 7.56,
+ "requires": "Fresh current backend wheel including the retained unchanged frontend bundle; actual current-source image builds still rebuild UI independently."
+ }
+ },
+ "pending": [
+ "Actual fixed materialization, bounded runner lifecycle and historical restore non-recreation",
+ "Completion-relative persistence and actual cycle/source handling",
+ "Reviewed attachment, schedule draft/configuration APIs, pause/resume/skip/reschedule and preview/apply",
+ "Task category/classification, protected operation/calendar query HTTP and both composed runners",
+ "Fresh hosted image/check results, actual UI/HA acceptance and complete retained52+37-case/first all-domain release"
+ ],
+ "ui": "No new public route or UI was added. Existing entry repair49 browser tests and original supported frontend coverage remain separate evidence.",
+ "images": "Image control now exercises actual SQLite fixed creation/replay and COUNT rejection. Local execution of that script is not actual CPU-image evidence; fresh hosted both-architecture jobs remain required.",
+ "ownerHa": "Installed Edge.5 and retained Edge.3 unchanged. No deployment, release or registry publication.",
+ "copilot": "No Copilot resource/configuration/bound/credential changed. Known progress evidence capacity and partial conversation reports remain upstream851/843.",
+ "retainedEvidenceDirectory": "/private/tmp/domus-race-71c54ab"
+}
diff --git a/docs/evidence/recurrence-storage.json b/docs/evidence/recurrence-storage.json
index 32cd6f7..7b0a0ba 100644
--- a/docs/evidence/recurrence-storage.json
+++ b/docs/evidence/recurrence-storage.json
@@ -85,7 +85,7 @@
"policyModulesByteVerified": 7,
"immutableBaseOwners": 18,
"retainedOwnersUnchanged": true,
- "currentCompanionSHA256": "eeeaa3703ac2dd58fa1428e8ff274e0cc84b089241e3b26637a2b3587ffda34b",
+ "currentCompanionSHA256": "890d10fb2c9abd3717f984853f178e15976423bdc2e49371eb7ad0ac4b0ab341",
"scope": "Explicit contributor integration; original first publication remains in Git. Format, reciprocal inventory/path consistency and current catalog pass."
},
"packageAndArchitectureTests": {
@@ -271,5 +271,15 @@
"aarch64": "passed",
"frontend": "failed on the independently investigated contention expectation; local repair49pass is not yet hostedCI success",
"aggregate": "failed"
+ },
+ "laterApplicationIncrement": "docs/evidence/recurrence-creation.json; this kernel report retains its original3500-test source and acceptance scope.",
+ "hostedBrowserRepair": {
+ "headRevision": "ec6a7198baf867b4b4392b5202d8345e90fcf2fd",
+ "runId": 41360326797,
+ "frontendJob": 125009131653,
+ "aggregateJob": 125010374783,
+ "amd64Job": 125009131683,
+ "aarch64Job": 125009131691,
+ "result": "All applicable CI Check suites passed; Copilot separately found one real nominal-membership issue, now corrected locally and awaiting publication/review. Full recurrence remains incomplete."
}
}
diff --git a/specs/recurrence-storage-continuation.md b/specs/recurrence-storage-continuation.md
index c0c2f91..0bd8161 100644
--- a/specs/recurrence-storage-continuation.md
+++ b/specs/recurrence-storage-continuation.md
@@ -138,8 +138,15 @@ Use real independent SQLite connections and exact before/after version/row check
| backend/src/domus/application/fixed_recurrence.py | update |
| backend/src/domus/application/ports.py | update |
| backend/src/domus/application/recurrence_ports.py | update |
+| backend/src/domus/application/recurrence_commands.py | create |
+| backend/src/domus/application/task_creation.py | create |
+| backend/src/domus/application/task_commands.py | update |
+| backend/src/domus/application/task_authorization.py | update |
| backend/src/domus/domain/recurrence_planning.py | update |
| backend/src/domus/domain/recurrence_records.py | create |
+| backend/src/domus/domain/recurrence_membership.py | create |
+| backend/src/domus/domain/recurrence_rules.py | update |
+| backend/src/domus/infrastructure/recurrence_time.py | update |
| backend/src/domus/infrastructure/sqlite.py | update |
| backend/src/domus/infrastructure/sqlite_snapshots.py | update |
| backend/src/domus/infrastructure/sqlite_recurrence.py | create |
@@ -147,6 +154,8 @@ Use real independent SQLite connections and exact before/after version/row check
| backend/src/domus/infrastructure/sqlite_recurrence_records.py | create |
| tests/backend/test_sqlite_recurrence.py | create |
| tests/backend/test_recurrence_records.py | create |
+| tests/backend/test_recurrence_creation.py | create |
+| tests/backend/test_recurrence_slot_membership.py | create |
| tests/backend/test_sqlite_recurrence_records.py | create |
| tests/backend/test_sqlite_recurrence_integrity.py | create |
| backend/src/domus/infrastructure/sqlite_recurrence_integrity.py | create |
@@ -163,6 +172,7 @@ Use real independent SQLite connections and exact before/after version/row check
| tests/backend/test_sqlite_task_reviews.py | update |
| tests/backend/test_task_restoration.py | update |
| docs/architecture-recurrence.md | update |
+| docs/evidence/recurrence-creation.json | create |
| docs/tasks/migration.md | update |
| docs/evidence/recurrence-execution.json | update |
@@ -192,6 +202,10 @@ The retained owner's reciprocal mappings remain unchanged. New mappings above co
After normal branch admission: restore the privately preserved test-first draft; implement pure records and scoped SQLite adapter; implement schema-8 migration and complete snapshot verification; exercise negative boundaries and real transactions; then continue all retained authorized execution, relative lifecycle, restoration, preview, calendar/API and runner work. Update evidence, documentation and applicable images before review. Administration schema 9 follows the actual recurrence merge.
+The next application increment implements new active fixed-task creation through existing task-create, configuration-override and inventory-link authority. It saves the shared task definition, rule, unconsumed cursor and original command evidence together. It deliberately creates no manual initial occurrence; actual materialization remains required. Date-only configuration still validates its actual pinned zone/data policy without storing a fabricated instant. Preflight provider work holds no writer lock, and current local authority is checked again in the commit. Authorized original receipts precede provider work; changed keys/payloads and revoked authority retain existing semantics. Draft configuration, relative creation, reviewed attachment, public HTTP/operation queries and composed runners remain unfinished.
+
+Bugbot identified a storage boundary missing from the first kernel: a correctly scoped slot could name a nominal outside its pattern or COUNT/UNTIL end. Validate actual finite-period candidate membership and nominal rank in the pure record constructor, so closed decoding and checkpoint inspection enforce the same invariant. COUNT rank preserves interval phase, initial-period truncation, omitted invalid dates and Gregorian repetition; bounded pure memoization is an optimization, never evidence or authority. Do not weaken the existing slot/receipt/source relationships or alter the schema to mask corrupt records. A codec fixture must itself describe a real candidate. These are refinements of retained RCE/storage integrity obligations, not additional counted product journeys.
+
## 19. Definition of Done
- [ ] The four additional cases and every retained requirement/case have truthful current-source evidence; no checkpoint is described as full feature acceptance.
diff --git a/tests/backend/test_recurrence_creation.py b/tests/backend/test_recurrence_creation.py
new file mode 100644
index 0000000..a8758a1
--- /dev/null
+++ b/tests/backend/test_recurrence_creation.py
@@ -0,0 +1,300 @@
+"""Actual authorized scheduled creation retains one atomic rule and no accidental manual work."""
+
+from dataclasses import replace
+from datetime import date, datetime
+
+import pytest
+from test_household_commands import Clock
+from test_task_commands import counts, fixture
+
+from domus.application.fixed_recurrence import FixedRecurrencePlanner
+from domus.application.recurrence_commands import CreateFixedTask, RecurrenceCommands
+from domus.application.task_requests import CreateTask
+from domus.domain.errors import (
+ Conflict,
+ Forbidden,
+ IdentityUnavailable,
+ InvalidInput,
+ StorageFailure,
+)
+from domus.domain.identity import HumanIdentity, MemberStatus, Permission, PermissionRule, Role
+from domus.domain.recurrence_planning import cursor_binding
+from domus.domain.recurrence_records import ScheduleState
+from domus.domain.recurrence_rules import ByDay, FixedPattern, Frequency, NominalDate, Weekday
+from domus.domain.recurrence_time import NominalTime, TimePolicy
+from domus.domain.task_values import DefinitionState, TaskContent, TaskOverrides
+from domus.infrastructure.recurrence_time import PinnedRecurrenceTime
+from domus.infrastructure.sqlite import SQLiteAudit, SQLiteDatabase
+from domus.infrastructure.sqlite_recurrence import SQLiteRecurrences
+
+
+def setup(tmp_path):
+ from domus.domain.nominal_recurrence import CalendarNominalExpansion
+
+ tasks, store, actor, identifiers = fixture(tmp_path)
+ planner = FixedRecurrencePlanner(CalendarNominalExpansion(), PinnedRecurrenceTime())
+ commands = RecurrenceCommands(store, actor, Clock(), identifiers, planner)
+ return commands, tasks, store, actor, identifiers
+
+
+def request(*, timed=False):
+ policy = TimePolicy("Europe/Madrid", PinnedRecurrenceTime().data_identity)
+ start = NominalTime(datetime(2026, 3, 29, 2, 30)) if timed else NominalDate(date(2026, 3, 29))
+ return CreateFixedTask(
+ CreateTask(TaskContent("Clean the filter")), FixedPattern(start, Frequency.DAILY), policy
+ )
+
+
+@pytest.mark.parametrize("timed", [False, True])
+def test_atomic_scheduled_creation_starts_unconsumed_and_never_creates_manual_work(tmp_path, timed):
+ commands, tasks, store, actor, identifiers = setup(tmp_path)
+ before = counts(store)
+ value = request(timed=timed)
+ result = commands.create_fixed("scheduled", value)
+ body = result.result.decode()
+ assert body["processing"] == "pending" and body["state"] == "active"
+ assert body["first_occurrence_id"] is None
+ with store.read() as unit:
+ schedule = unit.recurrence.schedule("home", body["schedule_id"])
+ item = unit.tasks.definition("home", body["definition_id"])
+ progress = unit.recurrence.progress("home", schedule.schedule_id)
+ assert schedule.pattern == value.pattern and schedule.policy == value.policy
+ assert item.content == value.task.content and item.created_at == schedule.created_at == 100
+ assert (
+ schedule.definition_id == item.definition_id and schedule.state is ScheduleState.ACTIVE
+ )
+ assert progress.cursor.binding == cursor_binding(value.pattern, value.policy)
+ assert (
+ progress.cursor.nominal_count
+ == progress.cursor.offset
+ == progress.cursor.period_index
+ == 0
+ )
+ assert not progress.range_complete and progress.last_run_at is None
+ assert unit.recurrence.slots("home", schedule.schedule_id) == ()
+ assert unit.tasks.occurrences("home") == ()
+ assert unit.tasks.policy("home").revision == 1
+ assert unit.receipts.get(result.command) == result
+ assert (
+ unit.audit.entries("home")[-1].detail.decode()["request"] == value.document().decode()
+ )
+ assert unit.outbox.pending()[-1].event_id == result.operation_id
+ assert counts(store) == (1, 0, before[2] + 1, before[3] + 1)
+ ordinary = tasks.create("ordinary", CreateTask(TaskContent("Manual")))
+ assert ordinary.result.decode()["occurrence_id"] is not None
+ assert counts(store)[:2] == (2, 1)
+ assert actor.calls >= 2 and identifiers.count >= 4
+
+
+def test_restart_replay_keeps_original_receipt_without_provider_or_identity_allocation(
+ tmp_path, monkeypatch
+):
+ commands, _, store, actor, identifiers = setup(tmp_path)
+ original_calls = actor.calls
+ value = request()
+ result = commands.create_fixed("create", value)
+ allocated = identifiers.count
+ before = counts(store)
+
+ def unavailable(*args, **kwargs):
+ raise AssertionError("An authorized original receipt must precede provider work")
+
+ monkeypatch.setattr(PinnedRecurrenceTime, "require_policy", unavailable)
+ reopened = SQLiteDatabase(store.path, store.installation)
+ replay = replace(commands, transactions=reopened).create_fixed("create", value)
+ assert replay == result and identifiers.count == allocated and counts(store) == before
+ assert actor.calls == original_calls + 2
+ with pytest.raises(Conflict, match="idempotency_conflict"):
+ commands.create_fixed("create", replace(value, pattern=replace(value.pattern, interval=2)))
+
+
+@pytest.mark.parametrize("stage", ["definition", "schedule", "progress", "audit"])
+def test_failure_after_each_creation_write_rolls_back_policy_rule_progress_and_all_evidence(
+ tmp_path, monkeypatch, stage
+):
+ from domus.infrastructure.sqlite_tasks import SQLiteTasks
+
+ commands, _, store, _, _ = setup(tmp_path)
+ target, name = {
+ "definition": (SQLiteTasks, "save_definition"),
+ "schedule": (SQLiteRecurrences, "save_schedule"),
+ "progress": (SQLiteRecurrences, "save_progress"),
+ "audit": (SQLiteAudit, "append"),
+ }[stage]
+ original = getattr(target, name)
+ before = counts(store)
+
+ def failed(self, *args):
+ original(self, *args)
+ raise StorageFailure("fixture_after_write")
+
+ with monkeypatch.context() as patch:
+ patch.setattr(target, name, failed)
+ with pytest.raises(StorageFailure, match="fixture_after_write"):
+ commands.create_fixed("create", request())
+ assert counts(store) == before
+ with store.read() as unit:
+ assert unit.tasks.policy("home") is None
+ assert unit.recurrence.schedules("home") == ()
+ assert commands.create_fixed("create", request()).result.decode()["processing"] == "pending"
+
+
+@pytest.mark.parametrize("change", ["restricted", "unlinked", "suspended", "outage", "override"])
+def test_current_identity_and_original_creation_permissions_apply_before_new_effect_or_replay(
+ tmp_path, change
+):
+ commands, _, store, actor, _ = setup(tmp_path)
+ value = request()
+ original = commands.create_fixed("original", value)
+ if change in ("restricted", "unlinked"):
+ actor.human = HumanIdentity(
+ "ha_child" if change == "restricted" else "ha_absent", False, True, False
+ )
+ elif change == "suspended":
+ with store.write() as unit:
+ member = unit.members.by_user("home", actor.human.ha_user_id)
+ unit.members.save(replace(member, status=MemberStatus.SUSPENDED, revision=2), 1)
+ elif change == "outage":
+ actor.error = IdentityUnavailable("ha_unavailable")
+ else:
+ actor.human = HumanIdentity("ha_other", False, True, False)
+ with store.write() as unit:
+ member = unit.members.by_user("home", actor.human.ha_user_id)
+ unit.members.save(replace(member, role=Role.ADULT, revision=2), 1)
+ value = replace(
+ value,
+ task=replace(value.task, overrides=TaskOverrides(adult_performer_may_approve=False)),
+ )
+ before = counts(store)
+ for key in ("new", "original"):
+ with pytest.raises(IdentityUnavailable if change == "outage" else Forbidden):
+ commands.create_fixed(key, value)
+ assert counts(store) == before
+ assert original.command.actor_id == "ha_admin"
+
+
+def test_permission_loss_after_external_rule_validation_is_rechecked_inside_writer(
+ tmp_path, monkeypatch
+):
+ commands, _, store, actor, _ = setup(tmp_path)
+ original = PinnedRecurrenceTime.require_policy
+
+ def revoke(self, policy):
+ original(self, policy)
+ with store.write() as unit:
+ member = unit.members.by_user("home", actor.human.ha_user_id)
+ unit.members.save(replace(member, status=MemberStatus.SUSPENDED, revision=2), 1)
+
+ monkeypatch.setattr(PinnedRecurrenceTime, "require_policy", revoke)
+ before = counts(store)
+ with pytest.raises(Forbidden):
+ commands.create_fixed("create", request())
+ assert counts(store) == before
+
+
+@pytest.mark.parametrize(
+ "change,code",
+ [
+ ("zone", "unknown_time_zone"),
+ ("data", "timezone_data_mismatch"),
+ ("start", "recurrence_start_mismatch"),
+ ],
+)
+def test_invalid_actual_policy_or_mismatched_start_is_rejected_without_silently_adjusting_rule(
+ tmp_path, change, code
+):
+ from domus.domain.errors import IntegrationUnavailable
+
+ commands, _, store, _, _ = setup(tmp_path)
+ value = request()
+ if change == "zone":
+ value = replace(value, policy=replace(value.policy, zone="Unknown/Zone"))
+ elif change == "data":
+ value = replace(value, policy=replace(value.policy, data_identity="fixture:wrong"))
+ else:
+ value = replace(
+ value,
+ pattern=replace(value.pattern, frequency=Frequency.WEEKLY, by_day=(ByDay(Weekday.MO),)),
+ )
+ before = counts(store)
+ with pytest.raises(InvalidInput if change != "data" else IntegrationUnavailable, match=code):
+ commands.create_fixed("create", value)
+ assert counts(store) == before
+
+
+@pytest.mark.parametrize("field,value", [("task", None), ("pattern", None), ("policy", None)])
+def test_untyped_scheduled_request_cannot_enter_application(field, value):
+ with pytest.raises(InvalidInput, match="invalid_recurrence_create"):
+ replace(request(), **{field: value})
+
+
+def test_scheduled_creation_reuses_task_material_validation_and_privileged_link_permissions(
+ tmp_path,
+):
+ from test_task_material_commands import Inventory
+
+ from domus.domain.task_material_values import MaterialQuantity, MaterialSelection
+
+ commands, _, store, actor, _ = setup(tmp_path)
+ inventory = Inventory(tmp_path / "inventory.sqlite3")
+ commands.inventory_factory = inventory.factory
+ value = request()
+ value = replace(
+ value,
+ task=replace(
+ value.task, materials=(MaterialSelection("binding", MaterialQuantity("0.5")),)
+ ),
+ )
+ receipt = commands.create_fixed("create", value)
+ with store.read() as unit:
+ item = unit.tasks.definition("home", receipt.result.decode()["definition_id"])
+ assert item.materials[0].binding == inventory.bindings["binding"]
+ assert item.materials[0].planned_quantity.value == "0.5"
+ member = unit.members.by_user("home", actor.human.ha_user_id)
+ before = counts(store)
+ with store.write() as unit:
+ unit.members.save(
+ replace(
+ member, exceptions=(PermissionRule(Permission.INVENTORY_MANAGE, False),), revision=2
+ ),
+ 1,
+ )
+ for key in ("new", "create"):
+ with pytest.raises(Forbidden):
+ commands.create_fixed(key, value)
+ assert counts(store) == before
+
+
+def test_nonactive_task_requires_deliberate_later_configuration():
+ with pytest.raises(InvalidInput, match="invalid_recurrence_create"):
+ replace(request(), task=CreateTask(TaskContent("Draft"), state=DefinitionState.DRAFT))
+
+
+def test_bounded_start_search_does_not_persist_an_unresolved_pattern(tmp_path):
+ from domus.domain.errors import IntegrationUnavailable
+
+ commands, _, store, _, _ = setup(tmp_path)
+ value = request()
+ value = replace(
+ value,
+ pattern=replace(
+ value.pattern, frequency=Frequency.YEARLY, by_month=(2,), by_month_day=(30,)
+ ),
+ )
+ before = counts(store)
+ with pytest.raises(IntegrationUnavailable, match="recurrence_start_unresolved"):
+ commands.create_fixed("create", value)
+ assert counts(store) == before
+
+
+def test_authorized_task_override_is_snapshotted_and_does_not_change_household_defaults(tmp_path):
+ commands, _, store, _, _ = setup(tmp_path)
+ value = request()
+ value = replace(
+ value, task=replace(value.task, overrides=TaskOverrides(adult_performer_may_approve=False))
+ )
+ receipt = commands.create_fixed("create", value)
+ with store.read() as unit:
+ item = unit.tasks.definition("home", receipt.result.decode()["definition_id"])
+ assert item.overrides == value.task.overrides
+ assert unit.tasks.policy("home").rules.adult_performer_may_approve
diff --git a/tests/backend/test_recurrence_slot_membership.py b/tests/backend/test_recurrence_slot_membership.py
new file mode 100644
index 0000000..3d75a9e
--- /dev/null
+++ b/tests/backend/test_recurrence_slot_membership.py
@@ -0,0 +1,175 @@
+"""Stored slots must belong to their actual rule, including nominal count and end bounds."""
+
+from dataclasses import replace
+from datetime import date, datetime
+
+import pytest
+from test_recurrence_records import values
+
+from domus.domain.errors import InvalidInput
+from domus.domain.recurrence_planning import PlannedFixedSlot
+from domus.domain.recurrence_rules import ByDay, FixedPattern, Frequency, NominalDate, Weekday
+from domus.domain.recurrence_time import NominalTime
+
+
+def dated(pattern, day):
+ original = values()[2]
+ return replace(
+ original,
+ schedule=replace(original.schedule, pattern=pattern),
+ planned=PlannedFixedSlot(NominalDate(day), None),
+ )
+
+
+@pytest.mark.parametrize(
+ "pattern,day",
+ [
+ (FixedPattern(NominalDate(date(2026, 1, 1)), Frequency.DAILY, count=1), date(2026, 1, 2)),
+ (FixedPattern(NominalDate(date(2026, 1, 1)), Frequency.DAILY), date(2025, 12, 31)),
+ (
+ FixedPattern(NominalDate(date(2026, 1, 1)), Frequency.DAILY, interval=2),
+ date(2026, 1, 2),
+ ),
+ (
+ FixedPattern(NominalDate(date(2026, 1, 5)), Frequency.WEEKLY, interval=2),
+ date(2026, 1, 12),
+ ),
+ (FixedPattern(NominalDate(date(2026, 1, 5)), Frequency.WEEKLY), date(2026, 1, 6)),
+ (FixedPattern(NominalDate(date(2026, 1, 31)), Frequency.MONTHLY), date(2026, 2, 28)),
+ (
+ FixedPattern(NominalDate(date(2026, 1, 31)), Frequency.MONTHLY, interval=2),
+ date(2026, 2, 28),
+ ),
+ (FixedPattern(NominalDate(date(2024, 2, 29)), Frequency.YEARLY), date(2026, 2, 28)),
+ (
+ FixedPattern(NominalDate(date(2024, 2, 29)), Frequency.YEARLY, interval=4),
+ date(2028, 3, 1),
+ ),
+ (
+ FixedPattern(
+ NominalDate(date(2026, 1, 1)), Frequency.DAILY, until=NominalDate(date(2026, 1, 2))
+ ),
+ date(2026, 1, 3),
+ ),
+ (
+ FixedPattern(NominalDate(date(2026, 1, 31)), Frequency.MONTHLY, count=2),
+ date(2026, 5, 31),
+ ),
+ (
+ FixedPattern(
+ NominalDate(date(2026, 1, 1)),
+ Frequency.MONTHLY,
+ by_day=(ByDay(Weekday.MO),),
+ by_set_position=(-1,),
+ ),
+ date(2026, 1, 5),
+ ),
+ ],
+)
+def test_constructor_rejects_same_type_slots_outside_pattern_or_original_end(pattern, day):
+ with pytest.raises(InvalidInput, match="invalid_recurrence_slot"):
+ dated(pattern, day)
+
+
+def test_monthly_invalid_dates_do_not_consume_count_and_until_remains_inclusive():
+ pattern = FixedPattern(NominalDate(date(2026, 1, 31)), Frequency.MONTHLY, count=2)
+ assert dated(pattern, date(2026, 1, 31))
+ assert dated(pattern, date(2026, 3, 31))
+ assert dated(
+ replace(pattern, count=None, until=NominalDate(date(2026, 3, 31))), date(2026, 3, 31)
+ )
+
+
+def test_timed_slot_must_keep_actual_rule_wall_time_and_instant_until():
+ original = values(True)[2]
+ shifted = replace(
+ original.planned.resolved,
+ nominal=NominalTime(datetime(2026, 1, 1, 13)),
+ resolved_local=datetime(2026, 1, 1, 13),
+ )
+ with pytest.raises(InvalidInput, match="invalid_recurrence_slot"):
+ replace(original, planned=PlannedFixedSlot(shifted.nominal, shifted))
+ until = original.planned.resolved.instant_ms
+ assert replace(
+ original,
+ schedule=replace(
+ original.schedule, pattern=replace(original.schedule.pattern, until=until)
+ ),
+ )
+ with pytest.raises(InvalidInput, match="invalid_recurrence_slot"):
+ replace(
+ original,
+ schedule=replace(
+ original.schedule, pattern=replace(original.schedule.pattern, until=until - 1)
+ ),
+ )
+
+
+@pytest.mark.parametrize(
+ "frequency,start,target,ordinal",
+ [
+ (Frequency.DAILY, date(1970, 1, 1), date(2243, 10, 16), 100000),
+ (Frequency.MONTHLY, date(2000, 1, 31), date(2800, 1, 31), 5601),
+ (Frequency.YEARLY, date(2000, 2, 29), date(2800, 2, 29), 195),
+ ],
+)
+def test_distant_count_uses_calendar_rank_not_elapsed_days(frequency, start, target, ordinal):
+ pattern = FixedPattern(NominalDate(start), frequency, count=ordinal)
+ assert dated(pattern, target)
+ with pytest.raises(InvalidInput, match="invalid_recurrence_slot"):
+ dated(replace(pattern, count=ordinal - 1), target)
+
+
+def test_period_zero_truncation_and_week_start_preserve_count():
+ # Friday DTSTART, weekdays in each Monday-based week: first week contributes only Friday.
+ pattern = FixedPattern(
+ NominalDate(date(2026, 1, 2)),
+ Frequency.WEEKLY,
+ by_day=(ByDay(Weekday.MO), ByDay(Weekday.FR)),
+ count=2,
+ )
+ assert dated(pattern, date(2026, 1, 5))
+ with pytest.raises(InvalidInput, match="invalid_recurrence_slot"):
+ dated(pattern, date(2026, 1, 9))
+
+
+def test_distant_weekly_count_includes_only_actual_selected_month_weekdays():
+ from calendar import monthrange
+
+ start = date(2000, 1, 3)
+ target = next(date(2800, 2, day) for day in range(1, 8) if date(2800, 2, day).weekday() == 0)
+ count = (
+ sum(
+ 1
+ for year in range(2000, 2800)
+ for day in range(1, monthrange(year, 2)[1] + 1)
+ if date(year, 2, day).weekday() == 0
+ )
+ + 1
+ )
+ pattern = FixedPattern(
+ NominalDate(start),
+ Frequency.WEEKLY,
+ by_month=(2,),
+ by_day=(ByDay(Weekday.MO),),
+ count=count,
+ )
+ assert dated(pattern, target)
+ with pytest.raises(InvalidInput, match="invalid_recurrence_slot"):
+ dated(replace(pattern, count=count - 1), target)
+
+
+@pytest.mark.parametrize(
+ "frequency,start,target,ordinal",
+ [
+ (Frequency.MONTHLY, date(2000, 1, 31), date(2800, 1, 31), 2401),
+ (Frequency.YEARLY, date(2000, 2, 29), date(4400, 2, 29), 195),
+ ],
+)
+def test_gregorian_count_cycle_keeps_the_original_nonunit_interval_phase(
+ frequency, start, target, ordinal
+):
+ pattern = FixedPattern(NominalDate(start), frequency, interval=3, count=ordinal)
+ assert dated(pattern, target)
+ with pytest.raises(InvalidInput, match="invalid_recurrence_slot"):
+ dated(replace(pattern, count=ordinal - 1), target)
diff --git a/tests/backend/test_sqlite_recurrence_integrity.py b/tests/backend/test_sqlite_recurrence_integrity.py
index 9bbf1e9..445ebc7 100644
--- a/tests/backend/test_sqlite_recurrence_integrity.py
+++ b/tests/backend/test_sqlite_recurrence_integrity.py
@@ -3,6 +3,7 @@
import json
from concurrent.futures import ThreadPoolExecutor
from dataclasses import replace
+from datetime import date
from threading import Barrier
import pytest
@@ -10,7 +11,8 @@
from test_sqlite_snapshots import Clock
from domus.domain.errors import Conflict, DatabaseRejected, StorageFailure
-from domus.domain.recurrence_planning import FixedCursor, cursor_binding
+from domus.domain.recurrence_planning import FixedCursor, PlannedFixedSlot, cursor_binding
+from domus.domain.recurrence_rules import NominalDate
from domus.infrastructure.sqlite import SQLiteDatabase
from domus.infrastructure.sqlite_recurrence_integrity import verify_recurrence_records
from domus.infrastructure.sqlite_recurrence_records import (
@@ -44,6 +46,56 @@ def test_checkpoint_verifies_and_reopens_real_rule_progress_slot_and_original_re
assert unit.receipts.get(value.command).result.decode()["slot_key"] == value.key
+def test_self_consistent_slot_headers_and_exact_receipt_cannot_bypass_nominal_count(tmp_path):
+ store, rule, progress, value = committed(tmp_path)
+ # Construct a legitimate second daily nominal under the original unlimited rule, then corrupt
+ # both stored source snapshots to COUNT1. All keys, headers and evidence still agree.
+ later = replace(value, planned=PlannedFixedSlot(NominalDate(date(2026, 1, 2)), None))
+ bounded = replace(rule, pattern=replace(rule.pattern, count=1))
+ document = slot_document(later).decode()
+ document["schedule"]["pattern"]["count"] = 1
+ result = json.dumps({"slot_key": later.key, "occurrence_id": later.occurrence_id})
+ with store._connection() as connection:
+ connection.execute(
+ "UPDATE recurrence_rules SET document=?", (schedule_document(bounded).encoded,)
+ )
+ connection.execute(
+ "UPDATE recurrence_progress SET document=?",
+ (
+ progress_document(
+ replace(
+ progress,
+ cursor=FixedCursor(cursor_binding(bounded.pattern, bounded.policy)),
+ )
+ ).encoded,
+ ),
+ )
+ connection.execute(
+ "UPDATE recurrence_slots SET slot_key=?,nominal_value=?,document=?",
+ (later.key, "2026-01-02", json.dumps(document)),
+ )
+ connection.execute(
+ "UPDATE receipts SET command_key=?,result=? WHERE command_key=?",
+ (later.key, result, value.key),
+ )
+ connection.execute(
+ "UPDATE audit SET command_key=? WHERE command_key=?", (later.key, value.key)
+ )
+ connection.execute(
+ "UPDATE outbox SET payload=? WHERE event_type='recurrence.materialize'", (result,)
+ )
+ assert connection.execute("PRAGMA foreign_key_check").fetchall() == []
+ before = store.path.read_bytes()
+ with store.read() as unit:
+ assert unit.recurrence.schedule("home", "rule") == bounded
+ assert unit.receipts.get(later.command).result.decode()["slot_key"] == later.key
+ with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
+ unit.recurrence.slot("home", later.key)
+ with pytest.raises(DatabaseRejected, match="invalid_snapshot_records"):
+ SQLiteSnapshots(store, tmp_path / "bad-count", store.installation, Clock()).create("bad")
+ assert store.path.read_bytes() == before
+
+
@pytest.mark.parametrize(
"table,column,value,kind",
[
diff --git a/tests/backend/test_sqlite_recurrence_records.py b/tests/backend/test_sqlite_recurrence_records.py
index bebcea7..3c70278 100644
--- a/tests/backend/test_sqlite_recurrence_records.py
+++ b/tests/backend/test_sqlite_recurrence_records.py
@@ -35,7 +35,7 @@ def test_closed_codecs_round_trip_every_pattern_selector_and_time_snapshot(timed
Frequency.YEARLY,
2,
Weekday.SU,
- (ByDay(Weekday.MO, 1), ByDay(Weekday.FR)),
+ (ByDay(Weekday.MO, 1), ByDay(Weekday.TH)),
(1, 12),
(-1, 1),
(-1, 1),
diff --git a/tests/containers/test_recurrence_image.py b/tests/containers/test_recurrence_image.py
index affe9fe..735dc42 100644
--- a/tests/containers/test_recurrence_image.py
+++ b/tests/containers/test_recurrence_image.py
@@ -4,7 +4,7 @@
from test_app_image import image as image
-def test_current_image_executes_recurrence_planning_and_closed_record_codecs(image):
+def test_current_image_executes_recurrence_creation_planning_and_closed_record_codecs(image):
result = docker(
"run",
"--rm",
@@ -17,15 +17,25 @@ def test_current_image_executes_recurrence_planning_and_closed_record_codecs(ima
image,
"-c",
"""
+from dataclasses import replace
from datetime import datetime
+from pathlib import Path
+from tempfile import TemporaryDirectory
from domus.application.fixed_recurrence import FixedRecurrencePlanner
+from domus.application.household_commands import HouseholdCommands, SetupHousehold
+from domus.application.recurrence_commands import CreateFixedTask, RecurrenceCommands
+from domus.application.task_requests import CreateTask
+from domus.domain.errors import InvalidInput
+from domus.domain.identity import HumanIdentity
from domus.domain.nominal_recurrence import CalendarNominalExpansion
from domus.domain.recurrence_records import FixedProgress, FixedSchedule, ScheduledSlot
from domus.domain.recurrence_rule_text import parse_fixed_pattern
from domus.domain.recurrence_time import NominalTime, TimePolicy, TimeResolution
from domus.domain.storage import Installation
+from domus.domain.task_values import TaskContent
+from domus.infrastructure.application_runtime import SecureIdentifiers, SystemClock, TimeZoneCatalog
from domus.infrastructure.recurrence_time import PinnedRecurrenceTime, TIMEZONE_DATA_IDENTITY
-from domus.infrastructure.sqlite import SCHEMA_VERSION
+from domus.infrastructure.sqlite import SCHEMA_VERSION, SQLiteDatabase
from domus.infrastructure.sqlite_recurrence_records import (
schedule_document, read_schedule, progress_document, read_progress, slot_document, read_slot,
)
@@ -46,8 +56,44 @@ def test_current_image_executes_recurrence_planning_and_closed_record_codecs(ima
assert read_progress(progress_document(progress).encoded) == progress
assert read_slot(slot_document(slot).encoded) == slot
assert progress.matches_schedule(rule) and slot.consistent_with(rule)
-print('Current-source recurrence codecs and pinned timezone planning passed.')
+try:
+ ScheduledSlot(installation, replace(rule, pattern=replace(pattern, count=1)),
+ planned.slots[1], 'outside-count', 100)
+except InvalidInput:
+ pass
+else:
+ raise AssertionError('A real second nominal cannot fit COUNT1')
+
+class FictionalHuman:
+ def current(self):
+ return HumanIdentity('fixture_ha_admin', True, True, False)
+
+class UnusedDirectory:
+ def get(self, user_id):
+ raise AssertionError('Default mapping selects no external directory subject')
+
+with TemporaryDirectory(prefix='domus-current-image-') as directory:
+ store = SQLiteDatabase(Path(directory) / 'domus.sqlite3', installation)
+ store.initialize()
+ clock, identifiers, human = SystemClock(), SecureIdentifiers(), FictionalHuman()
+ HouseholdCommands(store, human, UnusedDirectory(), TimeZoneCatalog(), clock, identifiers).setup(
+ 'setup', SetupHousehold('Fictional image fixture', 'UTC'))
+ creation = RecurrenceCommands(store, human, clock, identifiers,
+ FixedRecurrencePlanner(CalendarNominalExpansion(), times))
+ request = CreateFixedTask(CreateTask(TaskContent('Scheduled image fixture')), pattern, policy)
+ receipt = creation.create_fixed('create', request)
+ assert creation.create_fixed('create', request) == receipt
+ facts = receipt.result.decode()
+ with store.read() as unit:
+ assert unit.tasks.occurrences('home') == ()
+ assert len(unit.tasks.definitions('home')) == 1
+ assert unit.recurrence.progress('home', facts['schedule_id']).cursor.nominal_count == 0
+ assert unit.receipts.get(receipt.command) == receipt
+print('Current-source recurrence creation, codecs and pinned timezone planning passed.')
""",
)
assert result.returncode == 0, result.stderr
- assert "Current-source recurrence codecs and pinned timezone planning passed." in result.stdout
+ assert (
+ "Current-source recurrence creation, codecs and pinned timezone planning passed."
+ in result.stdout
+ )
From c5819b8ffd217bde96b440a46971e6df77867099 Mon Sep 17 00:00:00 2001
From: Efra Espada
Date: Mon, 2 Nov 2026 00:42:27 +0100
Subject: [PATCH 06/16] feat: Materialize fixed work with atomic progress and
restore-safe replay
---
backend/src/domus/application/commands.py | 54 +-
.../application/fixed_materialization.py | 279 +++++++++
docs/architecture-recurrence.md | 25 +
docs/evidence/recurrence-materialization.json | 150 +++++
docs/evidence/recurrence-storage.json | 5 +-
specs/recurrence-storage-continuation.md | 6 +
tests/backend/test_fixed_materialization.py | 545 ++++++++++++++++++
tests/containers/test_recurrence_image.py | 18 +-
8 files changed, 1058 insertions(+), 24 deletions(-)
create mode 100644 backend/src/domus/application/fixed_materialization.py
create mode 100644 docs/evidence/recurrence-materialization.json
create mode 100644 tests/backend/test_fixed_materialization.py
diff --git a/backend/src/domus/application/commands.py b/backend/src/domus/application/commands.py
index 70471d1..f1cffbb 100644
--- a/backend/src/domus/application/commands.py
+++ b/backend/src/domus/application/commands.py
@@ -48,24 +48,38 @@ def execute(
apply: Callable[[UnitOfWork], Change],
) -> Receipt:
with self.transactions.write() as unit:
- self.authorization.require(command, unit)
- previous = unit.receipts.get(command)
- if previous is not None:
- if previous.payload_fingerprint != payload.fingerprint:
- raise Conflict("idempotency_conflict")
- return previous
- change = apply(unit)
- operation_id, created_at = self.identifiers.new(), self.clock.now()
- receipt = Receipt(command, payload.fingerprint, operation_id, change.result, created_at)
- unit.receipts.add(receipt)
- unit.audit.append(AuditEntry(operation_id, command, change.audit_detail, created_at))
- unit.outbox.append(
- OutboxEvent(
- operation_id,
- command.household_id,
- command.action,
- change.event_payload,
- created_at,
- )
+ return self.in_unit(unit, command, payload, apply)
+
+ def in_unit(
+ self,
+ unit: UnitOfWork,
+ command: CommandKey,
+ payload: JsonDocument,
+ apply: Callable[[UnitOfWork], Change],
+ ) -> Receipt:
+ """Join the caller's writer transaction; authorization and replay are never skipped.
+
+ This does not own a connection or commit. The composing use case must keep any related
+ cursor update in that same transaction, including an advance through retained proof.
+ """
+ self.authorization.require(command, unit)
+ previous = unit.receipts.get(command)
+ if previous is not None:
+ if previous.payload_fingerprint != payload.fingerprint:
+ raise Conflict("idempotency_conflict")
+ return previous
+ change = apply(unit)
+ operation_id, created_at = self.identifiers.new(), self.clock.now()
+ receipt = Receipt(command, payload.fingerprint, operation_id, change.result, created_at)
+ unit.receipts.add(receipt)
+ unit.audit.append(AuditEntry(operation_id, command, change.audit_detail, created_at))
+ unit.outbox.append(
+ OutboxEvent(
+ operation_id,
+ command.household_id,
+ command.action,
+ change.event_payload,
+ created_at,
)
- return receipt
+ )
+ return receipt
diff --git a/backend/src/domus/application/fixed_materialization.py b/backend/src/domus/application/fixed_materialization.py
new file mode 100644
index 0000000..7e12fe0
--- /dev/null
+++ b/backend/src/domus/application/fixed_materialization.py
@@ -0,0 +1,279 @@
+"""One bounded local maintenance step commits actual fixed work or reconciles original proof."""
+
+from dataclasses import asdict, dataclass, replace
+from typing import cast
+
+from domus.application.commands import AtomicCommands, Change
+from domus.application.fixed_recurrence import FixedRecurrencePlanner
+from domus.application.ports import Clock, Identifiers, Transactions, UnitOfWork
+from domus.domain.errors import Conflict, Forbidden, InvalidInput, NotFound, StorageFailure
+from domus.domain.recurrence_planning import FixedBatch, FixedCursor
+from domus.domain.recurrence_records import (
+ MAINTENANCE_ACTOR,
+ FixedProgress,
+ FixedSchedule,
+ ScheduledSlot,
+ ScheduleState,
+)
+from domus.domain.recurrence_rules import NominalDate, NominalSlot
+from domus.domain.storage import (
+ CommandKey,
+ Installation,
+ JsonDocument,
+ Receipt,
+ validate_identifier,
+)
+from domus.domain.task_values import DefinitionState
+from domus.domain.tasks import TaskDefinition
+
+
+@dataclass(frozen=True, slots=True)
+class MaintenanceScope:
+ """Composition owns this local capability; it is never decoded from HTTP or a stored rule."""
+
+ installation: Installation
+ household_id: str
+
+ def __post_init__(self) -> None:
+ validate_identifier(self.household_id)
+ if not isinstance(self.installation, Installation):
+ raise InvalidInput("invalid_maintenance_scope")
+
+ def require(self, unit: UnitOfWork) -> None:
+ household = unit.households.current()
+ if (
+ unit.recurrence.installation != self.installation
+ or household is None
+ or household.household_id != self.household_id
+ ):
+ raise Forbidden("maintenance_scope_denied")
+
+
+@dataclass(frozen=True, slots=True)
+class FixedStep:
+ status: str
+ schedule_id: str
+ cursor: FixedCursor
+ range_complete: bool
+ periods_scanned: int = 0
+ receipt: Receipt | None = None
+ occurrence_id: str | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class _Source:
+ schedule: FixedSchedule
+ progress: FixedProgress
+ definition: TaskDefinition
+
+
+@dataclass(frozen=True, slots=True)
+class _FixedAuthority:
+ scope: MaintenanceScope
+ source: _Source
+ command: CommandKey
+
+ def require(self, command: CommandKey, unit: UnitOfWork) -> None:
+ self.scope.require(unit)
+ rule = self.source.schedule
+ if (
+ command != self.command
+ or command.household_id != self.scope.household_id
+ or command.actor_id != MAINTENANCE_ACTOR
+ or command.action not in ("recurrence.materialize", "recurrence.scan")
+ or rule.state is not ScheduleState.ACTIVE
+ or self.source.definition.state is not DefinitionState.ACTIVE
+ ):
+ raise Forbidden("maintenance_scope_denied")
+ if (
+ unit.recurrence.schedule(rule.household_id, rule.schedule_id) != rule
+ or unit.recurrence.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")
+
+
+def _nominal(value: NominalSlot) -> dict[str, str]:
+ return {
+ "kind": "date" if isinstance(value, NominalDate) else "time",
+ "local": value.local.isoformat(),
+ }
+
+
+@dataclass
+class FixedMaintenance:
+ transactions: Transactions
+ scope: MaintenanceScope
+ planner: FixedRecurrencePlanner
+ clock: Clock
+ identifiers: Identifiers
+
+ def _source(self, unit: UnitOfWork, schedule_id: str) -> _Source:
+ self.scope.require(unit)
+ rule = unit.recurrence.schedule(self.scope.household_id, schedule_id)
+ if rule is None:
+ raise NotFound("recurrence_rule_missing")
+ progress = unit.recurrence.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)
+
+ def step(self, schedule_id: str, before: NominalSlot, *, period_limit: int = 64) -> FixedStep:
+ validate_identifier(schedule_id)
+ if type(period_limit) is not int or not 1 <= period_limit <= 64:
+ raise InvalidInput("invalid_recurrence_limits")
+ with self.transactions.read() as unit:
+ source = self._source(unit, schedule_id)
+ rule, progress = source.schedule, source.progress
+ if rule.state is not ScheduleState.ACTIVE:
+ return FixedStep(rule.state.value, schedule_id, progress.cursor, False)
+ if source.definition.state is not DefinitionState.ACTIVE:
+ return FixedStep("definition_inactive", schedule_id, progress.cursor, False)
+ self.planner.times.require_policy(rule.policy)
+ batch = self.planner.expand(
+ rule.pattern,
+ rule.policy,
+ cursor=progress.cursor,
+ before=before,
+ slot_limit=1,
+ period_limit=period_limit,
+ )
+ if (
+ not batch.slots
+ and batch.cursor == progress.cursor
+ and batch.range_complete == progress.range_complete
+ and progress.last_run_at is not None
+ ):
+ return FixedStep(
+ "current", schedule_id, batch.cursor, batch.range_complete, batch.periods_scanned
+ )
+ return self._commit(source, batch, before)
+
+ def _commit(self, source: _Source, batch: FixedBatch, before: NominalSlot) -> FixedStep:
+ rule, progress = source.schedule, source.progress
+ planned = batch.slots[0] if batch.slots else None
+ probe = (
+ None
+ if planned is None
+ else ScheduledSlot(self.scope.installation, rule, planned, "pending", self.clock.now())
+ )
+ if probe is None:
+ payload = JsonDocument.from_value(
+ {
+ "version": 1,
+ "schedule_id": rule.schedule_id,
+ "pattern_revision": rule.pattern_revision,
+ "from_revision": progress.revision,
+ "from_cursor": asdict(progress.cursor),
+ "to_cursor": asdict(batch.cursor),
+ "before": _nominal(before),
+ }
+ )
+ command = CommandKey(
+ rule.household_id, MAINTENANCE_ACTOR, "recurrence.scan", payload.fingerprint
+ )
+ else:
+ resolved = probe.planned.resolved
+ payload = JsonDocument.from_value(
+ {
+ "version": 1,
+ "slot_key": probe.key,
+ "definition_id": rule.definition_id,
+ "pattern": rule.pattern.fingerprint,
+ "nominal": _nominal(probe.planned.nominal),
+ "resolved_at": None if resolved is None else resolved.instant_ms,
+ "zone_digest": None if resolved is None else resolved.zone_digest,
+ }
+ )
+ command = probe.command
+ authority = _FixedAuthority(self.scope, source, command)
+ commands = AtomicCommands(self.transactions, authority, self.clock, self.identifiers)
+ created = False
+
+ def apply(unit: UnitOfWork) -> Change:
+ nonlocal created
+ created = True
+ if planned is None:
+ result = JsonDocument.from_value(
+ {
+ "schedule_id": rule.schedule_id,
+ "processed_slots": 0,
+ "cursor": asdict(batch.cursor),
+ "range_complete": batch.range_complete,
+ }
+ )
+ else:
+ work = source.definition.new_occurrence(
+ self.identifiers.new(),
+ self.clock.now(),
+ due_at=None if planned.resolved is None else planned.resolved.instant_ms,
+ )
+ slot = ScheduledSlot(
+ self.scope.installation, rule, planned, work.occurrence_id, work.created_at
+ )
+ unit.tasks.save_occurrence(work, 0)
+ unit.recurrence.add_slot(slot)
+ result = JsonDocument.from_value(
+ {
+ "slot_key": slot.key,
+ "occurrence_id": work.occurrence_id,
+ "schedule_id": rule.schedule_id,
+ "definition_id": rule.definition_id,
+ "nominal": _nominal(planned.nominal),
+ "resolved_at": work.due_at,
+ }
+ )
+ return Change(
+ result,
+ JsonDocument.from_value({"request": payload.decode(), "result": result.decode()}),
+ JsonDocument.from_value({"version": 1, "kind": command.action, **result.decode()}),
+ )
+
+ with self.transactions.write() as unit:
+ receipt = commands.in_unit(unit, command, payload, apply)
+ occurrence_id = None
+ status = "scan"
+ if probe is not None:
+ data = receipt.result.decode()
+ occurrence_id = data.get("occurrence_id")
+ try:
+ validate_identifier(cast(str, occurrence_id))
+ if data.get("slot_key") != probe.key:
+ raise InvalidInput("invalid_recurrence_effect")
+ except InvalidInput:
+ raise StorageFailure("invalid_recurrence_effect") from None
+ saved = unit.recurrence.slot(rule.household_id, probe.key)
+ if receipt.historical:
+ if saved is not None:
+ raise StorageFailure("invalid_recurrence_effect")
+ status = "historical_absent"
+ else:
+ if (
+ saved is None
+ or saved.occurrence_id != occurrence_id
+ or not saved.consistent_with(rule)
+ or saved.planned != planned
+ ):
+ raise StorageFailure("invalid_recurrence_effect")
+ status = "created" if created else "replayed"
+ unit.recurrence.save_progress(
+ replace(
+ progress,
+ cursor=batch.cursor,
+ revision=progress.revision + 1,
+ last_run_at=self.clock.now(),
+ range_complete=batch.range_complete,
+ ),
+ progress.revision,
+ )
+ return FixedStep(
+ status,
+ rule.schedule_id,
+ batch.cursor,
+ batch.range_complete,
+ batch.periods_scanned,
+ receipt,
+ cast(str | None, occurrence_id),
+ )
diff --git a/docs/architecture-recurrence.md b/docs/architecture-recurrence.md
index 4f8c915..79fc05e 100644
--- a/docs/architecture-recurrence.md
+++ b/docs/architecture-recurrence.md
@@ -154,3 +154,28 @@ not imply runner execution, task performance, points, inventory consumption or d
This is an application use case, with no new public route or deployed interface yet. Draft/relative
configuration, reviewed attachment, protected operation recovery queries and full runtime/HA
acceptance remain required. See [creation evidence](evidence/recurrence-creation.json).
+
+## One fixed-materialization step
+
+The application [maintenance step](../backend/src/domus/application/fixed_materialization.py) receives
+an explicit local installation/channel/household scope from trusted composition. It accepts no
+human actor or authority from a rule or HTTP body. An active source can create at most one real
+occurrence, or advance through at most 64 empty original periods. Inactive rules/definitions retain
+their current work. Policy lookup and planning occur outside the writer transaction; source revisions
+and the narrow technical command namespace are checked again before reading original receipts.
+
+The shared [command boundary](../backend/src/domus/application/commands.py) can join that same
+caller-owned transaction without committing independently. New task/slot/progress and original
+receipt/audit/outbox commit or roll back together. A current original receipt advances a replayed
+cursor without another effect. An actual restored-away slot returns its original historical receipt
+and advances only the reconciled cursor; it creates no replacement task, identities or duplicate
+event. Contradictory current effects, corrupted original results and changed payloads fail safely.
+
+An empty bounded scan records its continuation without claiming a complete calendar. One step's
+range-complete result describes the requested horizon at that observation; the persisted Boolean
+alone is not proof of arbitrary calendar-range completeness. Date-only obligations retain dates and
+no invented UTC midnight. Technical attribution cannot act as a performer, approval or point award.
+
+This is not yet a running scheduler. Fair multi-rule continuation, global budgets and shutdown,
+durable review/calendar attention, relative sources and native/standalone background composition
+remain required. See [step evidence](evidence/recurrence-materialization.json).
diff --git a/docs/evidence/recurrence-materialization.json b/docs/evidence/recurrence-materialization.json
new file mode 100644
index 0000000..d20c645
--- /dev/null
+++ b/docs/evidence/recurrence-materialization.json
@@ -0,0 +1,150 @@
+{
+ "status": "partial; one actual fixed materialization/reconciliation step implemented; production loop and complete recurrence remain in progress",
+ "recordedAtUTC": "2026-11-01T23:40:51.401615+00:00",
+ "issue": "https://github.com/vypdev/domus/issues/20",
+ "pullRequest": "https://github.com/vypdev/domus/pull/22",
+ "sourceBase": "9c12953232c0fb967663c1853e5dc48112379623",
+ "backendSourceInventorySHA256": "bb4d3bfe2bdfa3f74a28b0b84c5435c94b0c42c5d79e710a54b17b29d76b311e",
+ "sourceManifestMethod": "SHA256 of compact sorted JSON mapping all authored Python module paths, including package initializers, to their byte SHA256 values.",
+ "implemented": [
+ "Composition-supplied installation/channel/household scope with a fixed technical namespace, separate from human identity and performer authority",
+ "One actual generated occurrence or at most 64 original empty periods per step; pinned date-only/instant policy validation and planning outside writer lock",
+ "Current exact rule/progress/definition checks before original receipt lookup; source changes invalidate planned writes",
+ "Shared AtomicCommands.in_unit joins the existing caller-owned transaction without independent commit or skipped authority/replay",
+ "Real task/slot/progress/original receipt/audit/outbox commit or roll back together",
+ "Current original proof avoids another effect; actual offline restore retains historical absence and advances only reconciled progress without new identity/task/event",
+ "Bounded empty-scan receipts preserve incomplete continuation; date-only tasks retain no fabricated UTC deadline",
+ "Corrupted original result/payload, missing/conflicting current effect and contradictory history projections fail safely"
+ ],
+ "testsFirst": {
+ "initial": "Collection failed with missing domus.application.fixed_materialization before implementation; original output retained.",
+ "authorityOrder": "A targeted public-use-case regression failed on a protected receipt lookup after the rule changed. Implementation now performs every such lookup only inside the shared authorization boundary; original failure and passing regression retained."
+ },
+ "focused": {
+ "executions": 67,
+ "durationSeconds": 1.79,
+ "scope": "The campaign covers the new step and existing atomic/creation regressions. Product journey acceptance is tracked separately.",
+ "authoredModules": 2,
+ "coverageTotals": {
+ "covered_lines": 147,
+ "num_statements": 147,
+ "percent_covered": 100.0,
+ "percent_covered_display": "100.00",
+ "missing_lines": 0,
+ "excluded_lines": 0,
+ "percent_statements_covered": 100.0,
+ "percent_statements_covered_display": "100.00",
+ "num_branches": 40,
+ "num_partial_branches": 0,
+ "covered_branches": 40,
+ "missing_branches": 0,
+ "percent_branches_covered": 100.0,
+ "percent_branches_covered_display": "100.00"
+ }
+ },
+ "completeBackend": {
+ "executions": 3584,
+ "durationSeconds": 112.27,
+ "authoredModules": 126,
+ "coverageTotals": {
+ "covered_lines": 8954,
+ "num_statements": 8954,
+ "percent_covered": 100.0,
+ "percent_covered_display": "100.00",
+ "missing_lines": 0,
+ "excluded_lines": 269,
+ "percent_statements_covered": 100.0,
+ "percent_statements_covered_display": "100.00",
+ "num_branches": 2546,
+ "num_partial_branches": 0,
+ "covered_branches": 2546,
+ "missing_branches": 0,
+ "percent_branches_covered": 100.0,
+ "percent_branches_covered_display": "100.00"
+ },
+ "independentPerModuleAudit": "passed"
+ },
+ "checks": {
+ "strictTypes": {
+ "passed": true,
+ "files": 126
+ },
+ "ruff": "check and format passed;316 files formatted",
+ "architecture": {
+ "contractsKept": 3,
+ "contractsBroken": 0,
+ "filesAnalyzed": 170,
+ "dependencies": 1070
+ },
+ "originalPinnedSdd": {
+ "upstream": "3e7ceea08e6131360e026937d1c0cc1b120472e0",
+ "base": "0806a38f8a2de1f102a017bff25ad6856fb17bcd",
+ "selected": "specs/recurrence-storage-continuation.md",
+ "originalPolicyModulesVerified": 7,
+ "format": "PASS",
+ "consistency": "PASS",
+ "composition": "explicit contributor integration; original publication retained in Git",
+ "unchangedCatalog": "PASS",
+ "immutableOwnerDocumentsUsed": 18,
+ "retainedCompanionCases": 52,
+ "newCases": 4,
+ "rawCharacters": 23882,
+ "renderedCharacters": 23882,
+ "composedCharacters": 23882,
+ "applicableDraftMaximumCharacters": 70000,
+ "sha256": "70dc7362f47a59b6f6d9d81c13f9ba7a4e8d720243f58a33ccc4b6c33f448f5c",
+ "scope": "Review input only; no gate admission, branch publication, source implementation or runtime acceptance.",
+ "retainedOwningFilesUnchanged": true,
+ "workingDocumentValidated": true
+ },
+ "imageControlOnHost": "Exact current image script executes real SQLite creation, both COUNT2 steps, terminal no-op and pinned gap facts successfully on host. Actual both-architecture image jobs remain required after source publication.",
+ "packageAndArchitectureTests": {
+ "passed": true,
+ "executions": 52,
+ "durationSeconds": 7.69,
+ "requires": "Fresh current backend wheel with the unchanged retained frontend bundle; hosted current-source images rebuild UI separately."
+ }
+ },
+ "actualRestore": {
+ "method": "Real SQLiteSnapshots, SQLiteRestorations and offline SQLiteActivation, followed by open_storage on the activated generation.",
+ "observed": "The old cursor advances through the exact retained absent-effect receipt; no replacement task/slot/identity or duplicate event appears, and the next terminal step performs no new work."
+ },
+ "concurrency": "Two independent SQLite writers plan the same actual source. Exactly one commits a complete occurrence; the other returns source conflict or bounded storage contention, never another effect.",
+ "limits": {
+ "step": "At most one generated slot, or at most 64 original periods; current step result refers only to the explicitly requested horizon.",
+ "rangeComplete": "Persisted range_complete alone does not prove arbitrary calendar completeness; future protected queries need the actual requested-range/source/attention predicate.",
+ "scope": "Internal application component only. No HTTP body grants maintenance authority, and no background loop or owner installation starts."
+ },
+ "pending": [
+ "Fair durable multi-rule continuation, global limits of 1,000 slots and 50 definitions, shutdown ownership and both production runners",
+ "Completion-relative persistence and actual lifecycle source/cycle consumption",
+ "Durable historical/review/collision attention and truthful protected calendar predicates/queries",
+ "Reviewed attachment, edits/exceptions/preview, task category/classification and public HTTP/operation recovery surfaces",
+ "Household timing-policy configuration/default wiring, full UI/HA surfaces, actual current-source image evidence and the retained 52 companion and 37 owner cases plus first all-domain release acceptance"
+ ],
+ "priorHeadVerification": {
+ "headRevision": "9c12953232c0fb967663c1853e5dc48112379623",
+ "ciRun": 41361528775,
+ "ciCheck": 125014080825,
+ "allApplicableProductSuites": "passed",
+ "amd64Job": 125012729341,
+ "aarch64Job": 125012729367,
+ "nativeReview": {
+ "jobId": 125012728997,
+ "reviewCheck": 125013191207,
+ "partitionsCompleted": 5,
+ "partitionsAssigned": 5,
+ "filesAssigned": 52,
+ "diffFragments": 57,
+ "candidateFindings": 0,
+ "publishedFindings": 0,
+ "fixedFindings": 1,
+ "openFindings": 0,
+ "overall": "partial/neutral due to human-conversation dimension; complete code coverage and manually inspected proposed-SDD inputs remain distinct."
+ },
+ "knownCopilotProgressFailure": "validation.evidence-too-large, reference 894d37b9-1732-4cf7-8237-19e46946ab5e, upstream issue #851; no unchanged-input rerun or limit alteration."
+ },
+ "ownerHa": "Installed Edge.5 and retained Edge.3 remain untouched; no release, registry publication or deployment.",
+ "copilot": "No Copilot source, configuration, credential, scope or evidence bound changed. Existing reports #851 and #843 include the exact latest native facts.",
+ "retainedEvidenceDirectory": "/private/tmp/domus-materialization-9c12953"
+}
diff --git a/docs/evidence/recurrence-storage.json b/docs/evidence/recurrence-storage.json
index 7b0a0ba..f4c0a17 100644
--- a/docs/evidence/recurrence-storage.json
+++ b/docs/evidence/recurrence-storage.json
@@ -85,7 +85,7 @@
"policyModulesByteVerified": 7,
"immutableBaseOwners": 18,
"retainedOwnersUnchanged": true,
- "currentCompanionSHA256": "890d10fb2c9abd3717f984853f178e15976423bdc2e49371eb7ad0ac4b0ab341",
+ "currentCompanionSHA256": "70dc7362f47a59b6f6d9d81c13f9ba7a4e8d720243f58a33ccc4b6c33f448f5c",
"scope": "Explicit contributor integration; original first publication remains in Git. Format, reciprocal inventory/path consistency and current catalog pass."
},
"packageAndArchitectureTests": {
@@ -281,5 +281,6 @@
"amd64Job": 125009131683,
"aarch64Job": 125009131691,
"result": "All applicable CI Check suites passed; Copilot separately found one real nominal-membership issue, now corrected locally and awaiting publication/review. Full recurrence remains incomplete."
- }
+ },
+ "laterMaterializationIncrement": "docs/evidence/recurrence-materialization.json; original storage-kernel results remain historical and scope-specific."
}
diff --git a/specs/recurrence-storage-continuation.md b/specs/recurrence-storage-continuation.md
index 0bd8161..2b348cd 100644
--- a/specs/recurrence-storage-continuation.md
+++ b/specs/recurrence-storage-continuation.md
@@ -139,6 +139,8 @@ Use real independent SQLite connections and exact before/after version/row check
| backend/src/domus/application/ports.py | update |
| backend/src/domus/application/recurrence_ports.py | update |
| backend/src/domus/application/recurrence_commands.py | create |
+| backend/src/domus/application/fixed_materialization.py | create |
+| backend/src/domus/application/commands.py | update |
| backend/src/domus/application/task_creation.py | create |
| backend/src/domus/application/task_commands.py | update |
| backend/src/domus/application/task_authorization.py | update |
@@ -155,6 +157,7 @@ Use real independent SQLite connections and exact before/after version/row check
| tests/backend/test_sqlite_recurrence.py | create |
| tests/backend/test_recurrence_records.py | create |
| tests/backend/test_recurrence_creation.py | create |
+| tests/backend/test_fixed_materialization.py | create |
| tests/backend/test_recurrence_slot_membership.py | create |
| tests/backend/test_sqlite_recurrence_records.py | create |
| tests/backend/test_sqlite_recurrence_integrity.py | create |
@@ -173,6 +176,7 @@ Use real independent SQLite connections and exact before/after version/row check
| tests/backend/test_task_restoration.py | update |
| docs/architecture-recurrence.md | update |
| docs/evidence/recurrence-creation.json | create |
+| docs/evidence/recurrence-materialization.json | create |
| docs/tasks/migration.md | update |
| docs/evidence/recurrence-execution.json | update |
@@ -206,6 +210,8 @@ The next application increment implements new active fixed-task creation through
Bugbot identified a storage boundary missing from the first kernel: a correctly scoped slot could name a nominal outside its pattern or COUNT/UNTIL end. Validate actual finite-period candidate membership and nominal rank in the pure record constructor, so closed decoding and checkpoint inspection enforce the same invariant. COUNT rank preserves interval phase, initial-period truncation, omitted invalid dates and Gregorian repetition; bounded pure memoization is an optimization, never evidence or authority. Do not weaken the existing slot/receipt/source relationships or alter the schema to mask corrupt records. A codec fixture must itself describe a real candidate. These are refinements of retained RCE/storage integrity obligations, not additional counted product journeys.
+The next application step materializes at most one actual fixed obligation or scans at most 64 original periods. Trusted composition supplies its explicit installation/channel/household maintenance scope; no request or stored record grants that capability. External policy/planning work holds no writer lock. Current rule/progress/definition facts and the fixed technical command namespace are checked before original receipt lookup in the writer transaction. A new task, slot, receipt/audit/outbox and progress advance commit together through the shared command boundary. A matching current receipt does not create another task. An actual restored absent-effect receipt advances the reconciled cursor without recreating the task, allocating replacement identities or re-emitting its event; the returned historical status remains explicit. Empty bounded scans retain their own proof and incomplete continuation. Date-only values retain no fabricated deadline instant, and technical maintenance never performs or approves human work. The fair multi-rule production loop, durable attention/calendar projections, relative lifecycle and full runtime surfaces remain unfinished.
+
## 19. Definition of Done
- [ ] The four additional cases and every retained requirement/case have truthful current-source evidence; no checkpoint is described as full feature acceptance.
diff --git a/tests/backend/test_fixed_materialization.py b/tests/backend/test_fixed_materialization.py
new file mode 100644
index 0000000..1382206
--- /dev/null
+++ b/tests/backend/test_fixed_materialization.py
@@ -0,0 +1,545 @@
+"""A composition-scoped step creates real work and advances only with durable original proof."""
+
+from dataclasses import replace
+from datetime import date
+
+import pytest
+from test_recurrence_creation import request, setup
+from test_task_commands import counts
+
+from domus.application.fixed_materialization import FixedMaintenance, MaintenanceScope
+from domus.domain.errors import Conflict, Forbidden, StorageFailure
+from domus.domain.recurrence_planning import FixedCursor
+from domus.domain.recurrence_records import ScheduleState
+from domus.domain.recurrence_rules import NominalDate
+from domus.domain.recurrence_time import NominalTime
+from domus.infrastructure.sqlite import SQLiteAudit, SQLiteDatabase
+from domus.infrastructure.sqlite_recurrence import SQLiteRecurrences
+from domus.infrastructure.sqlite_tasks import SQLiteTasks
+
+
+def configured(tmp_path, *, timed=False, count=1):
+ creation, _, store, actor, identifiers = setup(tmp_path)
+ value = request(timed=timed)
+ value = replace(value, pattern=replace(value.pattern, count=count))
+ receipt = creation.create_fixed("scheduled", value)
+ rule_id = receipt.result.decode()["schedule_id"]
+ service = FixedMaintenance(
+ store,
+ MaintenanceScope(store.installation, "home"),
+ creation.planner,
+ creation.clock,
+ identifiers,
+ )
+ before = (
+ NominalTime(value.pattern.start.local.replace(day=31))
+ if timed
+ else NominalDate(date(2026, 3, 31))
+ )
+ return service, store, actor, identifiers, rule_id, before
+
+
+@pytest.mark.parametrize("timed", [False, True])
+def test_one_actual_step_commits_work_cursor_and_exact_original_evidence_without_human_performance(
+ tmp_path, timed
+):
+ service, store, actor, _, rule_id, before = configured(tmp_path, timed=timed)
+ actor_calls = actor.calls
+ initial = counts(store)
+ result = service.step(rule_id, before)
+ assert result.status == "created" and result.range_complete and result.cursor.closed
+ assert result.receipt.command.actor_id == "system:recurrence"
+ assert result.receipt.command.action == "recurrence.materialize"
+ with store.read() as unit:
+ slot = unit.recurrence.slot("home", result.receipt.command.key)
+ work = unit.tasks.occurrence("home", result.occurrence_id)
+ progress = unit.recurrence.progress("home", rule_id)
+ assert progress.cursor == result.cursor and progress.revision == 2
+ assert progress.last_run_at == 100 and progress.range_complete
+ assert work.current_cycle_id is None and work.assignee_ids == ()
+ assert slot.occurrence_id == work.occurrence_id
+ assert unit.receipts.get(slot.command) == result.receipt
+ assert result.receipt.result.decode()["slot_key"] == slot.key
+ assert unit.audit.entries("home")[-1].operation_id == result.receipt.operation_id
+ assert unit.outbox.pending()[-1].event_id == result.receipt.operation_id
+ if timed:
+ assert slot.planned.resolved.resolved_local.hour == 3
+ assert work.due_at == slot.planned.resolved.instant_ms
+ else:
+ assert slot.planned.nominal.local == date(2026, 3, 29) and work.due_at is None
+ assert counts(store) == (initial[0], 1, initial[2] + 1, initial[3] + 1)
+ assert actor.calls == actor_calls
+
+
+def test_restart_at_terminal_cursor_creates_no_more_work_or_evidence(tmp_path):
+ service, store, _, identifiers, rule_id, before = configured(tmp_path)
+ first = service.step(rule_id, before)
+ initial = counts(store)
+ allocated = identifiers.count
+ restarted = replace(service, transactions=SQLiteDatabase(store.path, store.installation))
+ second = restarted.step(rule_id, before)
+ assert second.status == "current" and second.receipt is None
+ assert second.cursor == first.cursor and second.range_complete
+ assert counts(store) == initial and identifiers.count == allocated
+
+
+@pytest.mark.parametrize("stage", ["occurrence", "slot", "audit", "progress"])
+def test_failure_after_any_new_effect_rolls_back_task_cursor_and_command_proofs(
+ tmp_path, monkeypatch, stage
+):
+ service, store, _, _, rule_id, before = configured(tmp_path)
+ target, name = {
+ "occurrence": (SQLiteTasks, "save_occurrence"),
+ "slot": (SQLiteRecurrences, "add_slot"),
+ "audit": (SQLiteAudit, "append"),
+ "progress": (SQLiteRecurrences, "save_progress"),
+ }[stage]
+ method = getattr(target, name)
+ initial = counts(store)
+ with store.read() as unit:
+ original = unit.recurrence.progress("home", rule_id)
+
+ def fail(self, *args):
+ method(self, *args)
+ raise StorageFailure("fixture_after_effect")
+
+ with monkeypatch.context() as patch:
+ patch.setattr(target, name, fail)
+ with pytest.raises(StorageFailure, match="fixture_after_effect"):
+ service.step(rule_id, before)
+ assert counts(store) == initial
+ with store.read() as unit:
+ assert unit.recurrence.progress("home", rule_id) == original
+ assert unit.recurrence.slots("home", rule_id) == ()
+ assert service.step(rule_id, before).status == "created"
+
+
+@pytest.mark.parametrize("change", ["channel", "installation", "household"])
+def test_maintenance_scope_never_crosses_installation_channel_or_household(tmp_path, change):
+ service, store, _, _, rule_id, before = configured(tmp_path)
+ scope = service.scope
+ if change == "household":
+ scope = replace(scope, household_id="other")
+ else:
+ scope = replace(
+ scope,
+ installation=replace(
+ scope.installation,
+ **(
+ {"channel": "stable"}
+ if change == "channel"
+ else {"instance_id": "00000000-0000-0000-0000-000000000001"}
+ ),
+ ),
+ )
+ initial = counts(store)
+ with pytest.raises(Forbidden):
+ replace(service, scope=scope).step(rule_id, before)
+ assert counts(store) == initial
+
+
+def test_paused_rule_has_no_effect_and_pause_after_planning_invalidates_the_write(
+ tmp_path, monkeypatch
+):
+ from domus.application.fixed_recurrence import FixedRecurrencePlanner
+
+ service, store, _, _, rule_id, before = configured(tmp_path)
+ original = FixedRecurrencePlanner.expand
+
+ def pause(self, *args, **kwargs):
+ result = original(self, *args, **kwargs)
+ with store.write() as unit:
+ rule = unit.recurrence.schedule("home", rule_id)
+ unit.recurrence.save_schedule(replace(rule, state=ScheduleState.PAUSED, revision=2), 1)
+ return result
+
+ initial = counts(store)
+ with monkeypatch.context() as patch:
+ patch.setattr(FixedRecurrencePlanner, "expand", pause)
+ with pytest.raises(Conflict, match="recurrence_source_changed"):
+ service.step(rule_id, before)
+ assert service.step(rule_id, before).status == "paused"
+ assert counts(store) == initial
+
+
+def test_replaying_a_proved_current_slot_advances_progress_without_duplicate_work(tmp_path):
+ service, store, _, identifiers, rule_id, before = configured(tmp_path)
+ first = service.step(rule_id, before)
+ initial = counts(store)
+ allocated = identifiers.count
+ with store.write() as unit:
+ progress = unit.recurrence.progress("home", rule_id)
+ unit.recurrence.save_progress(
+ replace(
+ progress,
+ revision=3,
+ cursor=FixedCursor(progress.cursor.binding),
+ range_complete=False,
+ ),
+ 2,
+ )
+ second = service.step(rule_id, before)
+ assert second.status == "replayed" and second.receipt == first.receipt
+ assert counts(store) == initial and identifiers.count == allocated
+
+
+def test_actual_restore_retains_absent_slot_proof_and_step_never_recreates_it(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)
+ service, store, _, identifiers, rule_id, before = configured(root)
+ identity_at(root, store.installation)
+ archive = tmp_path / "checkpoint"
+ target = tmp_path / "prepared"
+ checkpoint = SQLiteSnapshots(store, archive, store.installation, Clock()).create("before-slot")
+ first = service.step(rule_id, before)
+ SQLiteRestorations(store, archive, target, store.installation, Clock(), checkpoint).prepare(
+ "restore-slot"
+ )
+ assert SQLiteActivation(root, store.installation, Clock(), target).activate().state == "active"
+ storage = open_storage(root, "edge")
+ try:
+ allocated = identifiers.count
+ second = replace(service, transactions=storage.database).step(rule_id, before)
+ assert second.status == "historical_absent" and second.receipt.historical
+ assert second.receipt.operation_id == first.receipt.operation_id
+ assert second.receipt.archived_by == "restore-slot" and identifiers.count == allocated
+ with storage.database.read() as unit:
+ assert unit.tasks.occurrences("home") == ()
+ assert unit.recurrence.slots("home", rule_id) == ()
+ assert unit.recurrence.progress("home", rule_id).cursor.closed
+ assert all(
+ event.event_id != first.receipt.operation_id for event in unit.outbox.pending()
+ )
+ assert (
+ replace(service, transactions=storage.database).step(rule_id, before).status
+ == "current"
+ )
+ finally:
+ storage.close()
+
+
+def test_changed_source_is_rejected_before_protected_receipt_lookup(tmp_path, monkeypatch):
+ from domus.application.fixed_recurrence import FixedRecurrencePlanner
+ from domus.infrastructure.sqlite import SQLiteReceipts
+
+ service, store, _, _, rule_id, before = configured(tmp_path)
+ expand = FixedRecurrencePlanner.expand
+
+ def change(self, *args, **kwargs):
+ batch = expand(self, *args, **kwargs)
+ with store.write() as unit:
+ rule = unit.recurrence.schedule("home", rule_id)
+ unit.recurrence.save_schedule(replace(rule, revision=2, state=ScheduleState.PAUSED), 1)
+ return batch
+
+ def denied_lookup(*args):
+ pytest.fail("Current maintenance authority must precede any protected receipt lookup")
+
+ monkeypatch.setattr(FixedRecurrencePlanner, "expand", change)
+ monkeypatch.setattr(SQLiteReceipts, "get", denied_lookup)
+ with pytest.raises(Conflict, match="recurrence_source_changed"):
+ service.step(rule_id, before)
+
+
+def rewind(store, rule_id):
+ with store.write() as unit:
+ progress = unit.recurrence.progress("home", rule_id)
+ unit.recurrence.save_progress(
+ replace(
+ progress,
+ revision=progress.revision + 1,
+ cursor=FixedCursor(progress.cursor.binding),
+ range_complete=False,
+ ),
+ progress.revision,
+ )
+
+
+@pytest.mark.parametrize("limit", [0, 65, True])
+def test_invalid_step_budgets_fail_before_any_effect(tmp_path, limit):
+ from domus.domain.errors import InvalidInput
+
+ service, store, _, _, rule_id, before = configured(tmp_path)
+ initial = counts(store)
+ with pytest.raises(InvalidInput):
+ service.step(rule_id, before, period_limit=limit)
+ assert counts(store) == initial
+
+
+def test_a_future_horizon_scan_has_its_own_bounded_proof_and_never_consumes_first_work(tmp_path):
+ service, store, _, _, rule_id, before = configured(tmp_path)
+ earlier = NominalDate(date(2026, 3, 28))
+ result = service.step(rule_id, earlier)
+ assert result.status == "scan" and result.range_complete and result.cursor.nominal_count == 0
+ assert result.receipt.command.action == "recurrence.scan" and result.occurrence_id is None
+ initial = counts(store)
+ assert service.step(rule_id, earlier).status == "current" and counts(store) == initial
+ assert service.step(rule_id, before).status == "created"
+
+
+@pytest.mark.parametrize("change", ["missing-progress", "missing-definition", "wrong-binding"])
+def test_inconsistent_durable_source_is_not_an_empty_or_successful_step(tmp_path, change):
+ from domus.infrastructure.sqlite_recurrence_records import progress_document
+
+ service, store, _, _, rule_id, before = configured(tmp_path)
+ with store._connection() as connection:
+ if change == "missing-progress":
+ connection.execute("DELETE FROM recurrence_progress")
+ elif change == "missing-definition":
+ connection.execute("DELETE FROM task_definitions")
+ else:
+ with store.read() as unit:
+ progress = unit.recurrence.progress("home", rule_id)
+ connection.execute(
+ "UPDATE recurrence_progress SET document=?",
+ (progress_document(replace(progress, cursor=FixedCursor("a" * 64))).encoded,),
+ )
+ with pytest.raises(StorageFailure, match="invalid_recurrence_source"):
+ service.step(rule_id, before)
+
+
+def test_missing_rule_empty_installation_and_untyped_capability_have_distinct_safe_failures(
+ tmp_path,
+):
+ from test_atomic_commands import store_at
+
+ from domus.domain.errors import InvalidInput, NotFound
+
+ service, _, _, _, _, before = configured(tmp_path)
+ with pytest.raises(NotFound, match="recurrence_rule_missing"):
+ service.step("absent", before)
+ with pytest.raises(InvalidInput):
+ MaintenanceScope(None, "home")
+ empty = store_at(tmp_path / "empty.sqlite3")
+ with pytest.raises(Forbidden):
+ replace(service, transactions=empty).step("absent", before)
+
+
+@pytest.mark.parametrize("change", ["retired", "definition-paused"])
+def test_inactive_source_remains_unchanged(tmp_path, change):
+ from domus.domain.task_values import DefinitionState
+
+ service, store, _, _, rule_id, before = configured(tmp_path)
+ with store.write() as unit:
+ rule = unit.recurrence.schedule("home", rule_id)
+ if change == "retired":
+ unit.recurrence.save_schedule(replace(rule, state=ScheduleState.RETIRED, revision=2), 1)
+ else:
+ item = unit.tasks.definition("home", rule.definition_id)
+ unit.tasks.save_definition(replace(item, state=DefinitionState.PAUSED, revision=2), 1)
+ initial = counts(store)
+ assert service.step(rule_id, before).status == (
+ "retired" if change == "retired" else "definition_inactive"
+ )
+ assert counts(store) == initial
+
+
+@pytest.mark.parametrize("change", ["occurrence-id", "slot-key", "missing-slot", "future-snapshot"])
+def test_corrupt_current_original_effect_is_not_recreated_or_consumed_as_valid_progress(
+ tmp_path, change
+):
+ import json
+
+ service, store, _, _, rule_id, before = configured(tmp_path)
+ original = service.step(rule_id, before)
+ rewind(store, rule_id)
+ with store._connection() as connection:
+ if change in ("occurrence-id", "slot-key"):
+ data = original.receipt.result.decode()
+ data["occurrence_id" if change == "occurrence-id" else "slot_key"] = (
+ "" if change == "occurrence-id" else "wrong"
+ )
+ connection.execute(
+ "UPDATE receipts SET result=? WHERE command_key=?",
+ (json.dumps(data), original.receipt.command.key),
+ )
+ elif change == "missing-slot":
+ connection.execute("DELETE FROM recurrence_slots")
+ else:
+ row = connection.execute("SELECT document FROM recurrence_slots").fetchone()
+ data = json.loads(row[0])
+ data["schedule"]["revision"] = 2
+ connection.execute("UPDATE recurrence_slots SET document=?", (json.dumps(data),))
+ initial = counts(store)
+ with pytest.raises(StorageFailure, match="invalid_recurrence_effect"):
+ service.step(rule_id, before)
+ assert counts(store) == initial
+ with store.read() as unit:
+ assert unit.recurrence.progress("home", rule_id).cursor.nominal_count == 0
+
+
+def test_contradictory_historical_receipt_projection_cannot_hide_a_current_effect(
+ tmp_path, monkeypatch
+):
+ from domus.domain.storage import RestorationProvenance
+ from domus.infrastructure.sqlite import SQLiteReceipts
+
+ service, store, _, _, rule_id, before = configured(tmp_path)
+ first = service.step(rule_id, before)
+ rewind(store, rule_id)
+ original = SQLiteReceipts.get
+
+ def inconsistent(self, command):
+ result = original(self, command)
+ return (
+ replace(result, restoration=RestorationProvenance("fixture_restore", False))
+ if result == first.receipt
+ else result
+ )
+
+ monkeypatch.setattr(SQLiteReceipts, "get", inconsistent)
+ with pytest.raises(StorageFailure, match="invalid_recurrence_effect"):
+ service.step(rule_id, before)
+
+
+@pytest.mark.parametrize(
+ "change",
+ ["input-key", "household", "human-actor", "human-action", "paused", "inactive-definition"],
+)
+def test_technical_authority_cannot_be_repurposed_for_another_command_or_inactive_record(
+ tmp_path, change
+):
+ from domus.application.fixed_materialization import _FixedAuthority
+ from domus.domain.storage import CommandKey
+ from domus.domain.task_values import DefinitionState
+
+ service, store, _, _, rule_id, _ = configured(tmp_path)
+ command = CommandKey("home", "system:recurrence", "recurrence.materialize", "slot")
+ with store.read() as unit:
+ source = service._source(unit, rule_id)
+ if change == "household":
+ command = replace(command, household_id="other")
+ elif change == "human-actor":
+ command = replace(command, actor_id="ha_admin")
+ elif change == "human-action":
+ command = replace(command, action="task.complete")
+ elif change == "paused":
+ source = replace(source, schedule=replace(source.schedule, state=ScheduleState.PAUSED))
+ elif change == "inactive-definition":
+ source = replace(
+ source, definition=replace(source.definition, state=DefinitionState.PAUSED)
+ )
+ authority = _FixedAuthority(service.scope, source, command)
+ submitted = replace(command, key="other") if change == "input-key" else command
+ with pytest.raises(Forbidden):
+ authority.require(submitted, unit)
+
+
+def test_empty_period_work_exhausts_its_budget_with_durable_incomplete_progress(tmp_path):
+ from domus.domain.recurrence_planning import cursor_binding
+ from domus.domain.recurrence_rules import Frequency
+
+ service, store, _, _, rule_id, _ = configured(tmp_path)
+ with store.write() as unit:
+ rule = unit.recurrence.schedule("home", rule_id)
+ pattern = replace(
+ rule.pattern, frequency=Frequency.YEARLY, by_month=(2,), by_month_day=(30,), count=None
+ )
+ rule = replace(rule, revision=2, pattern_revision=2, pattern=pattern)
+ progress = unit.recurrence.progress("home", rule_id)
+ unit.recurrence.save_schedule(rule, 1)
+ unit.recurrence.save_progress(
+ replace(
+ progress,
+ revision=2,
+ pattern_revision=2,
+ cursor=FixedCursor(cursor_binding(pattern, rule.policy)),
+ ),
+ 1,
+ )
+ initial = counts(store)
+ first = service.step(rule_id, NominalDate(date(2036, 1, 1)), period_limit=3)
+ assert first.status == "scan" and first.periods_scanned == 3 and not first.range_complete
+ assert first.cursor.period_index == 3 and first.cursor.nominal_count == 0
+ with store.read() as unit:
+ assert unit.recurrence.progress("home", rule_id).cursor == first.cursor
+ second = service.step(rule_id, NominalDate(date(2036, 1, 1)), period_limit=3)
+ assert (
+ second.cursor.period_index == 6 and second.receipt.command.key != first.receipt.command.key
+ )
+ assert counts(store) == (initial[0], 0, initial[2] + 2, initial[3] + 2)
+
+
+def test_actual_independent_step_writers_have_one_complete_winner(tmp_path, monkeypatch):
+ from concurrent.futures import ThreadPoolExecutor
+ from threading import Barrier
+
+ from domus.application.fixed_recurrence import FixedRecurrencePlanner
+ from domus.domain.errors import StorageBusy
+ from domus.infrastructure.application_runtime import SecureIdentifiers
+
+ service, store, _, _, rule_id, before = configured(tmp_path)
+ independent = replace(
+ service,
+ transactions=SQLiteDatabase(store.path, store.installation, busy_timeout=5),
+ identifiers=SecureIdentifiers(),
+ )
+ gate = Barrier(2)
+ expand = FixedRecurrencePlanner.expand
+
+ def planned(self, *args, **kwargs):
+ batch = expand(self, *args, **kwargs)
+ gate.wait(timeout=5)
+ return batch
+
+ def run(candidate):
+ try:
+ return candidate.step(rule_id, before).status
+ except Conflict:
+ return "conflict"
+ except StorageBusy:
+ return "busy"
+
+ with monkeypatch.context() as patch:
+ patch.setattr(FixedRecurrencePlanner, "expand", planned)
+ with ThreadPoolExecutor(max_workers=2) as workers:
+ outcomes = list(workers.map(run, (service, independent)))
+ assert outcomes.count("created") == 1 and len(set(outcomes) & {"conflict", "busy"}) == 1
+ assert service.step(rule_id, before).status == "current"
+ assert counts(store) == (1, 1, 3, 3)
+
+
+def test_conflicting_original_payload_never_advances_or_replaces_a_slot(tmp_path):
+ service, store, _, _, rule_id, before = configured(tmp_path)
+ first = service.step(rule_id, before)
+ rewind(store, rule_id)
+ initial = counts(store)
+ with store._connection() as connection:
+ connection.execute(
+ "UPDATE receipts SET payload_fingerprint=? WHERE command_key=?",
+ ("f" * 64, first.receipt.command.key),
+ )
+ with pytest.raises(Conflict, match="idempotency_conflict"):
+ service.step(rule_id, before)
+ assert counts(store) == initial
+ with store.read() as unit:
+ assert unit.recurrence.progress("home", rule_id).cursor.nominal_count == 0
+
+
+def test_date_only_maintenance_still_requires_the_actual_pinned_policy_without_writer_lock(
+ tmp_path, monkeypatch
+):
+ from domus.domain.errors import IntegrationUnavailable
+ from domus.infrastructure.recurrence_time import PinnedRecurrenceTime
+
+ service, store, _, _, rule_id, before = configured(tmp_path)
+ initial = counts(store)
+
+ def unavailable(self, policy):
+ # An independent writer can still enter while external policy data is consulted.
+ with store.write():
+ pass
+ raise IntegrationUnavailable("timezone_source_unavailable")
+
+ monkeypatch.setattr(PinnedRecurrenceTime, "require_policy", unavailable)
+ with pytest.raises(IntegrationUnavailable, match="timezone_source_unavailable"):
+ service.step(rule_id, before)
+ assert counts(store) == initial
diff --git a/tests/containers/test_recurrence_image.py b/tests/containers/test_recurrence_image.py
index 735dc42..1db815d 100644
--- a/tests/containers/test_recurrence_image.py
+++ b/tests/containers/test_recurrence_image.py
@@ -22,6 +22,7 @@ def test_current_image_executes_recurrence_creation_planning_and_closed_record_c
from pathlib import Path
from tempfile import TemporaryDirectory
from domus.application.fixed_recurrence import FixedRecurrencePlanner
+from domus.application.fixed_materialization import FixedMaintenance, MaintenanceScope
from domus.application.household_commands import HouseholdCommands, SetupHousehold
from domus.application.recurrence_commands import CreateFixedTask, RecurrenceCommands
from domus.application.task_requests import CreateTask
@@ -89,11 +90,24 @@ def get(self, user_id):
assert len(unit.tasks.definitions('home')) == 1
assert unit.recurrence.progress('home', facts['schedule_id']).cursor.nominal_count == 0
assert unit.receipts.get(receipt.command) == receipt
-print('Current-source recurrence creation, codecs and pinned timezone planning passed.')
+ maintenance = FixedMaintenance(store, MaintenanceScope(installation, 'home'),
+ creation.planner, clock, identifiers)
+ horizon = NominalTime(datetime(2026, 3, 31, 2, 30))
+ first_step = maintenance.step(facts['schedule_id'], horizon)
+ assert first_step.status == 'created' and not first_step.cursor.closed
+ with store.read() as unit:
+ work = unit.tasks.occurrence('home', first_step.occurrence_id)
+ assert work.due_at == first.resolved.instant_ms
+ assert maintenance.step(facts['schedule_id'], horizon).cursor.closed
+ assert maintenance.step(facts['schedule_id'], horizon).status == 'current'
+ with store.read() as unit:
+ assert len(unit.tasks.occurrences('home')) == 2
+ assert len(unit.recurrence.slots('home', facts['schedule_id'])) == 2
+print('Current-source recurrence creation, materialization and pinned-time codecs passed.')
""",
)
assert result.returncode == 0, result.stderr
assert (
- "Current-source recurrence creation, codecs and pinned timezone planning passed."
+ "Current-source recurrence creation, materialization and pinned-time codecs passed."
in result.stdout
)
From ae73710972c386b18406921316d6311ced0d76e6 Mon Sep 17 00:00:00 2001
From: Efra Espada
Date: Mon, 2 Nov 2026 01:41:46 +0100
Subject: [PATCH 07/16] feat: Bound fair recurrence runs across restart and
cancellation
---
.../src/domus/application/fair_recurrence.py | 217 ++++++++++++++++++
.../application/fixed_materialization.py | 47 +++-
.../src/domus/application/recurrence_ports.py | 4 +
.../src/domus/domain/recurrence_frontier.py | 22 ++
.../domus/infrastructure/sqlite_recurrence.py | 49 +++-
.../sqlite_recurrence_integrity.py | 2 +
.../sqlite_recurrence_records.py | 27 +++
.../sqlite_recurrence_schema.py | 12 +-
docs/architecture-recurrence.md | 25 ++
docs/evidence/recurrence-fairness.json | 213 +++++++++++++++++
docs/evidence/recurrence-storage.json | 5 +-
docs/tasks/migration.md | 12 +-
specs/recurrence-storage-continuation.md | 14 ++
tests/backend/test_fair_recurrence.py | 204 ++++++++++++++++
.../test_fair_recurrence_boundaries.py | 216 +++++++++++++++++
tests/backend/test_fixed_materialization.py | 34 +++
.../test_fixed_materialization_shutdown.py | 43 ++++
tests/backend/test_recurrence_frontier.py | 177 ++++++++++++++
tests/backend/test_sqlite_task_reviews.py | 7 +-
tests/containers/test_recurrence_image.py | 14 +-
.../sqlite/unreleased-c581-schema8.sql | 61 +++++
21 files changed, 1387 insertions(+), 18 deletions(-)
create mode 100644 backend/src/domus/application/fair_recurrence.py
create mode 100644 backend/src/domus/domain/recurrence_frontier.py
create mode 100644 docs/evidence/recurrence-fairness.json
create mode 100644 tests/backend/test_fair_recurrence.py
create mode 100644 tests/backend/test_fair_recurrence_boundaries.py
create mode 100644 tests/backend/test_fixed_materialization_shutdown.py
create mode 100644 tests/backend/test_recurrence_frontier.py
create mode 100644 tests/fixtures/sqlite/unreleased-c581-schema8.sql
diff --git a/backend/src/domus/application/fair_recurrence.py b/backend/src/domus/application/fair_recurrence.py
new file mode 100644
index 0000000..2bf9d98
--- /dev/null
+++ b/backend/src/domus/application/fair_recurrence.py
@@ -0,0 +1,217 @@
+"""Durable bounded rule admission and fair round-robin steps; no thread or calendar claim."""
+
+from collections import deque
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from domus.application.fixed_materialization import FixedMaintenance, MaintenanceScope
+from domus.application.ports import Clock, Transactions
+from domus.application.recurrence_ports import RecurrenceTimes
+from domus.domain.errors import (
+ Conflict,
+ DomusError,
+ Forbidden,
+ IntegrationUnavailable,
+ InvalidInput,
+ NotFound,
+ StorageBusy,
+ StorageFailure,
+)
+from domus.domain.recurrence_frontier import RecurrenceFrontier
+from domus.domain.recurrence_records import FixedSchedule, ScheduleState
+from domus.domain.recurrence_rules import NominalDate, NominalSlot
+from domus.domain.recurrence_time import RelativeInterval
+
+
+@dataclass(frozen=True, slots=True)
+class RunLimits:
+ max_definitions: int = 50
+ max_slots: int = 1000
+ max_periods: int = 4096
+ horizon_days: int = 60
+
+ def __post_init__(self) -> None:
+ for value, lower, upper in (
+ (self.max_definitions, 1, 50),
+ (self.max_slots, 1, 1000),
+ (self.max_periods, 1, 64000),
+ (self.horizon_days, 7, 366),
+ ):
+ if type(value) is not int or not lower <= value <= upper:
+ raise InvalidInput("invalid_recurrence_run_limits")
+
+
+@dataclass(frozen=True, slots=True)
+class RuleAttention:
+ schedule_id: str
+ code: str
+
+
+def _attention_code(error: DomusError) -> str:
+ if isinstance(error, Conflict):
+ return (
+ "original_proof_conflict" if error.code == "idempotency_conflict" else "source_changed"
+ )
+ for kind, code in (
+ (StorageBusy, "storage_busy"),
+ (StorageFailure, "result_unconfirmed"),
+ (IntegrationUnavailable, "time_source_unavailable"),
+ (InvalidInput, "invalid_rule"),
+ (NotFound, "source_changed"),
+ ):
+ if isinstance(error, kind):
+ return code
+ return "recurrence_unavailable"
+
+
+@dataclass(frozen=True, slots=True)
+class FixedRun:
+ """Counters are confirmed observations for the selected run, never calendar totals."""
+
+ status: str
+ selected_schedule_ids: tuple[str, ...] = ()
+ created: int = 0
+ replayed: int = 0
+ historical_absent: int = 0
+ periods_scanned: int = 0
+ attention_items: tuple[RuleAttention, ...] = ()
+
+ @property
+ def attention_count(self) -> int:
+ return len(self.attention_items)
+
+
+@dataclass
+class FairFixedRunner:
+ transactions: Transactions
+ scope: MaintenanceScope
+ stepper: FixedMaintenance
+ times: RecurrenceTimes
+ clock: Clock
+ limits: RunLimits = RunLimits()
+
+ def _select(self) -> tuple[tuple[FixedSchedule, ...], int] | None:
+ with self.transactions.read() as unit:
+ if self.scope.household(unit) is None:
+ return None
+ home = self.scope.household_id
+ frontier = unit.recurrence.frontier(home)
+ after = None if frontier is None else frontier.after_schedule_id
+ selected = unit.recurrence.schedules(
+ home, limit=self.limits.max_definitions, after=after, state=ScheduleState.ACTIVE
+ )
+ if after is not None and len(selected) < self.limits.max_definitions:
+ head = unit.recurrence.schedules(
+ home,
+ limit=self.limits.max_definitions - len(selected),
+ state=ScheduleState.ACTIVE,
+ )
+ seen = {rule.schedule_id for rule in selected}
+ selected += tuple(rule for rule in head if rule.schedule_id not in seen)
+ now = self.clock.now()
+ return selected, now
+
+ def _admit(self, schedule_id: str) -> None:
+ with self.transactions.write() as unit:
+ self.scope.require(unit)
+ home = self.scope.household_id
+ frontier = unit.recurrence.frontier(home)
+ if frontier is not None and frontier.after_schedule_id == schedule_id:
+ return
+ expected = 0 if frontier is None else frontier.revision
+ unit.recurrence.save_frontier(
+ RecurrenceFrontier(home, schedule_id, self.clock.now(), expected + 1), expected
+ )
+
+ def tick(self, *, should_continue: Callable[[], bool] = lambda: True) -> FixedRun:
+ if not should_continue():
+ return FixedRun("stopped")
+ claim = self._select()
+ if claim is None:
+ return FixedRun("unconfigured")
+ selected, started_at = claim
+ pending = deque(selected)
+ horizons: dict[str, NominalSlot] = {}
+ admitted: set[str] = set()
+ created = replayed = historical = scanned = 0
+ attention: list[RuleAttention] = []
+ stopped = False
+ while (
+ pending
+ and created + replayed + historical < self.limits.max_slots
+ and scanned < self.limits.max_periods
+ ):
+ if not should_continue():
+ stopped = True
+ break
+ rule = pending.popleft()
+ try:
+ # Reserve each actually attempted rule before provider I/O. Reserving an entire
+ # unvisited window could starve its later rules after repeated interrupted runs.
+ if rule.schedule_id not in admitted:
+ self._admit(rule.schedule_id)
+ admitted.add(rule.schedule_id)
+ before = horizons.get(rule.schedule_id)
+ if before is None:
+ horizon = self.times.after_local_days(
+ started_at, RelativeInterval(self.limits.horizon_days), rule.policy
+ ).nominal
+ before = (
+ NominalDate(horizon.local.date())
+ if isinstance(rule.pattern.start, NominalDate)
+ else horizon
+ )
+ horizons[rule.schedule_id] = before
+ if not should_continue():
+ stopped = True
+ break
+ result = self.stepper.step(
+ rule.schedule_id,
+ before,
+ period_limit=min(64, self.limits.max_periods - scanned),
+ expected_schedule=rule,
+ should_continue=should_continue,
+ )
+ except Forbidden:
+ raise
+ except DomusError as error:
+ # An unavailable/unconfirmed rule keeps its durable facts and cannot starve peers.
+ attention.append(RuleAttention(rule.schedule_id, _attention_code(error)))
+ continue
+ scanned += result.periods_scanned
+ if result.status == "stopped":
+ stopped = True
+ break
+ if result.status == "created":
+ created += 1
+ elif result.status == "replayed":
+ replayed += 1
+ elif result.status == "historical_absent":
+ historical += 1
+ elif result.status in ("current", "paused", "retired", "definition_inactive"):
+ continue
+ elif result.status != "scan" or (
+ result.periods_scanned == 0 and not result.range_complete
+ ):
+ attention.append(RuleAttention(rule.schedule_id, "result_unconfirmed"))
+ continue
+ if not result.range_complete:
+ pending.append(rule)
+ status = (
+ "stopped"
+ if stopped
+ else "budget_exhausted"
+ if pending
+ else "attention"
+ if attention
+ else "complete"
+ )
+ return FixedRun(
+ status,
+ tuple(rule.schedule_id for rule in selected),
+ created,
+ replayed,
+ historical,
+ scanned,
+ tuple(attention),
+ )
diff --git a/backend/src/domus/application/fixed_materialization.py b/backend/src/domus/application/fixed_materialization.py
index 7e12fe0..ada0674 100644
--- a/backend/src/domus/application/fixed_materialization.py
+++ b/backend/src/domus/application/fixed_materialization.py
@@ -1,5 +1,6 @@
"""One bounded local maintenance step commits actual fixed work or reconciles original proof."""
+from collections.abc import Callable
from dataclasses import asdict, dataclass, replace
from typing import cast
@@ -18,6 +19,7 @@
from domus.domain.recurrence_rules import NominalDate, NominalSlot
from domus.domain.storage import (
CommandKey,
+ Household,
Installation,
JsonDocument,
Receipt,
@@ -39,14 +41,17 @@ def __post_init__(self) -> None:
if not isinstance(self.installation, Installation):
raise InvalidInput("invalid_maintenance_scope")
- def require(self, unit: UnitOfWork) -> None:
+ def household(self, unit: UnitOfWork) -> Household | None:
household = unit.households.current()
- if (
- unit.recurrence.installation != self.installation
- or household is None
- or household.household_id != self.household_id
+ if unit.recurrence.installation != self.installation or (
+ household is not None and household.household_id != self.household_id
):
raise Forbidden("maintenance_scope_denied")
+ return household
+
+ def require(self, unit: UnitOfWork) -> None:
+ if self.household(unit) is None:
+ raise Forbidden("maintenance_scope_denied")
@dataclass(frozen=True, slots=True)
@@ -120,17 +125,29 @@ def _source(self, unit: UnitOfWork, schedule_id: str) -> _Source:
raise StorageFailure("invalid_recurrence_source")
return _Source(rule, progress, definition)
- def step(self, schedule_id: str, before: NominalSlot, *, period_limit: int = 64) -> FixedStep:
+ def step(
+ self,
+ schedule_id: str,
+ before: NominalSlot,
+ *,
+ period_limit: int = 64,
+ expected_schedule: FixedSchedule | None = None,
+ should_continue: Callable[[], bool] = lambda: True,
+ ) -> FixedStep:
validate_identifier(schedule_id)
if type(period_limit) is not int or not 1 <= period_limit <= 64:
raise InvalidInput("invalid_recurrence_limits")
with self.transactions.read() as unit:
source = self._source(unit, schedule_id)
rule, progress = source.schedule, source.progress
+ if expected_schedule is not None and rule != expected_schedule:
+ raise Conflict("recurrence_source_changed")
if rule.state is not ScheduleState.ACTIVE:
return FixedStep(rule.state.value, schedule_id, progress.cursor, False)
if source.definition.state is not DefinitionState.ACTIVE:
return FixedStep("definition_inactive", schedule_id, progress.cursor, False)
+ if not should_continue():
+ return FixedStep("stopped", schedule_id, progress.cursor, False)
self.planner.times.require_policy(rule.policy)
batch = self.planner.expand(
rule.pattern,
@@ -149,10 +166,20 @@ def step(self, schedule_id: str, before: NominalSlot, *, period_limit: int = 64)
return FixedStep(
"current", schedule_id, batch.cursor, batch.range_complete, batch.periods_scanned
)
- return self._commit(source, batch, before)
+ return self._commit(source, batch, before, should_continue)
- def _commit(self, source: _Source, batch: FixedBatch, before: NominalSlot) -> FixedStep:
+ def _commit(
+ self,
+ source: _Source,
+ batch: FixedBatch,
+ before: NominalSlot,
+ should_continue: Callable[[], bool],
+ ) -> FixedStep:
rule, progress = source.schedule, source.progress
+ if not should_continue():
+ return FixedStep(
+ "stopped", rule.schedule_id, progress.cursor, False, batch.periods_scanned
+ )
planned = batch.slots[0] if batch.slots else None
probe = (
None
@@ -232,6 +259,10 @@ def apply(unit: UnitOfWork) -> Change:
)
with self.transactions.write() as unit:
+ if not should_continue():
+ return FixedStep(
+ "stopped", rule.schedule_id, progress.cursor, False, batch.periods_scanned
+ )
receipt = commands.in_unit(unit, command, payload, apply)
occurrence_id = None
status = "scan"
diff --git a/backend/src/domus/application/recurrence_ports.py b/backend/src/domus/application/recurrence_ports.py
index 5ce6735..bdb1515 100644
--- a/backend/src/domus/application/recurrence_ports.py
+++ b/backend/src/domus/application/recurrence_ports.py
@@ -2,6 +2,7 @@
from typing import Protocol
+from domus.domain.recurrence_frontier import RecurrenceFrontier
from domus.domain.recurrence_records import (
FixedProgress,
FixedSchedule,
@@ -34,6 +35,9 @@ class RecurrenceRepository(Protocol):
@property
def installation(self) -> Installation: ...
+ def frontier(self, household_id: str) -> RecurrenceFrontier | None: ...
+ def save_frontier(self, value: RecurrenceFrontier, expected_revision: int) -> None: ...
+
def schedule(self, household_id: str, schedule_id: str) -> FixedSchedule | None: ...
def progress(self, household_id: str, schedule_id: str) -> FixedProgress | None: ...
def slot(self, household_id: str, slot_key: str) -> ScheduledSlot | None: ...
diff --git a/backend/src/domus/domain/recurrence_frontier.py b/backend/src/domus/domain/recurrence_frontier.py
new file mode 100644
index 0000000..b49b57b
--- /dev/null
+++ b/backend/src/domus/domain/recurrence_frontier.py
@@ -0,0 +1,22 @@
+"""A fair-selection cursor records admission only; it does not prove work or calendar freshness."""
+
+from dataclasses import dataclass
+
+from domus.domain.identity import validate_revision
+from domus.domain.storage import validate_identifier
+from domus.domain.task_values import validate_task_time
+
+
+@dataclass(frozen=True, slots=True)
+class RecurrenceFrontier:
+ household_id: str
+ after_schedule_id: str | None
+ selected_at: int
+ revision: int = 1
+
+ def __post_init__(self) -> None:
+ validate_identifier(self.household_id)
+ if self.after_schedule_id is not None:
+ validate_identifier(self.after_schedule_id)
+ validate_task_time(self.selected_at)
+ validate_revision(self.revision)
diff --git a/backend/src/domus/infrastructure/sqlite_recurrence.py b/backend/src/domus/infrastructure/sqlite_recurrence.py
index fe45906..b69ba70 100644
--- a/backend/src/domus/infrastructure/sqlite_recurrence.py
+++ b/backend/src/domus/infrastructure/sqlite_recurrence.py
@@ -6,6 +6,7 @@
from typing import cast
from domus.domain.errors import Conflict, InvalidInput, StorageFailure
+from domus.domain.recurrence_frontier import RecurrenceFrontier
from domus.domain.recurrence_records import (
FixedProgress,
FixedSchedule,
@@ -15,7 +16,9 @@
from domus.domain.recurrence_rules import NominalDate
from domus.domain.storage import Installation, validate_identifier
from domus.infrastructure.sqlite_recurrence_records import (
+ frontier_document,
progress_document,
+ read_frontier,
read_progress,
read_schedule,
read_slot,
@@ -24,6 +27,15 @@
)
+def frontier_headers(value: RecurrenceFrontier) -> dict[str, object]:
+ return {
+ "household_id": value.household_id,
+ "after_schedule_id": value.after_schedule_id,
+ "selected_at": value.selected_at,
+ "revision": value.revision,
+ }
+
+
def schedule_headers(value: FixedSchedule) -> dict[str, object]:
return {
"schedule_id": value.schedule_id,
@@ -101,6 +113,28 @@ class SQLiteRecurrences:
connection: sqlite3.Connection
installation: Installation
+ def frontier(self, household_id: str) -> RecurrenceFrontier | None:
+ validate_identifier(household_id)
+ return _model(
+ self.connection.execute(
+ "SELECT * FROM recurrence_frontiers WHERE household_id=?", (household_id,)
+ ).fetchone(),
+ read_frontier,
+ frontier_headers,
+ )
+
+ def save_frontier(self, value: RecurrenceFrontier, expected_revision: int) -> None:
+ _expected(expected_revision)
+ if value.revision != expected_revision + 1:
+ raise Conflict("revision_conflict")
+ self._write(
+ "recurrence_frontiers",
+ frontier_headers(value),
+ frontier_document(value).encoded,
+ expected_revision,
+ identity="household_id",
+ )
+
def schedule(self, household_id: str, schedule_id: str) -> FixedSchedule | None:
validate_identifier(household_id)
validate_identifier(schedule_id)
@@ -141,8 +175,17 @@ def _slot(self, row: sqlite3.Row | None) -> ScheduledSlot | None:
raise StorageFailure("invalid_recurrence_record")
return value
- def _write(self, table: str, headers: dict[str, object], document: str, expected: int) -> None:
+ def _write(
+ self,
+ table: str,
+ headers: dict[str, object],
+ document: str,
+ expected: int,
+ *,
+ identity: str = "schedule_id",
+ ) -> None:
values = {**headers, "document": document}
+ keys = ("household_id",) if identity == "household_id" else ("household_id", identity)
if expected == 0:
try:
self.connection.execute(
@@ -155,8 +198,8 @@ def _write(self, table: str, headers: dict[str, object], document: str, expected
elif (
self.connection.execute(
f"UPDATE {table} SET {','.join(key + '=?' for key in values)} "
- "WHERE household_id=? AND schedule_id=? AND revision=?",
- (*values.values(), headers["household_id"], headers["schedule_id"], expected),
+ f"WHERE {' AND '.join(key + '=?' for key in keys)} AND revision=?",
+ (*values.values(), *(headers[key] for key in keys), expected),
).rowcount
!= 1
):
diff --git a/backend/src/domus/infrastructure/sqlite_recurrence_integrity.py b/backend/src/domus/infrastructure/sqlite_recurrence_integrity.py
index 958e623..5e66fa7 100644
--- a/backend/src/domus/infrastructure/sqlite_recurrence_integrity.py
+++ b/backend/src/domus/infrastructure/sqlite_recurrence_integrity.py
@@ -11,6 +11,8 @@
def verify_recurrence_records(connection: sqlite3.Connection, installation: Installation) -> None:
repository = SQLiteRecurrences(connection, installation)
+ for row in connection.execute("SELECT household_id FROM recurrence_frontiers"):
+ repository.frontier(row[0])
for row in connection.execute("SELECT household_id,schedule_id FROM recurrence_rules"):
# These keys came from the same read cursor; record decoding still checks every header.
rule = cast(FixedSchedule, repository.schedule(row[0], row[1]))
diff --git a/backend/src/domus/infrastructure/sqlite_recurrence_records.py b/backend/src/domus/infrastructure/sqlite_recurrence_records.py
index 49edbdf..f2efe75 100644
--- a/backend/src/domus/infrastructure/sqlite_recurrence_records.py
+++ b/backend/src/domus/infrastructure/sqlite_recurrence_records.py
@@ -5,6 +5,7 @@
from typing import cast
from domus.domain.errors import DomusError, InvalidInput, StorageFailure
+from domus.domain.recurrence_frontier import RecurrenceFrontier
from domus.domain.recurrence_planning import FixedCursor, PlannedFixedSlot
from domus.domain.recurrence_records import (
FixedProgress,
@@ -332,3 +333,29 @@ def read_progress(document: str) -> FixedProgress:
def read_slot(document: str) -> ScheduledSlot:
return _read(document, _slot)
+
+
+def frontier_document(value: RecurrenceFrontier) -> JsonDocument:
+ return JsonDocument.from_value(
+ {
+ "version": 1,
+ "household_id": value.household_id,
+ "after_schedule_id": value.after_schedule_id,
+ "selected_at": value.selected_at,
+ "revision": value.revision,
+ }
+ )
+
+
+def _frontier(value: JsonValue) -> RecurrenceFrontier:
+ row = _record(value, ("household_id", "after_schedule_id", "selected_at", "revision"))
+ return RecurrenceFrontier(
+ cast(str, row["household_id"]),
+ cast(str | None, row["after_schedule_id"]),
+ cast(int, row["selected_at"]),
+ cast(int, row["revision"]),
+ )
+
+
+def read_frontier(document: str) -> RecurrenceFrontier:
+ return _read(document, _frontier)
diff --git a/backend/src/domus/infrastructure/sqlite_recurrence_schema.py b/backend/src/domus/infrastructure/sqlite_recurrence_schema.py
index a6a585d..faefa1d 100644
--- a/backend/src/domus/infrastructure/sqlite_recurrence_schema.py
+++ b/backend/src/domus/infrastructure/sqlite_recurrence_schema.py
@@ -1,7 +1,17 @@
"""Scoped recurrence rows and deferred original evidence share the enclosing atomic commit."""
-RECURRENCE_TABLES = {"recurrence_rules", "recurrence_progress", "recurrence_slots"}
+RECURRENCE_TABLES = {
+ "recurrence_rules",
+ "recurrence_progress",
+ "recurrence_slots",
+ "recurrence_frontiers",
+}
RECURRENCE_STATEMENTS = (
+ "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 UNIQUE INDEX recurrence_occurrence_scope "
"ON task_occurrences(household_id,definition_id,occurrence_id)",
"CREATE TABLE recurrence_rules(schedule_id TEXT PRIMARY KEY NOT NULL, "
diff --git a/docs/architecture-recurrence.md b/docs/architecture-recurrence.md
index 79fc05e..6ad8014 100644
--- a/docs/architecture-recurrence.md
+++ b/docs/architecture-recurrence.md
@@ -179,3 +179,28 @@ no invented UTC midnight. Technical attribution cannot act as a performer, appro
This is not yet a running scheduler. Fair multi-rule continuation, global budgets and shutdown,
durable review/calendar attention, relative sources and native/standalone background composition
remain required. See [step evidence](evidence/recurrence-materialization.json).
+
+## Bounded fair application runs
+
+The [fair run](../backend/src/domus/application/fair_recurrence.py) selects at most 50 active rules
+and shares their turns within a global limit of 1,000 processed nominal slots. Replayed and historical
+slots consume that work budget. Empty-period scans consume a separate bounded period budget.
+The default horizon is 60 local days; it never deletes past backlog. Rule snapshots used for that
+horizon are rechecked before the step can write.
+
+A small [frontier](../backend/src/domus/domain/recurrence_frontier.py) persists the last actually
+admitted rule. Reserving all 50 unread rules at once can starve later ones if every run stops after
+its first step. Admission therefore advances before provider work for each visited rule, while
+unvisited rules retain their turn across restart. The removed-rule key remains a lexical continuation;
+it grants no authority and proves neither task completion nor arbitrary calendar completeness.
+
+Current task/progress/proof facts remain authoritative. Results distinguish created, replayed,
+historically absent and unconfirmed work, with bounded safe rule-specific attention. A malformed
+post-commit acknowledgement cannot turn existing tasks into an empty-state claim. Cancellation is
+checked before atomic admission, including after a writer wait; an admitted effect completes or
+rolls back. No subsequent step begins after the cancellation is observed.
+
+These are application runs, without a production thread or new HTTP surface. Owned polling,
+startup/shutdown wiring, relative lifecycle and durable calendar/review attention remain required.
+See [fairness evidence](evidence/recurrence-fairness.json) and the
+[migration boundary](tasks/migration.md).
diff --git a/docs/evidence/recurrence-fairness.json b/docs/evidence/recurrence-fairness.json
new file mode 100644
index 0000000..cbf81f6
--- /dev/null
+++ b/docs/evidence/recurrence-fairness.json
@@ -0,0 +1,213 @@
+{
+ "status": "partial; durable bounded fair application runs implemented; production polling and complete recurrence remain in progress",
+ "recordedAtUTC": "2026-11-02T00:37:54.094645+00:00",
+ "issue": "https://github.com/vypdev/domus/issues/20",
+ "pullRequest": "https://github.com/vypdev/domus/pull/22",
+ "sourceBase": "c5819b8ffd217bde96b440a46971e6df77867099",
+ "backendSourceInventorySHA256": "e2369a3dd8875edca196c4da7a0387508eeb18a168089e3c489a5c91a34b56ea",
+ "sourceManifestMethod": "SHA256 of compact sorted JSON mapping every authored Python module path, including package initializers, to its byte SHA256 value.",
+ "implemented": [
+ "Pure scoped selection frontier with closed codec, scoped CAS persistence and checkpoint integrity; no completion/authority meaning",
+ "At most50 selected active rules, at most 1,000 processed nominal slots and a separate bounded empty-period budget per run",
+ "Round-robin service, local-calendar horizon, original-proof/historical work accounting and source-bound horizon validation",
+ "Durable admission for each actually visited rule before provider I/O; unvisited rules keep their turn across repeated interruption",
+ "Cancellation before providers and before atomic write admission, including after writer entry; already admitted effects are retained",
+ "Bounded safe per-rule attention categories and confirmed observation counters; scope failure stays fatal",
+ "Actual global offline restoration and original-proof replay preserve absent-effect/current facts without duplicate tasks"
+ ],
+ "testsFirst": {
+ "frontier": "New collection failed with missing domus.domain.recurrence_frontier before implementation.",
+ "fairRun": "New collection failed with missing domus.application.fair_recurrence before implementation.",
+ "horizonAndShutdown": "Two tests failed on unsupported expected_schedule/should_continue arguments before the step guards existed.",
+ "interruption": "Repeated restart after every first step processed only 2 of 100 rules before changing whole-window reservation to actual-rule admission. The original failing assertion and passing complete campaign are retained.",
+ "attention": "A test failed on missing safe scoped attention_items before the report was implemented."
+ },
+ "completeBackend": {
+ "executions": 3639,
+ "durationSeconds": 114.99,
+ "authoredModules": 128,
+ "coverageTotals": {
+ "covered_lines": 9125,
+ "num_statements": 9125,
+ "percent_covered": 100.0,
+ "percent_covered_display": "100.00",
+ "missing_lines": 0,
+ "excluded_lines": 272,
+ "percent_statements_covered": 100.0,
+ "percent_statements_covered_display": "100.00",
+ "num_branches": 2606,
+ "num_partial_branches": 0,
+ "covered_branches": 2606,
+ "missing_branches": 0,
+ "percent_branches_covered": 100.0,
+ "percent_branches_covered_display": "100.00"
+ },
+ "independentPerModuleAudit": "passed"
+ },
+ "focusedFairness": {
+ "executions": 94,
+ "durationSeconds": 8.65,
+ "authoredModules": 3,
+ "coverageTotals": {
+ "covered_lines": 262,
+ "num_statements": 262,
+ "percent_covered": 100.0,
+ "percent_covered_display": "100.00",
+ "missing_lines": 0,
+ "excluded_lines": 0,
+ "percent_statements_covered": 100.0,
+ "percent_statements_covered_display": "100.00",
+ "num_branches": 90,
+ "num_partial_branches": 0,
+ "covered_branches": 90,
+ "missing_branches": 0,
+ "percent_branches_covered": 100.0,
+ "percent_branches_covered_display": "100.00"
+ },
+ "scope": "Includes existing step and metadata regressions. Product journey acceptance is tracked separately."
+ },
+ "focusedStorage": {
+ "executions": 119,
+ "authoredModules": 4,
+ "coverageTotals": {
+ "covered_lines": 258,
+ "num_statements": 258,
+ "percent_covered": 100.0,
+ "percent_covered_display": "100.00",
+ "missing_lines": 0,
+ "excluded_lines": 0,
+ "percent_statements_covered": 100.0,
+ "percent_statements_covered_display": "100.00",
+ "num_branches": 68,
+ "num_partial_branches": 0,
+ "covered_branches": 68,
+ "missing_branches": 0,
+ "percent_branches_covered": 100.0,
+ "percent_branches_covered_display": "100.00"
+ },
+ "warningCorrection": "The first campaign observed an unclosed test-only fixture database. Its producer now uses closing(); the targeted refusal test passes with ResourceWarning promoted to error, and the final complete campaign has no warning."
+ },
+ "capacityObservations": {
+ "rules": "101 actual rules receive 50/50/1 new work across durable restart; every selection has at most 50 rules.",
+ "slots": "An actual past backlog of 1,001 nominal slots creates exactly 1,000 in the first run and one in the next; original per-slot evidence remains separate.",
+ "interrupted": "100 actual rules each receive exactly one first-step turn over 100 interrupted restarted runs, rather than revisiting only the first rules of large windows.",
+ "periods": "Empty scan work consumes the global budget and retains its exact continuation for the next run.",
+ "uncertainty": "An unknown post-commit acknowledgement reports safe attention and retains the real committed task; observation counters are not assumed to be database totals."
+ },
+ "checks": {
+ "strictTypes": {
+ "passed": true,
+ "files": 128
+ },
+ "ruff": "check and format passed;322 files formatted",
+ "architecture": {
+ "contractsKept": 3,
+ "contractsBroken": 0,
+ "filesAnalyzed": 172,
+ "dependencies": 1088
+ },
+ "originalPinnedSdd": {
+ "upstream": "3e7ceea08e6131360e026937d1c0cc1b120472e0",
+ "base": "0806a38f8a2de1f102a017bff25ad6856fb17bcd",
+ "selected": "specs/recurrence-storage-continuation.md",
+ "originalPolicyModulesVerified": 7,
+ "format": "PASS",
+ "consistency": "PASS",
+ "composition": "explicit contributor integration; original publication retained in Git",
+ "unchangedCatalog": "PASS",
+ "immutableOwnerDocumentsUsed": 18,
+ "retainedCompanionCases": 52,
+ "newCases": 4,
+ "rawCharacters": 26395,
+ "renderedCharacters": 26395,
+ "composedCharacters": 26395,
+ "applicableDraftMaximumCharacters": 70000,
+ "sha256": "a129daa6c324ada4072850e952826992d19054c233110d0b342240de9aa624bf",
+ "scope": "Review input only; no gate admission, branch publication, source implementation or runtime acceptance.",
+ "retainedOwningFilesUnchanged": true,
+ "workingDocumentValidated": true
+ },
+ "packageAndArchitectureTests": {
+ "passed": true,
+ "executions": 52,
+ "durationSeconds": 7.49,
+ "requires": "Fresh current backend wheel with the retained unchanged frontend bundle; hosted current-source images rebuild UI separately."
+ },
+ "imageControlOnHost": "Exact current script executes real SQLite fair runs, actual materialization, frontier persistence and pinned-time facts. Host execution is separate from required fresh CPU-image jobs."
+ },
+ "migration": {
+ "schema": 8,
+ "supportedHistoricalSchemas": [
+ 1,
+ 2,
+ 3,
+ 4,
+ 5,
+ 6,
+ 7
+ ],
+ "selectionTable": "recurrence_frontiers",
+ "unreleasedPriorInput": {
+ "sourceCommit": "c5819b8ffd217bde96b440a46971e6df77867099",
+ "scope": "Genuine prior unreleased schema8 input generated before adding selection metadata. Not a released schema8 migration promise.",
+ "sqlFixture": "tests/fixtures/sqlite/unreleased-c581-schema8.sql",
+ "sqlSHA256": "2f884cd00b735ddf60c567a908bf05e709f887f699953bce7808326e017647b4",
+ "rawSource": "/private/tmp/domus-recurrence-runner-c5819b8/frozen-schema8-c581/domus.sqlite3",
+ "rawSHA256": "b87d0e5af9a9a3df5b8c30f659ed91336c965bf859dc38d0a601a70a92643d96",
+ "actualMaterialization": "identity-7"
+ },
+ "priorLayoutRefusal": "Normal reads/writes and initialization reject the genuine prior development layout without changing bytes. It does not become a released-source migration or a version-number downgrade.",
+ "originalFixturesUnchanged": "Original schemas 1..7 remain unchanged; genuine v7 SHA256 stays c5ca7b2383e9f75561a59b9ea0bbbd98736ae400b19e735b5a6fdf90ca139ea5."
+ },
+ "limits": {
+ "maxDefinitions": 50,
+ "maxProcessedSlots": 1000,
+ "defaultPeriodBudget": 4096,
+ "maximumPeriodBudget": 64000,
+ "defaultLocalHorizonDays": 60,
+ "horizonRangeDays": [
+ 7,
+ 366
+ ],
+ "meaning": "The frontier records admission, and run counters record confirmed observations for the selected window. Neither proves arbitrary calendar completeness or human performance."
+ },
+ "priorHeadVerification": {
+ "headRevision": "c5819b8ffd217bde96b440a46971e6df77867099",
+ "ciRun": 41363418789,
+ "ciCheck": 125019775929,
+ "allApplicableProductSuites": "passed",
+ "amd64Job": 125018446570,
+ "aarch64Job": 125018446322,
+ "nativeReview": {
+ "jobId": 125018446857,
+ "reviewCheck": 125018888587,
+ "partitionsCompleted": 6,
+ "partitionsAssigned": 6,
+ "filesAssigned": 56,
+ "diffFragments": 63,
+ "candidateFindings": 0,
+ "publishedFindings": 0,
+ "fixedFindings": 1,
+ "openFindings": 0,
+ "humanConversation": {
+ "fetched": 3,
+ "retained": 2,
+ "omitted": 1,
+ "truncated": 0,
+ "limitReached": true
+ },
+ "overall": "partial/neutral; code is complete and all three nonempty maintainer-account technical review inputs were manually inspected. Telemetry does not identify the omitted input or prove whole-prompt absence."
+ },
+ "copilotProgressFailure": "validation.evidence-too-large, reference e5d87e1e-56a3-4bfb-9d4a-0bf63bb91ca3; existing upstream report851 updated without trimming evidence or restarting unchanged work."
+ },
+ "pending": [
+ "Owned production polling lifecycle and both native/standalone composition paths",
+ "Completion-relative persistence and actual source/cycle handling",
+ "Durable historical/review/collision attention and protected calendar queries with the actual range predicate",
+ "Reviewed attachment, edits/exceptions/preview, category/classification and public HTTP/operation recovery",
+ "Household timing-policy defaults, actual UI/HA surfaces, fresh hosted images and the retained 52 companion plus 37 owner cases for first all-domain acceptance."
+ ],
+ "ownerHa": "Installed Edge.5 schema 7 and retained Edge.3 remain untouched. No deployment or release.",
+ "copilot": "No Copilot/gateway code, configuration, credential, scope or evidence bound changed. Known limitations remain under upstream issues #851 and #843.",
+ "retainedEvidenceDirectory": "/private/tmp/domus-recurrence-runner-c5819b8"
+}
diff --git a/docs/evidence/recurrence-storage.json b/docs/evidence/recurrence-storage.json
index f4c0a17..bfe9169 100644
--- a/docs/evidence/recurrence-storage.json
+++ b/docs/evidence/recurrence-storage.json
@@ -85,7 +85,7 @@
"policyModulesByteVerified": 7,
"immutableBaseOwners": 18,
"retainedOwnersUnchanged": true,
- "currentCompanionSHA256": "70dc7362f47a59b6f6d9d81c13f9ba7a4e8d720243f58a33ccc4b6c33f448f5c",
+ "currentCompanionSHA256": "a129daa6c324ada4072850e952826992d19054c233110d0b342240de9aa624bf",
"scope": "Explicit contributor integration; original first publication remains in Git. Format, reciprocal inventory/path consistency and current catalog pass."
},
"packageAndArchitectureTests": {
@@ -282,5 +282,6 @@
"aarch64Job": 125009131691,
"result": "All applicable CI Check suites passed; Copilot separately found one real nominal-membership issue, now corrected locally and awaiting publication/review. Full recurrence remains incomplete."
},
- "laterMaterializationIncrement": "docs/evidence/recurrence-materialization.json; original storage-kernel results remain historical and scope-specific."
+ "laterMaterializationIncrement": "docs/evidence/recurrence-materialization.json; original storage-kernel results remain historical and scope-specific.",
+ "laterFairnessIncrement": "docs/evidence/recurrence-fairness.json; original storage-kernel acceptance scope remains unchanged."
}
diff --git a/docs/tasks/migration.md b/docs/tasks/migration.md
index 6c19844..d7b53ce 100644
--- a/docs/tasks/migration.md
+++ b/docs/tasks/migration.md
@@ -11,7 +11,7 @@ Keep the exact application/data pair and installation/channel identity.
| 5 | Task policy, definitions, occurrences and completion cycles with scoped constraints | Existing household/member/history rows are retained |
| 6 | Frozen material bindings, usage intents, strict codecs and compare-and-set guards | Legacy task/request evidence remains readable; original hashes/receipts stay unchanged |
| 7 | Three causal-evidence indexes on the existing outbox | No business table, task row, receipt or event is rewritten |
-| 8 | Scoped recurrence rules, matching durable progress and immutable nominal slots | Original task, receipt, audit, outbox and historical records remain authoritative |
+| 8 | Scoped recurrence rules, matching durable progress, immutable nominal slots and a fair-selection frontier | Original task, receipt, audit, outbox and historical records remain authoritative |
Initialization accepts an empty owned destination or supported versions 1–7, verifies the application
marker and installation/channel, then repeats inspection inside the bounded writer transaction.
@@ -76,6 +76,16 @@ The immutable Edge.5 image supplies real schema-7 upgrade and refusal controls;
its source pin or publish an owner package. Actual hosted image results remain a separate required
gate. See [storage evidence](../evidence/recurrence-storage.json) for the current measured scope.
+Schema 8 is still under development in the unmerged recurrence feature. Its selection frontier is
+added before the first schema-8 release; the owner still runs schema 7. Intermediate CI artifacts
+remain tied to their exact source/data pair. The genuine
+[c5819b8 development input](../../tests/fixtures/sqlite/unreleased-c581-schema8.sql) was generated
+before the selection-table addition, with real synthetic rule/materialization receipts. Its SHA-256
+is 2f884cd00b735ddf60c567a908bf05e709f887f699953bce7808326e017647b4. Current normal reads,
+writes and initialization refuse that unknown development layout and preserve its bytes. This
+control does not extend the supported historical migration set or authorize version-number edits.
+Keep its matching application for an intentional development rollback.
+
Those observations are retained in [task evidence](../evidence/task-execution-foundation.json).
They are distinct from image/cross-architecture adoption, real consumer ledgers and a restoration on
the owner's HA, which remain required before integrated release claims. No automatic backup schedule
diff --git a/specs/recurrence-storage-continuation.md b/specs/recurrence-storage-continuation.md
index 2b348cd..8233296 100644
--- a/specs/recurrence-storage-continuation.md
+++ b/specs/recurrence-storage-continuation.md
@@ -140,6 +140,7 @@ Use real independent SQLite connections and exact before/after version/row check
| backend/src/domus/application/recurrence_ports.py | update |
| backend/src/domus/application/recurrence_commands.py | create |
| backend/src/domus/application/fixed_materialization.py | create |
+| backend/src/domus/application/fair_recurrence.py | create |
| backend/src/domus/application/commands.py | update |
| backend/src/domus/application/task_creation.py | create |
| backend/src/domus/application/task_commands.py | update |
@@ -147,6 +148,7 @@ Use real independent SQLite connections and exact before/after version/row check
| backend/src/domus/domain/recurrence_planning.py | update |
| backend/src/domus/domain/recurrence_records.py | create |
| backend/src/domus/domain/recurrence_membership.py | create |
+| backend/src/domus/domain/recurrence_frontier.py | create |
| backend/src/domus/domain/recurrence_rules.py | update |
| backend/src/domus/infrastructure/recurrence_time.py | update |
| backend/src/domus/infrastructure/sqlite.py | update |
@@ -158,11 +160,16 @@ Use real independent SQLite connections and exact before/after version/row check
| tests/backend/test_recurrence_records.py | create |
| tests/backend/test_recurrence_creation.py | create |
| tests/backend/test_fixed_materialization.py | create |
+| tests/backend/test_fixed_materialization_shutdown.py | create |
+| tests/backend/test_fair_recurrence.py | create |
+| tests/backend/test_fair_recurrence_boundaries.py | create |
+| tests/backend/test_recurrence_frontier.py | create |
| tests/backend/test_recurrence_slot_membership.py | create |
| tests/backend/test_sqlite_recurrence_records.py | create |
| tests/backend/test_sqlite_recurrence_integrity.py | create |
| backend/src/domus/infrastructure/sqlite_recurrence_integrity.py | create |
| tests/fixtures/sqlite/v7.sql | create |
+| tests/fixtures/sqlite/unreleased-c581-schema8.sql | create |
| tests/backend/test_sqlite_transactions.py | update |
| tests/backend/test_sqlite_snapshots.py | update |
| tests/backend/test_activation_compatibility.py | update |
@@ -177,6 +184,7 @@ Use real independent SQLite connections and exact before/after version/row check
| docs/architecture-recurrence.md | update |
| docs/evidence/recurrence-creation.json | create |
| docs/evidence/recurrence-materialization.json | create |
+| docs/evidence/recurrence-fairness.json | create |
| docs/tasks/migration.md | update |
| docs/evidence/recurrence-execution.json | update |
@@ -212,6 +220,12 @@ Bugbot identified a storage boundary missing from the first kernel: a correctly
The next application step materializes at most one actual fixed obligation or scans at most 64 original periods. Trusted composition supplies its explicit installation/channel/household maintenance scope; no request or stored record grants that capability. External policy/planning work holds no writer lock. Current rule/progress/definition facts and the fixed technical command namespace are checked before original receipt lookup in the writer transaction. A new task, slot, receipt/audit/outbox and progress advance commit together through the shared command boundary. A matching current receipt does not create another task. An actual restored absent-effect receipt advances the reconciled cursor without recreating the task, allocating replacement identities or re-emitting its event; the returned historical status remains explicit. Empty bounded scans retain their own proof and incomplete continuation. Date-only values retain no fabricated deadline instant, and technical maintenance never performs or approves human work. The fair multi-rule production loop, durable attention/calendar projections, relative lifecycle and full runtime surfaces remain unfinished.
+The bounded fair application run selects at most 50 active rules and processes at most 1,000 nominal slots globally, including original-proof replay and historical reconciliation. A separate technical period budget defaults to 4,096 and cannot exceed 64,000; empty scans consume it. Horizon defaults to 60 local days within the retained 7..366 bound. Round-robin service shares each selected window. A scoped revisioned frontier records each actually admitted rule before provider work, once per rule in that window; reserving an entire unvisited window is insufficient under repeated interruption. The frontier is selection metadata, not completion or calendar-freshness proof, and has no foreign key to a potentially removed last rule.
+
+Rule snapshots used to compute horizons are rechecked by the step. Cancellation is observed before provider work and again before atomic write admission, including after waiting for a writer; an already admitted atomic effect completes or rolls back. Results report bounded confirmed observations and safe rule-specific attention categories without echoing raw diagnostics. Source failure does not starve peers; scope failure remains fatal. An actual offline restore keeps absent-effect semantics through the global run. No thread, production polling lifecycle or public route is added by this application-run checkpoint.
+
+Selection metadata is added to schema 8 before its first release. Supported historical schemas 1..7 and original fixture bytes remain unchanged. The genuine intermediate c5819b8 schema-8 SQL input was generated under that exact source before this DDL change; normal operations and initialization reject its unknown development layout without modifying it. It is a refusal/rollback control, not a promise to migrate an unreleased layout or permission to edit user_version. The owner remains on the unchanged released schema-7 Edge.5 pair. Complete production scheduling, relative sources, durable calendar/review attention and the remaining retained requirements remain pending.
+
## 19. Definition of Done
- [ ] The four additional cases and every retained requirement/case have truthful current-source evidence; no checkpoint is described as full feature acceptance.
diff --git a/tests/backend/test_fair_recurrence.py b/tests/backend/test_fair_recurrence.py
new file mode 100644
index 0000000..8707779
--- /dev/null
+++ b/tests/backend/test_fair_recurrence.py
@@ -0,0 +1,204 @@
+"""Actual fixed rules receive bounded fair turns across restart, errors and atomic stopping."""
+
+from dataclasses import replace
+from datetime import UTC, date, datetime
+from threading import Event
+
+import pytest
+from test_recurrence_creation import request, setup
+
+from domus.application.fair_recurrence import FairFixedRunner, RunLimits
+from domus.application.fixed_materialization import FixedMaintenance, MaintenanceScope
+from domus.domain.errors import Forbidden, IntegrationUnavailable, InvalidInput
+from domus.domain.recurrence_rules import NominalDate
+from domus.infrastructure.sqlite import SQLiteDatabase
+
+
+class Clock:
+ def now(self):
+ return int(datetime(2026, 3, 28, tzinfo=UTC).timestamp() * 1000)
+
+
+def configured(tmp_path, number=1, *, count=1, start=None, limits=None, timed=False):
+ creation, _, store, _, ids = setup(tmp_path)
+ rules = []
+ for index in range(number):
+ value = request(timed=timed)
+ value = replace(
+ value,
+ pattern=replace(
+ value.pattern,
+ count=count,
+ start=value.pattern.start if start is None else NominalDate(start),
+ ),
+ )
+ rules.append(
+ creation.create_fixed(f"configure-{index}", value).result.decode()["schedule_id"]
+ )
+ scope = MaintenanceScope(store.installation, "home")
+ clock = Clock()
+ stepper = FixedMaintenance(store, scope, creation.planner, clock, ids)
+ runner = FairFixedRunner(
+ store,
+ scope,
+ stepper,
+ creation.planner.times,
+ clock,
+ RunLimits() if limits is None else limits,
+ )
+ return runner, store, rules
+
+
+@pytest.mark.parametrize(
+ "field,value",
+ [
+ ("max_definitions", 0),
+ ("max_definitions", 51),
+ ("max_slots", 0),
+ ("max_slots", 1001),
+ ("max_periods", 0),
+ ("max_periods", 64001),
+ ("horizon_days", 6),
+ ("horizon_days", 367),
+ ("max_slots", True),
+ ],
+)
+def test_invalid_run_limits_cannot_enter_a_runner(field, value):
+ with pytest.raises(InvalidInput):
+ replace(RunLimits(), **{field: value})
+
+
+def test_more_than_fifty_rules_receive_turns_across_durable_restart(tmp_path):
+ runner, store, rules = configured(tmp_path, 101)
+ results = []
+ for _ in range(3):
+ result = runner.tick()
+ results.append(result)
+ assert len(result.selected_schedule_ids) <= 50
+ reopened = SQLiteDatabase(store.path, store.installation)
+ runner = replace(
+ runner, transactions=reopened, stepper=replace(runner.stepper, transactions=reopened)
+ )
+ assert [result.created for result in results] == [50, 50, 1]
+ assert set().union(*(set(result.selected_schedule_ids) for result in results)) == set(rules)
+ with store.read() as unit:
+ assert len(unit.tasks.occurrences("home", limit=101)) == 101
+ assert unit.recurrence.frontier("home").revision >= 3
+ assert (
+ unit.recurrence.frontier("home").after_schedule_id
+ == results[-1].selected_schedule_ids[-1]
+ )
+
+
+def test_actual_global_thousand_slot_limit_retains_the_next_obligation_for_another_tick(tmp_path):
+ runner, store, _ = configured(tmp_path, count=1001, start=date(1970, 1, 1))
+ first = runner.tick()
+ assert first.status == "budget_exhausted" and first.created == 1000
+ assert first.periods_scanned <= 4096
+ second = runner.tick()
+ assert second.status == "complete" and second.created == 1
+ with store.read() as unit:
+ total = 0
+ after = None
+ while rows := unit.tasks.occurrences("home", limit=101, after=after):
+ total += len(rows)
+ after = rows[-1].occurrence_id
+ assert total == 1001
+
+
+def test_stop_after_one_atomic_step_retains_only_that_step_and_starts_no_next_work(
+ tmp_path, monkeypatch
+):
+ runner, store, _ = configured(tmp_path, count=3)
+ stopped = Event()
+ step = FixedMaintenance.step
+
+ def stop_after(self, *args, **kwargs):
+ result = step(self, *args, **kwargs)
+ stopped.set()
+ return result
+
+ monkeypatch.setattr(FixedMaintenance, "step", stop_after)
+ result = runner.tick(should_continue=lambda: not stopped.is_set())
+ assert result.status == "stopped" and result.created == 1
+ with store.read() as unit:
+ assert len(unit.tasks.occurrences("home")) == 1
+
+
+def test_stop_before_admission_creates_no_frontier_or_work(tmp_path):
+ runner, store, _ = configured(tmp_path)
+ result = runner.tick(should_continue=lambda: False)
+ assert result.status == "stopped" and result.created == 0
+ with store.read() as unit:
+ assert unit.recurrence.frontier("home") is None
+ assert unit.tasks.occurrences("home") == ()
+
+
+def test_one_unavailable_rule_does_not_starve_the_other_selected_rule(tmp_path, monkeypatch):
+ runner, store, rules = configured(tmp_path, 2)
+ step = FixedMaintenance.step
+
+ def fail_one(self, schedule_id, *args, **kwargs):
+ if schedule_id == rules[0]:
+ raise IntegrationUnavailable("timezone_source_unavailable")
+ return step(self, schedule_id, *args, **kwargs)
+
+ monkeypatch.setattr(FixedMaintenance, "step", fail_one)
+ result = runner.tick()
+ assert result.status == "attention" and result.attention_count == 1 and result.created == 1
+ with store.read() as unit:
+ assert len(unit.tasks.occurrences("home")) == 1
+
+
+def test_installed_scope_is_checked_before_claim_or_any_step(tmp_path):
+ runner, store, _ = configured(tmp_path)
+ runner = replace(
+ runner,
+ scope=replace(runner.scope, installation=replace(store.installation, channel="stable")),
+ )
+ with pytest.raises(Forbidden):
+ runner.tick()
+ with store.read() as unit:
+ assert unit.recurrence.frontier("home") is None
+ assert unit.tasks.occurrences("home") == ()
+
+
+def test_repeated_restart_after_every_first_step_cannot_starve_the_other_ninety_eight_rules(
+ tmp_path, monkeypatch
+):
+ runner, store, rules = configured(tmp_path, 100)
+ stopped = Event()
+ step = FixedMaintenance.step
+ observed = []
+
+ def stop_after_first(self, schedule_id, *args, **kwargs):
+ observed.append(schedule_id)
+ result = step(self, schedule_id, *args, **kwargs)
+ stopped.set()
+ return result
+
+ monkeypatch.setattr(FixedMaintenance, "step", stop_after_first)
+ created = 0
+ for _ in range(100):
+ stopped.clear()
+ result = runner.tick(should_continue=lambda: not stopped.is_set())
+ created += result.created
+ reopened = SQLiteDatabase(store.path, store.installation)
+ runner = replace(
+ runner, transactions=reopened, stepper=replace(runner.stepper, transactions=reopened)
+ )
+ assert created == 100
+ assert set(observed) == set(rules) and len(observed) == len(set(observed))
+
+
+def test_unavailable_rule_reports_only_a_safe_scoped_category(tmp_path, monkeypatch):
+ runner, _, rules = configured(tmp_path)
+
+ def unavailable(*args, **kwargs):
+ raise IntegrationUnavailable("private fixture diagnostic")
+
+ monkeypatch.setattr(FixedMaintenance, "step", unavailable)
+ result = runner.tick()
+ assert result.attention_items[0].schedule_id == rules[0]
+ assert result.attention_items[0].code == "time_source_unavailable"
+ assert "private" not in repr(result)
diff --git a/tests/backend/test_fair_recurrence_boundaries.py b/tests/backend/test_fair_recurrence_boundaries.py
new file mode 100644
index 0000000..81c18d2
--- /dev/null
+++ b/tests/backend/test_fair_recurrence_boundaries.py
@@ -0,0 +1,216 @@
+"""Run observations preserve scope, actual work and bounded recovery under failure and stopping."""
+
+from dataclasses import replace
+from datetime import UTC, date, datetime
+from threading import Event
+
+import pytest
+from test_fair_recurrence import configured
+
+from domus.application.fair_recurrence import RunLimits
+from domus.application.fixed_materialization import FixedMaintenance
+from domus.domain.errors import Forbidden, InvalidInput
+
+
+def test_unconfigured_and_configured_without_rules_do_not_create_admission_metadata(tmp_path):
+ from test_atomic_commands import store_at
+
+ runner, store, _ = configured(tmp_path, 0)
+ assert runner.tick().status == "complete"
+ with store.read() as unit:
+ assert unit.recurrence.frontier("home") is None
+ empty = store_at(tmp_path / "empty.sqlite3")
+ assert replace(runner, transactions=empty).tick().status == "unconfigured"
+ with empty.read() as unit:
+ assert unit.recurrence.frontier("home") is None
+
+
+def test_a_miswired_step_scope_is_fatal_instead_of_an_unavailable_rule(tmp_path):
+ runner, store, _ = configured(tmp_path)
+ foreign = replace(
+ runner.stepper.scope, installation=replace(store.installation, channel="stable")
+ )
+ runner = replace(runner, stepper=replace(runner.stepper, scope=foreign))
+ with pytest.raises(Forbidden):
+ runner.tick()
+ with store.read() as unit:
+ assert unit.tasks.occurrences("home") == ()
+
+
+def test_global_period_budget_counts_empty_scan_work_and_keeps_its_durable_continuation(tmp_path):
+ runner, store, rules = configured(
+ tmp_path, count=3, start=date(1970, 1, 1), limits=RunLimits(max_periods=2)
+ )
+ first = runner.tick()
+ assert first.status == "budget_exhausted" and first.created == 1 and first.periods_scanned == 2
+ with store.read() as unit:
+ progress = unit.recurrence.progress("home", rules[0])
+ assert progress.cursor.period_index == 1 and progress.cursor.nominal_count == 1
+ assert len(unit.tasks.occurrences("home")) == 1
+ second = runner.tick()
+ assert second.created == 1 and second.periods_scanned == 2
+
+
+def test_future_rule_scan_is_complete_for_the_requested_horizon_without_fake_current_work(tmp_path):
+ runner, store, _ = configured(tmp_path, start=date(2027, 1, 1))
+ result = runner.tick()
+ assert result.status == "complete" and result.created == 0 and result.periods_scanned == 0
+ with store.read() as unit:
+ assert unit.tasks.occurrences("home") == ()
+
+
+def test_timed_horizon_materializes_the_actual_gap_resolved_deadline(tmp_path):
+ runner, store, _ = configured(tmp_path, timed=True)
+ assert runner.tick().created == 1
+ with store.read() as unit:
+ work = unit.tasks.occurrences("home")[0]
+ assert work.due_at == int(datetime(2026, 3, 29, 1, tzinfo=UTC).timestamp() * 1000)
+
+
+def test_current_original_slot_replay_is_charged_to_work_budget_without_duplicate_effect(tmp_path):
+ from test_fixed_materialization import rewind
+
+ runner, store, rules = configured(tmp_path)
+ assert runner.tick().created == 1
+ rewind(store, rules[0])
+ result = runner.tick()
+ assert result.replayed == 1 and result.created == 0
+ with store.read() as unit:
+ assert len(unit.tasks.occurrences("home")) == 1
+
+
+def test_global_tick_reconciles_actual_restored_absence_without_new_tasks(tmp_path):
+ from test_activation_compatibility import identity_at
+ from test_sqlite_snapshots import Clock as SnapshotClock
+
+ 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)
+ runner, store, _ = configured(root)
+ identity_at(root, store.installation)
+ archive = tmp_path / "snapshot"
+ prepared = tmp_path / "prepared"
+ checkpoint = SQLiteSnapshots(store, archive, store.installation, SnapshotClock()).create(
+ "before-run"
+ )
+ assert runner.tick().created == 1
+ SQLiteRestorations(
+ store, archive, prepared, store.installation, SnapshotClock(), checkpoint
+ ).prepare("restore-run")
+ assert (
+ SQLiteActivation(root, store.installation, SnapshotClock(), prepared).activate().state
+ == "active"
+ )
+ storage = open_storage(root, "edge")
+ try:
+ service = replace(
+ runner,
+ transactions=storage.database,
+ stepper=replace(runner.stepper, transactions=storage.database),
+ )
+ result = service.tick()
+ assert result.created == 0 and result.historical_absent == 1
+ with storage.database.read() as unit:
+ assert unit.tasks.occurrences("home") == ()
+ finally:
+ storage.close()
+
+
+def test_stop_during_horizon_lookup_never_calls_the_next_step(tmp_path, monkeypatch):
+ from domus.infrastructure.recurrence_time import PinnedRecurrenceTime
+
+ runner, store, _ = configured(tmp_path)
+ stopped = Event()
+ after = PinnedRecurrenceTime.after_local_days
+
+ def cancel(self, *args, **kwargs):
+ result = after(self, *args, **kwargs)
+ stopped.set()
+ return result
+
+ def forbidden_step(*args, **kwargs):
+ pytest.fail("A new step started after cancellation")
+
+ monkeypatch.setattr(PinnedRecurrenceTime, "after_local_days", cancel)
+ monkeypatch.setattr(FixedMaintenance, "step", forbidden_step)
+ assert runner.tick(should_continue=lambda: not stopped.is_set()).status == "stopped"
+ with store.read() as unit:
+ assert unit.tasks.occurrences("home") == ()
+
+
+def test_stop_during_step_planning_is_a_stopped_run_without_a_business_write(tmp_path, monkeypatch):
+ from domus.application.fixed_recurrence import FixedRecurrencePlanner
+
+ runner, store, _ = configured(tmp_path)
+ stopped = Event()
+ expand = FixedRecurrencePlanner.expand
+
+ def cancel(self, *args, **kwargs):
+ result = expand(self, *args, **kwargs)
+ stopped.set()
+ return result
+
+ monkeypatch.setattr(FixedRecurrencePlanner, "expand", cancel)
+ result = runner.tick(should_continue=lambda: not stopped.is_set())
+ assert result.status == "stopped" and result.created == 0
+ with store.read() as unit:
+ assert unit.tasks.occurrences("home") == ()
+
+
+def test_an_unknown_postcommit_step_outcome_reports_uncertainty_and_retains_real_work(
+ tmp_path, monkeypatch
+):
+ runner, store, _ = configured(tmp_path)
+ step = FixedMaintenance.step
+
+ def unknown(self, *args, **kwargs):
+ return replace(step(self, *args, **kwargs), status="unknown fixture state")
+
+ monkeypatch.setattr(FixedMaintenance, "step", unknown)
+ result = runner.tick()
+ assert result.status == "attention" and result.attention_items[0].code == "result_unconfirmed"
+ with store.read() as unit:
+ assert len(unit.tasks.occurrences("home")) == 1
+
+
+@pytest.mark.parametrize(
+ "kind,code",
+ [
+ ("idempotency_conflict", "original_proof_conflict"),
+ ("conflict", "source_changed"),
+ ("busy", "storage_busy"),
+ ("storage", "result_unconfirmed"),
+ ("invalid", "invalid_rule"),
+ ("missing", "source_changed"),
+ ("unknown", "recurrence_unavailable"),
+ ],
+)
+def test_attention_categories_preserve_scoped_identity_and_never_echo_raw_diagnostics(
+ tmp_path, monkeypatch, kind, code
+):
+ from domus.domain.errors import Conflict, DomusError, NotFound, StorageBusy, StorageFailure
+
+ runner, _, rules = configured(tmp_path)
+ errors = {
+ "idempotency_conflict": Conflict("idempotency_conflict"),
+ "conflict": Conflict("private fixture"),
+ "busy": StorageBusy("private fixture"),
+ "storage": StorageFailure("private fixture"),
+ "invalid": InvalidInput("private fixture"),
+ "missing": NotFound("private fixture"),
+ "unknown": DomusError("private fixture"),
+ }
+
+ def fail(*args, **kwargs):
+ raise errors[kind]
+
+ monkeypatch.setattr(FixedMaintenance, "step", fail)
+ result = runner.tick()
+ assert (
+ result.attention_items[0].schedule_id == rules[0] and result.attention_items[0].code == code
+ )
+ assert "private" not in repr(result)
diff --git a/tests/backend/test_fixed_materialization.py b/tests/backend/test_fixed_materialization.py
index 1382206..f030f08 100644
--- a/tests/backend/test_fixed_materialization.py
+++ b/tests/backend/test_fixed_materialization.py
@@ -543,3 +543,37 @@ def unavailable(self, policy):
with pytest.raises(IntegrationUnavailable, match="timezone_source_unavailable"):
service.step(rule_id, before)
assert counts(store) == initial
+
+
+def test_rule_snapshot_used_for_a_horizon_cannot_change_before_the_step(tmp_path):
+ service, store, _, _, rule_id, before = configured(tmp_path)
+ with store.write() as unit:
+ expected = unit.recurrence.schedule("home", rule_id)
+ unit.recurrence.save_schedule(replace(expected, state=ScheduleState.PAUSED, revision=2), 1)
+ initial = counts(store)
+ with pytest.raises(Conflict, match="recurrence_source_changed"):
+ service.step(rule_id, before, expected_schedule=expected)
+ assert counts(store) == initial
+
+
+def test_shutdown_during_external_planning_does_not_start_the_atomic_write(tmp_path, monkeypatch):
+ from threading import Event
+
+ from domus.application.fixed_recurrence import FixedRecurrencePlanner
+
+ service, store, _, _, rule_id, before = configured(tmp_path)
+ stopped = Event()
+ expand = FixedRecurrencePlanner.expand
+
+ def cancel(self, *args, **kwargs):
+ result = expand(self, *args, **kwargs)
+ stopped.set()
+ return result
+
+ monkeypatch.setattr(FixedRecurrencePlanner, "expand", cancel)
+ initial = counts(store)
+ result = service.step(rule_id, before, should_continue=lambda: not stopped.is_set())
+ assert (
+ result.status == "stopped" and result.receipt is None and result.cursor.nominal_count == 0
+ )
+ assert counts(store) == initial
diff --git a/tests/backend/test_fixed_materialization_shutdown.py b/tests/backend/test_fixed_materialization_shutdown.py
new file mode 100644
index 0000000..56feab9
--- /dev/null
+++ b/tests/backend/test_fixed_materialization_shutdown.py
@@ -0,0 +1,43 @@
+"""Cancellation around provider and writer admission preserves every original business fact."""
+
+from contextlib import contextmanager
+from dataclasses import replace
+from threading import Event
+from types import SimpleNamespace
+
+from test_fixed_materialization import configured
+from test_task_commands import counts
+
+from domus.infrastructure.recurrence_time import PinnedRecurrenceTime
+
+
+def test_stop_before_policy_lookup_does_no_external_or_business_work(tmp_path, monkeypatch):
+ import pytest
+
+ service, store, _, _, rule_id, before = configured(tmp_path)
+
+ def denied(*args, **kwargs):
+ pytest.fail("Stopped maintenance contacted the policy source")
+
+ monkeypatch.setattr(PinnedRecurrenceTime, "require_policy", denied)
+ initial = counts(store)
+ assert service.step(rule_id, before, should_continue=lambda: False).status == "stopped"
+ assert counts(store) == initial
+
+
+def test_stop_while_entering_the_writer_leaves_no_effect_or_allocated_identity(tmp_path):
+ service, store, _, identifiers, rule_id, before = configured(tmp_path)
+ stopped = Event()
+ initial = counts(store)
+ allocated = identifiers.count
+
+ @contextmanager
+ def stopping_writer():
+ with store.write() as unit:
+ stopped.set()
+ yield unit
+
+ service = replace(service, transactions=SimpleNamespace(read=store.read, write=stopping_writer))
+ result = service.step(rule_id, before, should_continue=lambda: not stopped.is_set())
+ assert result.status == "stopped" and result.receipt is None
+ assert counts(store) == initial and identifiers.count == allocated
diff --git a/tests/backend/test_recurrence_frontier.py b/tests/backend/test_recurrence_frontier.py
new file mode 100644
index 0000000..8211fb0
--- /dev/null
+++ b/tests/backend/test_recurrence_frontier.py
@@ -0,0 +1,177 @@
+"""Fair rule selection has a small scoped durable cursor, separate from task/work progress."""
+
+import json
+from concurrent.futures import ThreadPoolExecutor
+from dataclasses import FrozenInstanceError, replace
+from threading import Barrier
+
+import pytest
+from test_sqlite_snapshots import Clock
+from test_task_commands import counts, fixture
+
+from domus.domain.errors import Conflict, DatabaseRejected, InvalidInput, StorageFailure
+from domus.domain.recurrence_frontier import RecurrenceFrontier
+from domus.infrastructure.sqlite import SQLiteDatabase
+from domus.infrastructure.sqlite_recurrence_records import frontier_document, read_frontier
+from domus.infrastructure.sqlite_snapshots import SQLiteSnapshots
+
+
+@pytest.mark.parametrize(
+ "field,value",
+ [
+ ("household_id", ""),
+ ("after_schedule_id", ""),
+ ("selected_at", True),
+ ("selected_at", -1),
+ ("revision", 0),
+ ("revision", True),
+ ],
+)
+def test_frontier_rejects_invalid_scope_revision_or_clock(field, value):
+ with pytest.raises(InvalidInput):
+ replace(RecurrenceFrontier("home", None, 100), **{field: value})
+
+
+def test_frontier_is_immutable_and_can_keep_a_removed_selection_key():
+ value = RecurrenceFrontier("home", "retired-rule", 100)
+ with pytest.raises(FrozenInstanceError):
+ value.revision = 2
+ assert read_frontier(frontier_document(value).encoded) == value
+ assert (
+ read_frontier(
+ frontier_document(replace(value, after_schedule_id=None)).encoded
+ ).after_schedule_id
+ is None
+ )
+
+
+def test_frontier_commits_reopens_and_never_creates_task_or_business_evidence(tmp_path):
+ _, store, _, _ = fixture(tmp_path)
+ initial = counts(store)
+ value = RecurrenceFrontier("home", "removed-rule", 100)
+ with store.write() as unit:
+ assert unit.recurrence.frontier("home") is None
+ unit.recurrence.save_frontier(value, 0)
+ reopened = SQLiteDatabase(store.path, store.installation)
+ with reopened.read() as unit:
+ assert unit.recurrence.frontier("home") == value
+ assert unit.recurrence.frontier("other") is None
+ assert counts(store) == initial
+ assert (
+ SQLiteSnapshots(store, tmp_path / "checkpoint", store.installation, Clock())
+ .create("frontier")
+ .schema_version
+ == 8
+ )
+
+
+def test_frontier_cas_and_duplicate_initialization_preserve_the_current_selection(tmp_path):
+ _, store, _, _ = fixture(tmp_path)
+ value = RecurrenceFrontier("home", "first", 100)
+ with store.write() as unit:
+ unit.recurrence.save_frontier(value, 0)
+ updated = replace(value, after_schedule_id="last", selected_at=101, revision=2)
+ with store.write() as unit:
+ unit.recurrence.save_frontier(updated, 1)
+ for proposed, expected in [(value, 0), (updated, 1), (replace(updated, revision=3), 1)]:
+ with pytest.raises(Conflict):
+ with store.write() as unit:
+ unit.recurrence.save_frontier(proposed, expected)
+ with store.read() as unit:
+ assert unit.recurrence.frontier("home") == updated
+
+
+def test_failed_frontier_claim_and_foreign_household_leave_no_partial_record(tmp_path):
+ _, store, _, _ = fixture(tmp_path)
+ value = RecurrenceFrontier("home", "first", 100)
+ with pytest.raises(RuntimeError, match="fixture_failure"):
+ with store.write() as unit:
+ unit.recurrence.save_frontier(value, 0)
+ raise RuntimeError("fixture_failure")
+ with pytest.raises(Conflict):
+ with store.write() as unit:
+ unit.recurrence.save_frontier(replace(value, household_id="other"), 0)
+ with store.read() as unit:
+ assert unit.recurrence.frontier("home") is None
+
+
+@pytest.mark.parametrize(
+ "change", ["unknown-version", "extra-field", "bool-revision", "duplicate-key"]
+)
+def test_closed_frontier_codec_never_guesses_or_coerces_stored_data(change):
+ document = frontier_document(RecurrenceFrontier("home", None, 100)).decode()
+ if change == "unknown-version":
+ document["version"] = 2
+ elif change == "extra-field":
+ document["unexpected"] = None
+ elif change == "bool-revision":
+ document["revision"] = True
+ raw = json.dumps(document) if change != "duplicate-key" else '{"version":1,"version":1}'
+ with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
+ read_frontier(raw)
+
+
+def test_corrupt_frontier_header_is_rejected_by_read_and_checkpoint_without_repair(tmp_path):
+ _, store, _, _ = fixture(tmp_path)
+ value = RecurrenceFrontier("home", "first", 100)
+ with store.write() as unit:
+ unit.recurrence.save_frontier(value, 0)
+ with store._connection() as connection:
+ connection.execute("UPDATE recurrence_frontiers SET after_schedule_id='other'")
+ before = store.path.read_bytes()
+ with store.read() as unit:
+ with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
+ unit.recurrence.frontier("home")
+ with pytest.raises(DatabaseRejected, match="invalid_snapshot_records"):
+ SQLiteSnapshots(store, tmp_path / "bad", store.installation, Clock()).create("bad")
+ assert store.path.read_bytes() == before
+
+
+def test_two_independent_initial_claims_commit_only_one_frontier(tmp_path):
+ _, store, _, _ = fixture(tmp_path)
+ gate = Barrier(2)
+
+ def claim(key):
+ independent = SQLiteDatabase(store.path, store.installation, busy_timeout=5)
+ gate.wait(timeout=5)
+ try:
+ with independent.write() as unit:
+ unit.recurrence.save_frontier(RecurrenceFrontier("home", key, 100), 0)
+ return "saved"
+ except Conflict:
+ return "conflict"
+
+ with ThreadPoolExecutor(max_workers=2) as workers:
+ assert sorted(workers.map(claim, ("a", "z"))) == ["conflict", "saved"]
+ with store.read() as unit:
+ assert unit.recurrence.frontier("home").after_schedule_id in ("a", "z")
+
+
+def test_genuine_prior_unreleased_schema8_layout_is_refused_without_mutating_its_data(tmp_path):
+ import hashlib
+ import sqlite3
+ from contextlib import closing
+ from pathlib import Path
+
+ from test_sqlite_transactions import IDENTITY
+
+ fixture = Path("tests/fixtures/sqlite/unreleased-c581-schema8.sql")
+ assert (
+ hashlib.sha256(fixture.read_bytes()).hexdigest()
+ == "2f884cd00b735ddf60c567a908bf05e709f887f699953bce7808326e017647b4"
+ )
+ path = tmp_path / "prior-unreleased.sqlite3"
+ with closing(sqlite3.connect(path, autocommit=True)) as connection:
+ connection.executescript(fixture.read_text())
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 8
+ assert connection.execute("SELECT COUNT(*) FROM recurrence_slots").fetchone()[0] == 1
+ before = path.read_bytes()
+ store = SQLiteDatabase(path, IDENTITY)
+ for method in (store.read, store.write):
+ with pytest.raises(DatabaseRejected, match="unrelated_database"):
+ with method():
+ pytest.fail("An unknown prior development layout was accepted")
+ assert path.read_bytes() == before
+ with pytest.raises(DatabaseRejected, match="unrelated_database"):
+ store.initialize(require_existing=True)
+ assert path.read_bytes() == before
diff --git a/tests/backend/test_sqlite_task_reviews.py b/tests/backend/test_sqlite_task_reviews.py
index 87a435c..e64aba6 100644
--- a/tests/backend/test_sqlite_task_reviews.py
+++ b/tests/backend/test_sqlite_task_reviews.py
@@ -45,7 +45,12 @@ def test_original_schema6_migration_preserves_rows_and_projects_existing_cause(t
store.initialize(require_existing=True)
with store._connection() as connection:
upgraded = rows(connection)
- added = {"recurrence_rules", "recurrence_progress", "recurrence_slots"}
+ added = {
+ "recurrence_rules",
+ "recurrence_progress",
+ "recurrence_slots",
+ "recurrence_frontiers",
+ }
assert set(upgraded) == set(original) | added
assert all(upgraded[table] == values for table, values in original.items())
assert all(upgraded[table] == [] for table in added)
diff --git a/tests/containers/test_recurrence_image.py b/tests/containers/test_recurrence_image.py
index 1db815d..df042ae 100644
--- a/tests/containers/test_recurrence_image.py
+++ b/tests/containers/test_recurrence_image.py
@@ -23,6 +23,7 @@ def test_current_image_executes_recurrence_creation_planning_and_closed_record_c
from tempfile import TemporaryDirectory
from domus.application.fixed_recurrence import FixedRecurrencePlanner
from domus.application.fixed_materialization import FixedMaintenance, MaintenanceScope
+from domus.application.fair_recurrence import FairFixedRunner, RunLimits
from domus.application.household_commands import HouseholdCommands, SetupHousehold
from domus.application.recurrence_commands import CreateFixedTask, RecurrenceCommands
from domus.application.task_requests import CreateTask
@@ -103,11 +104,20 @@ def get(self, user_id):
with store.read() as unit:
assert len(unit.tasks.occurrences('home')) == 2
assert len(unit.recurrence.slots('home', facts['schedule_id'])) == 2
-print('Current-source recurrence creation, materialization and pinned-time codecs passed.')
+ other = creation.create_fixed('fair-create', request).result.decode()
+ fair = FairFixedRunner(store, maintenance.scope, maintenance, times, clock,
+ RunLimits(max_slots=1))
+ assert fair.tick().created == 1
+ assert fair.tick().created == 1
+ with store.read() as unit:
+ assert len(unit.tasks.occurrences('home')) == 4
+ assert unit.recurrence.frontier('home') is not None
+ assert unit.recurrence.progress('home', other['schedule_id']).cursor.closed
+print('Current-source bounded recurrence, materialization and pinned-time codecs passed.')
""",
)
assert result.returncode == 0, result.stderr
assert (
- "Current-source recurrence creation, materialization and pinned-time codecs passed."
+ "Current-source bounded recurrence, materialization and pinned-time codecs passed."
in result.stdout
)
diff --git a/tests/fixtures/sqlite/unreleased-c581-schema8.sql b/tests/fixtures/sqlite/unreleased-c581-schema8.sql
new file mode 100644
index 0000000..39c759e
--- /dev/null
+++ b/tests/fixtures/sqlite/unreleased-c581-schema8.sql
@@ -0,0 +1,61 @@
+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('identity-2','home','ha_admin','household.setup','setup','{"administrator_member_id":"identity-1","mapping_mode":"all_ha_administrators","transition":"household.configured"}',100);
+INSERT INTO "audit" VALUES('identity-5','home','ha_admin','recurrence.fixed.create','scheduled','{"request":{"pattern":{"by_day":[],"by_month":[],"by_month_day":[],"by_set_position":[],"count":1,"frequency":"DAILY","interval":1,"start":"2026-03-29","start_kind":"date","until":null,"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":"Clean the filter"},"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":"identity-3","first_occurrence_id":null,"pattern_revision":1,"processing":"pending","revision":1,"schedule_id":"identity-4","state":"active"},"transition":"recurrence.fixed.created"}',100);
+INSERT INTO "audit" VALUES('identity-7','home','system:recurrence','recurrence.materialize','cfb79ea199608518d585ea2f90ee20b82ca1f37a1c14365f4185ef9ea7a29f23','{"request":{"definition_id":"identity-3","nominal":{"kind":"date","local":"2026-03-29"},"pattern":"0b262babd83a673c1f2a6bd87d85a0da9c98b2aa6f884167103251d54605e29f","resolved_at":null,"slot_key":"cfb79ea199608518d585ea2f90ee20b82ca1f37a1c14365f4185ef9ea7a29f23","version":1,"zone_digest":null},"result":{"definition_id":"identity-3","nominal":{"kind":"date","local":"2026-03-29"},"occurrence_id":"identity-6","resolved_at":null,"schedule_id":"identity-4","slot_key":"cfb79ea199608518d585ea2f90ee20b82ca1f37a1c14365f4185ef9ea7a29f23"}}',100);
+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,'Home','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,'047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6','edge');
+CREATE TABLE members(member_id TEXT PRIMARY KEY, household_id TEXT NOT NULL REFERENCES households(household_id), ha_user_id TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('administrator','adult','restricted','viewer')), status TEXT NOT NULL CHECK(status IN ('pending','active','suspended','archived')), revision INTEGER NOT NULL CHECK(revision>=1), exceptions TEXT NOT NULL, UNIQUE(household_id,ha_user_id));
+INSERT INTO "members" VALUES('identity-1','home','ha_admin','viewer','active',1,'{"rules":[]}');
+INSERT INTO "members" VALUES('child','home','ha_child','restricted','active',1,'{"rules":[]}');
+INSERT INTO "members" VALUES('other','home','ha_other','restricted','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,'identity-2','home','household.setup','{"household_id":"home","revision":1}',100,0);
+INSERT INTO "outbox" VALUES(2,'identity-5','home','recurrence.fixed.create','{"definition_id":"identity-3","first_occurrence_id":null,"kind":"recurrence.fixed.created","pattern_revision":1,"processing":"pending","revision":1,"schedule_id":"identity-4","state":"active","version":1}',100,0);
+INSERT INTO "outbox" VALUES(3,'identity-7','home','recurrence.materialize','{"definition_id":"identity-3","kind":"recurrence.materialize","nominal":{"kind":"date","local":"2026-03-29"},"occurrence_id":"identity-6","resolved_at":null,"schedule_id":"identity-4","slot_key":"cfb79ea199608518d585ea2f90ee20b82ca1f37a1c14365f4185ef9ea7a29f23","version":1}',100,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','ha_admin','household.setup','setup','fd81d9a903eff49c605ff960bfae80f61f684b2f1f12ff15fb363cd7bdea3c51','identity-2','{"administrator_member_id":"identity-1","household_id":"home","mapping_mode":"all_ha_administrators","member_id":"identity-1","policy_revision":1,"revision":1,"state":"configured"}',100);
+INSERT INTO "receipts" VALUES('home','ha_admin','recurrence.fixed.create','scheduled','d6159b5b9b591d0e6ec34dbc50f4216e2f1f4f0f76e66025ce1d9f55a19cff99','identity-5','{"definition_id":"identity-3","first_occurrence_id":null,"pattern_revision":1,"processing":"pending","revision":1,"schedule_id":"identity-4","state":"active"}',100);
+INSERT INTO "receipts" VALUES('home','system:recurrence','recurrence.materialize','cfb79ea199608518d585ea2f90ee20b82ca1f37a1c14365f4185ef9ea7a29f23','71fad31bbe56f0e51e738e43451c2c7f1744e511e4aa234a536564f9c26fc2e7','identity-7','{"definition_id":"identity-3","nominal":{"kind":"date","local":"2026-03-29"},"occurrence_id":"identity-6","resolved_at":null,"schedule_id":"identity-4","slot_key":"cfb79ea199608518d585ea2f90ee20b82ca1f37a1c14365f4185ef9ea7a29f23"}',100);
+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_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), document TEXT NOT NULL, UNIQUE(household_id,schedule_id,pattern_revision), FOREIGN KEY(household_id,schedule_id,pattern_revision) REFERENCES recurrence_rules(household_id,schedule_id,pattern_revision) DEFERRABLE INITIALLY DEFERRED);
+INSERT INTO "recurrence_progress" VALUES('identity-4','home',1,2,'{"cursor":{"binding":"66561183da24e4a47180f929275ac0f8e91ddf157ef834db6b9d4c6ddfd06dec","closed":true,"nominal_count":1,"offset":1,"period_index":0},"household_id":"home","last_run_at":100,"pattern_revision":1,"range_complete":true,"revision":2,"schedule_id":"identity-4","version":1}');
+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')), document TEXT NOT NULL, UNIQUE(household_id,schedule_id), UNIQUE(household_id,schedule_id,definition_id), UNIQUE(household_id,schedule_id,pattern_revision), FOREIGN KEY(household_id,definition_id) REFERENCES task_definitions(household_id,definition_id), FOREIGN KEY(household_id,schedule_id,pattern_revision) REFERENCES recurrence_progress(household_id,schedule_id,pattern_revision) DEFERRABLE INITIALLY DEFERRED);
+INSERT INTO "recurrence_rules" VALUES('identity-4','home','identity-3',1,1,'active','{"created_at":100,"definition_id":"identity-3","household_id":"home","pattern":{"by_day":[],"by_month":[],"by_month_day":[],"by_set_position":[],"count":1,"frequency":"DAILY","interval":1,"start":{"kind":"date","local":"2026-03-29"},"until":null,"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":"identity-4","state":"active","version":1}');
+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'), 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) REFERENCES recurrence_rules(household_id,schedule_id,definition_id), 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','cfb79ea199608518d585ea2f90ee20b82ca1f37a1c14365f4185ef9ea7a29f23','identity-4','identity-3',1,'identity-6','date','2026-03-29',NULL,100,'system:recurrence','recurrence.materialize','{"created_at":100,"installation":{"channel":"edge","instance_id":"047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6"},"occurrence_id":"identity-6","planned":{"nominal":{"kind":"date","local":"2026-03-29"},"resolved":null},"schedule":{"created_at":100,"definition_id":"identity-3","household_id":"home","pattern":{"by_day":[],"by_month":[],"by_month_day":[],"by_set_position":[],"count":1,"frequency":"DAILY","interval":1,"start":{"kind":"date","local":"2026-03-29"},"until":null,"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":"identity-4","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('identity-3','home',1,'active','{"content":{"description":"","priority":"normal","tags":[],"title":"Clean the filter"},"created_at":100,"definition_id":"identity-3","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('identity-6','home','identity-3',1,'available',NULL,NULL,'{"assignee_ids":[],"created_at":100,"current_cycle_id":null,"current_cycle_revision":null,"definition_snapshot":{"content":{"description":"","priority":"normal","tags":[],"title":"Clean the filter"},"created_at":100,"definition_id":"identity-3","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":null,"household_id":"home","occurrence_id":"identity-6","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 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);
+DELETE FROM "sqlite_sequence";
+INSERT INTO "sqlite_sequence" VALUES('outbox',3);
+PRAGMA application_id=1146047829;
+PRAGMA user_version=8;
+COMMIT;
From 872bdba55dc7fa9ec942a9f0ff27a635b62d0169 Mon Sep 17 00:00:00 2001
From: Efra Espada
Date: Mon, 2 Nov 2026 09:41:42 +0100
Subject: [PATCH 08/16] feat: Run fixed recurrence within both owned
application lifetimes
---
backend/src/domus/composition/ingress.py | 10 +-
backend/src/domus/composition/recurrence.py | 58 ++++
backend/src/domus/composition/server.py | 11 +-
.../domus/infrastructure/recurrence_worker.py | 90 ++++++
docs/architecture-recurrence.md | 24 ++
docs/evidence/recurrence-lifecycle.json | 112 +++++++
docs/evidence/recurrence-storage.json | 2 +-
specs/recurrence-storage-continuation.md | 11 +
tests/backend/test_recurrence_runtime.py | 280 ++++++++++++++++++
tests/backend/test_recurrence_worker.py | 136 +++++++++
tests/containers/test_recurrence_image.py | 36 ++-
11 files changed, 765 insertions(+), 5 deletions(-)
create mode 100644 backend/src/domus/composition/recurrence.py
create mode 100644 backend/src/domus/infrastructure/recurrence_worker.py
create mode 100644 docs/evidence/recurrence-lifecycle.json
create mode 100644 tests/backend/test_recurrence_runtime.py
create mode 100644 tests/backend/test_recurrence_worker.py
diff --git a/backend/src/domus/composition/ingress.py b/backend/src/domus/composition/ingress.py
index 3b01e7b..1a33563 100644
--- a/backend/src/domus/composition/ingress.py
+++ b/backend/src/domus/composition/ingress.py
@@ -16,6 +16,7 @@
from domus.application.policy_commands import PolicyCommands
from domus.application.recovery_commands import RecoveryCommands, RecoveryQueries
from domus.application.task_events import TaskEventSource
+from domus.composition.recurrence import bind_lifespan, recurrence_worker
from domus.composition.tasks import task_contexts, task_event_source
from domus.domain.errors import DomusError, InvalidInput
from domus.domain.storage import Installation
@@ -32,6 +33,7 @@
from domus.infrastructure.ha_websocket import HomeAssistantWebSocket
from domus.infrastructure.ingress_sessions import IngressRequest, IngressSessions
from domus.infrastructure.owned_storage import OwnedStorage, open_storage, reject_owned_database
+from domus.infrastructure.recurrence_worker import RecurrenceWorker
from domus.infrastructure.sqlite import SQLiteDatabase
@@ -39,10 +41,12 @@
class NativeRuntime:
app: FastAPI
sessions: IngressSessions = field(repr=False)
+ recurrence: RecurrenceWorker = field(repr=False)
storage: OwnedStorage | None = field(default=None, repr=False)
task_events: TaskEventSource | None = field(default=None, repr=False)
def close(self) -> None:
+ self.recurrence.close()
try:
self.sessions.close()
finally:
@@ -128,7 +132,11 @@ def ui_binding(request: Request) -> None:
ui_bundle=load_bundle(Path(__file__).resolve().parents[1] / "ui"),
ui_binding=ui_binding,
)
- return NativeRuntime(app, sessions, task_events=task_event_source(store, store.installation))
+ worker = recurrence_worker(store, store.installation)
+ bind_lifespan(app, worker)
+ return NativeRuntime(
+ app, sessions, worker, task_events=task_event_source(store, store.installation)
+ )
def create_runtime(token: str, channel: str) -> FastAPI:
diff --git a/backend/src/domus/composition/recurrence.py b/backend/src/domus/composition/recurrence.py
new file mode 100644
index 0000000..f50faf3
--- /dev/null
+++ b/backend/src/domus/composition/recurrence.py
@@ -0,0 +1,58 @@
+"""Bind local maintenance to the actual persistent installation and ASGI-owned lifetime."""
+
+from collections.abc import AsyncIterator, Callable, Mapping
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI
+from starlette.concurrency import run_in_threadpool
+
+from domus.application.fair_recurrence import FairFixedRunner, FixedRun
+from domus.application.fixed_materialization import FixedMaintenance, MaintenanceScope
+from domus.application.fixed_recurrence import FixedRecurrencePlanner
+from domus.application.ports import Transactions
+from domus.domain.errors import Forbidden
+from domus.domain.nominal_recurrence import CalendarNominalExpansion
+from domus.domain.storage import Installation
+from domus.infrastructure.application_runtime import SecureIdentifiers, SystemClock
+from domus.infrastructure.recurrence_time import PinnedRecurrenceTime
+from domus.infrastructure.recurrence_worker import RecurrenceWorker
+
+
+def recurrence_worker(store: Transactions, installation: Installation) -> RecurrenceWorker:
+ times, clock, identifiers = PinnedRecurrenceTime(), SystemClock(), SecureIdentifiers()
+ planner = FixedRecurrencePlanner(CalendarNominalExpansion(), times)
+
+ def tick(should_continue: Callable[[], bool]) -> FixedRun:
+ if not should_continue():
+ return FixedRun("stopped")
+ with store.read() as unit:
+ if unit.recurrence.installation != installation:
+ raise Forbidden("maintenance_scope_denied")
+ household = unit.households.current()
+ if household is None:
+ return FixedRun("unconfigured")
+ scope = MaintenanceScope(installation, household.household_id)
+ return FairFixedRunner(
+ store,
+ scope,
+ FixedMaintenance(store, scope, planner, clock, identifiers),
+ times,
+ clock,
+ ).tick(should_continue=should_continue)
+
+ return RecurrenceWorker(tick)
+
+
+def bind_lifespan(app: FastAPI, worker: RecurrenceWorker) -> None:
+ previous = app.router.lifespan_context
+
+ @asynccontextmanager
+ async def lifespan(app: FastAPI) -> AsyncIterator[Mapping[str, object]]:
+ async with previous(app) as state:
+ try:
+ worker.start()
+ yield {} if state is None else state
+ finally:
+ await run_in_threadpool(worker.close)
+
+ app.router.lifespan_context = lifespan
diff --git a/backend/src/domus/composition/server.py b/backend/src/domus/composition/server.py
index 476a8d0..8a348d8 100644
--- a/backend/src/domus/composition/server.py
+++ b/backend/src/domus/composition/server.py
@@ -13,6 +13,7 @@
from domus.application.sessions import SessionGuard
from domus.application.task_events import TaskEventSource
from domus.composition.household import standalone_context
+from domus.composition.recurrence import bind_lifespan, recurrence_worker
from domus.composition.tasks import task_contexts, task_event_source
from domus.domain.errors import DomusError, InvalidInput
from domus.domain.storage import Installation
@@ -27,6 +28,7 @@
from domus.infrastructure.ha_websocket import HomeAssistantWebSocket
from domus.infrastructure.memory_attempts import MemoryLoginAttempts
from domus.infrastructure.owned_storage import OwnedStorage, open_storage, reject_owned_database
+from domus.infrastructure.recurrence_worker import RecurrenceWorker
from domus.infrastructure.session_access import SessionActor
from domus.infrastructure.sqlite import SQLiteDatabase
from domus.infrastructure.standalone_sessions import StandaloneIdentity
@@ -38,8 +40,11 @@ class AuthenticationRuntime:
identity: StandaloneIdentity = field(repr=False)
storage: OwnedStorage | None = field(default=None, repr=False)
task_events: TaskEventSource | None = field(default=None, repr=False)
+ recurrence: RecurrenceWorker | None = field(default=None, repr=False)
def close(self) -> tuple[int, int]:
+ if self.recurrence is not None:
+ self.recurrence.close()
try:
return self.identity.shutdown()
finally:
@@ -119,7 +124,11 @@ def session_tasks(session_id: str) -> TaskHTTPContext:
task_context=tasks,
ui_bundle=load_bundle(Path(__file__).resolve().parents[1] / "ui"),
)
- return AuthenticationRuntime(app, identity, task_events=events)
+ worker = None
+ if store is not None:
+ worker = recurrence_worker(store, installation)
+ bind_lifespan(app, worker)
+ return AuthenticationRuntime(app, identity, task_events=events, recurrence=worker)
def main(argv: list[str] | None = None) -> int:
diff --git a/backend/src/domus/infrastructure/recurrence_worker.py b/backend/src/domus/infrastructure/recurrence_worker.py
new file mode 100644
index 0000000..db98be2
--- /dev/null
+++ b/backend/src/domus/infrastructure/recurrence_worker.py
@@ -0,0 +1,90 @@
+"""One explicitly owned polling thread; stop and join before releasing its data lease."""
+
+import logging
+import math
+from collections.abc import Callable
+from threading import Event, Lock, Thread
+
+from domus.application.fair_recurrence import FixedRun
+from domus.domain.errors import DomusError, Forbidden
+
+
+class RecurrenceWorker:
+ def __init__(
+ self, tick: Callable[[Callable[[], bool]], FixedRun], *, interval: float = 5.0
+ ) -> None:
+ if (
+ type(interval) not in (int, float)
+ or not math.isfinite(interval)
+ or not 0 < interval <= 60
+ ):
+ raise ValueError("invalid_recurrence_poll_interval")
+ self._tick = tick
+ self._interval = interval
+ self._stop = Event()
+ self._lock = Lock()
+ self._thread: Thread | None = None
+ self._closed = False
+ self._last_run: FixedRun | None = None
+ self._last_failure: str | None = None
+
+ @property
+ def is_running(self) -> bool:
+ with self._lock:
+ return self._thread is not None and self._thread.is_alive()
+
+ @property
+ def last_run(self) -> FixedRun | None:
+ with self._lock:
+ return self._last_run
+
+ @property
+ def last_failure(self) -> str | None:
+ with self._lock:
+ return self._last_failure
+
+ def start(self) -> None:
+ with self._lock:
+ if self._closed:
+ raise RuntimeError("recurrence_worker_closed")
+ if self._thread is not None:
+ return
+ thread = Thread(target=self._run, name="domus-recurrence", daemon=False)
+ thread.start()
+ self._thread = thread
+
+ def close(self) -> None:
+ with self._lock:
+ self._closed = True
+ self._stop.set()
+ thread = self._thread
+ if thread is not None:
+ thread.join()
+
+ def _should_continue(self) -> bool:
+ return not self._stop.is_set()
+
+ def _run(self) -> None:
+ while self._should_continue():
+ try:
+ result = self._tick(self._should_continue)
+ except Exception as error:
+ code = (
+ "scope_denied"
+ if isinstance(error, Forbidden)
+ else "recurrence_unavailable"
+ if isinstance(error, DomusError)
+ else "result_unconfirmed"
+ )
+ with self._lock:
+ self._last_run, self._last_failure = None, code
+ logging.getLogger("domus.recurrence").warning(
+ "recurrence_run_unconfirmed code=%s", code
+ )
+ if isinstance(error, Forbidden):
+ return
+ else:
+ with self._lock:
+ self._last_run, self._last_failure = result, None
+ if self._stop.wait(self._interval):
+ return
diff --git a/docs/architecture-recurrence.md b/docs/architecture-recurrence.md
index 6ad8014..66c7bb5 100644
--- a/docs/architecture-recurrence.md
+++ b/docs/architecture-recurrence.md
@@ -204,3 +204,27 @@ These are application runs, without a production thread or new HTTP surface. Own
startup/shutdown wiring, relative lifecycle and durable calendar/review attention remain required.
See [fairness evidence](evidence/recurrence-fairness.json) and the
[migration boundary](tasks/migration.md).
+## Owned fixed-run polling lifecycle
+
+Both persistent compositions now bind the real fixed runner through
+[recurrence composition](../backend/src/domus/composition/recurrence.py). Merely constructing an
+application does not start it. ASGI lifespan starts one
+[owned worker](../backend/src/domus/infrastructure/recurrence_worker.py), which attempts an immediate
+bounded run and waits five seconds after each attempt. The thread and waiting primitives remain
+in infrastructure; application/domain planning and command code remain independent of them.
+
+Composition validates its installation/channel against the repository and selects its actual current
+household. It never accepts a maintenance actor or scope from a request or a stored rule. An
+unconfigured installation stays unconfigured; no-active-rule polls do not churn frontier/business
+records. Technical materialization needs no human HA request and cannot perform or approve work.
+
+Shutdown sets cancellation, waits for the admitted step to finish or roll back, and joins before
+identity/storage disposal releases the actual installation lease. The ASGI wrapper preserves prior
+lifespan state and closes the worker on startup/application failure. Ordinary failures retain a safe
+process-local category and wait before retrying; scope denial stops the worker. These observations
+do not establish durable attention or full calendar completeness. Preview without SQLite has no
+worker. The [lifecycle evidence](evidence/recurrence-lifecycle.json) separates real composed tests,
+host smoke execution and fresh hosted CPU image results from owner-HA deployment.
+
+Relative execution, durable attention/calendar predicates, protected public interfaces, reviewed
+schedule changes and the complete retained recurrence contract remain unfinished.
diff --git a/docs/evidence/recurrence-lifecycle.json b/docs/evidence/recurrence-lifecycle.json
new file mode 100644
index 0000000..333a59f
--- /dev/null
+++ b/docs/evidence/recurrence-lifecycle.json
@@ -0,0 +1,112 @@
+{
+ "status": "partial; owned fixed-rule production polling implemented; complete retained recurrence contract remains in progress",
+ "recordedAtUTC": "2026-11-02T08:41:10.835735+00:00",
+ "issue": "https://github.com/vypdev/domus/issues/20",
+ "pullRequest": "https://github.com/vypdev/domus/pull/22",
+ "sourceBase": "ae73710972c386b18406921316d6311ced0d76e6",
+ "backendSourceInventorySHA256": "a2e20456183762b241c54479bb1b89b3cee7ebc17b3e9085d0586a0600c6f700",
+ "sourceManifestMethod": "SHA256 of compact sorted JSON mapping every authored Python module path, including package initializers, to its byte SHA256 value.",
+ "implemented": [
+ "Both persistent native/standalone compositions bind the actual bounded fixed runner and pinned providers to their owned installation and actual current household.",
+ "Application construction is inert; ASGI lifespan starts one owned non-daemon thread, preserves prior lifespan state and closes the worker on startup/application failure.",
+ "Immediate first attempt, then a five-second interruptible wait after every attempt; ordinary failures use safe categories and retry on that interval, while scope denial stops the worker.",
+ "Stop reaches the actual application cancellation boundary; worker joins before runtime identity/storage disposal releases the installation lease.",
+ "Unconfigured installations remain unconfigured; no-active-rule polls do not create a frontier or business effects; identity-only previews have no worker.",
+ "Process-local last-run/failure observations are synchronized and do not certify durable attention, complete calendar coverage or downstream delivery."
+ ],
+ "testsFirst": {
+ "worker": "Original test collection failed with missing domus.infrastructure.recurrence_worker before implementation.",
+ "productionComposition": "Original test collection failed with missing domus.composition.recurrence before implementation.",
+ "firstFullCampaign": "All3667 tests passed; coverage initially99.99% because NativeRuntime admitted an optional absent worker that real native composition never constructs. Its type now requires the worker; no synthetic coverage-only branch test or coverage exclusion was added."
+ },
+ "completeBackend": {
+ "executions": 3667,
+ "durationSeconds": 96.73,
+ "authoredModules": 130,
+ "coverageTotals": {
+ "covered_lines": 9242,
+ "num_statements": 9242,
+ "percent_covered": 100.0,
+ "percent_covered_display": "100.00",
+ "missing_lines": 0,
+ "excluded_lines": 272,
+ "percent_statements_covered": 100.0,
+ "percent_statements_covered_display": "100.00",
+ "num_branches": 2630,
+ "num_partial_branches": 0,
+ "covered_branches": 2630,
+ "missing_branches": 0,
+ "percent_branches_covered": 100.0,
+ "percent_branches_covered_display": "100.00"
+ },
+ "independentGlobalAndPerModuleAudit": "passed"
+ },
+ "focusedLifecycle": {
+ "executions": 69,
+ "durationSeconds": 5.75,
+ "newModuleStatements": 102,
+ "newModuleBranches": 20,
+ "coveragePercent": 100,
+ "scope": "Worker/composition plus existing runtime regression cases. The final complete campaign covers the final required native-worker invariant."
+ },
+ "actualBoundaryTests": [
+ "Repeated start is idempotent, close without start prevents late work, and cancellation before actual scheduled thread entry prevents its first tick.",
+ "An in-flight step blocks disposal; stop is visible to it and no subsequent tick starts. Thread-start failure leaves no running-worker claim.",
+ "Real transient/fatal injected failures retain only safe diagnostics; a later poll recovers ordinary failure and scope denial terminates polling.",
+ "Both actual native/standalone ASGI lifetimes materialize real scoped SQLite occurrences, stop, reopen composition and do not duplicate terminal work; paused rules stay paused.",
+ "Both actual owned-directory runtimes retain their real fcntl installation lease while close waits for a worker, and release it only after join.",
+ "Existing lifespan state and failure disposal are preserved; unconfigured/no-active-rule/current-installation negatives run against real SQLite."
+ ],
+ "checks": {
+ "strictTypes": {
+ "passed": true,
+ "files": 130
+ },
+ "ruff": "check and format pass;326 files",
+ "architecture": {
+ "contractsKept": 3,
+ "contractsBroken": 0,
+ "filesAnalyzed": 174,
+ "dependencies": 1112
+ },
+ "originalPinnedSdd": {
+ "upstream": "3e7ceea08e6131360e026937d1c0cc1b120472e0",
+ "base": "0806a38f8a2de1f102a017bff25ad6856fb17bcd",
+ "selected": "specs/recurrence-storage-continuation.md",
+ "originalPolicyModulesVerified": 7,
+ "format": "PASS",
+ "consistency": "PASS",
+ "composition": "explicit contributor integration; original publication retained in Git",
+ "unchangedCatalog": "PASS",
+ "immutableOwnerDocumentsUsed": 18,
+ "retainedCompanionCases": 52,
+ "newCases": 4,
+ "rawCharacters": 28304,
+ "renderedCharacters": 28304,
+ "composedCharacters": 28304,
+ "applicableDraftMaximumCharacters": 70000,
+ "sha256": "753accab3ba73c603a57692abe21477ae92710b4ed5d4e9f70a639383ad5b6de",
+ "scope": "Review input only; no gate admission, branch publication, source implementation or runtime acceptance.",
+ "retainedOwningFilesUnchanged": true,
+ "workingDocumentValidated": true
+ },
+ "packageAndArchitecture": {
+ "executions": 52,
+ "durationSeconds": 6.99,
+ "freshWheel": "passed"
+ }
+ },
+ "imageControls": {
+ "hostExecution": "Exact current container smoke script passes on host: real SQLite fixed creation, fair run, then actual ASGI startup/work/shutdown in both production compositions without HA network access.",
+ "hostOutput": "Current-source recurrence, both owned runtimes and pinned-time codecs passed.",
+ "currentCpuImages": "Fresh hosted aarch64 and amd64 execution required after this source is pushed. Host execution is not CPU-image evidence.",
+ "priorPublishedCheckpoint": "ae73710972c386b18406921316d6311ced0d76e6 passed all applicable product CI and both real CPU images; native code review completed7/7 partitions,74 fragments and64 assigned files with0 open/candidate/published findings. Overall review remained partial/neutral because human-conversation was3 fetched/2 retained/1 omitted; the technical inputs were manually inspected."
+ },
+ "copilot": "Progress validation.evidence-too-large and partial human-conversation remain reported in upstream851/843, updated for ae73710. No Copilot source/configuration/credential/bound changed. Fresh review is required for this increment.",
+ "ownerInstallation": "No owner HA request, app update, release or migration performed. Edge.5/schema7 and the retained stopped Edge.3 remain unchanged.",
+ "rawEvidence": {
+ "privateCampaign": "/private/tmp/domus-recurrence-lifecycle-ae73710",
+ "consumer": "Current PR22 review: original failing tests, full/initial/focused coverage, exact source manifest, SDD, current package, image-host smoke and upstream reports. Cleanup retains necessary compact proof and current rollback."
+ },
+ "pending": "Completion-relative lifecycle, durable attention and truthful calendar predicates, current-authorized public HTTP/operation recovery, reviewed attachment/schedule edits/exceptions/classification, household timing-policy defaults, full UI/HA and complete retained52+37 cases and first all-domain Stable/Edge release. No whole-product acceptance is claimed."
+}
diff --git a/docs/evidence/recurrence-storage.json b/docs/evidence/recurrence-storage.json
index bfe9169..2b69f8d 100644
--- a/docs/evidence/recurrence-storage.json
+++ b/docs/evidence/recurrence-storage.json
@@ -85,7 +85,7 @@
"policyModulesByteVerified": 7,
"immutableBaseOwners": 18,
"retainedOwnersUnchanged": true,
- "currentCompanionSHA256": "a129daa6c324ada4072850e952826992d19054c233110d0b342240de9aa624bf",
+ "currentCompanionSHA256": "753accab3ba73c603a57692abe21477ae92710b4ed5d4e9f70a639383ad5b6de",
"scope": "Explicit contributor integration; original first publication remains in Git. Format, reciprocal inventory/path consistency and current catalog pass."
},
"packageAndArchitectureTests": {
diff --git a/specs/recurrence-storage-continuation.md b/specs/recurrence-storage-continuation.md
index 8233296..7635004 100644
--- a/specs/recurrence-storage-continuation.md
+++ b/specs/recurrence-storage-continuation.md
@@ -151,6 +151,10 @@ Use real independent SQLite connections and exact before/after version/row check
| backend/src/domus/domain/recurrence_frontier.py | create |
| backend/src/domus/domain/recurrence_rules.py | update |
| backend/src/domus/infrastructure/recurrence_time.py | update |
+| backend/src/domus/infrastructure/recurrence_worker.py | create |
+| backend/src/domus/composition/recurrence.py | create |
+| backend/src/domus/composition/server.py | update |
+| backend/src/domus/composition/ingress.py | update |
| backend/src/domus/infrastructure/sqlite.py | update |
| backend/src/domus/infrastructure/sqlite_snapshots.py | update |
| backend/src/domus/infrastructure/sqlite_recurrence.py | create |
@@ -164,6 +168,8 @@ Use real independent SQLite connections and exact before/after version/row check
| tests/backend/test_fair_recurrence.py | create |
| tests/backend/test_fair_recurrence_boundaries.py | create |
| tests/backend/test_recurrence_frontier.py | create |
+| tests/backend/test_recurrence_worker.py | create |
+| tests/backend/test_recurrence_runtime.py | create |
| tests/backend/test_recurrence_slot_membership.py | create |
| tests/backend/test_sqlite_recurrence_records.py | create |
| tests/backend/test_sqlite_recurrence_integrity.py | create |
@@ -185,6 +191,7 @@ Use real independent SQLite connections and exact before/after version/row check
| docs/evidence/recurrence-creation.json | create |
| docs/evidence/recurrence-materialization.json | create |
| docs/evidence/recurrence-fairness.json | create |
+| docs/evidence/recurrence-lifecycle.json | create |
| docs/tasks/migration.md | update |
| docs/evidence/recurrence-execution.json | update |
@@ -226,6 +233,10 @@ Rule snapshots used to compute horizons are rechecked by the step. Cancellation
Selection metadata is added to schema 8 before its first release. Supported historical schemas 1..7 and original fixture bytes remain unchanged. The genuine intermediate c5819b8 schema-8 SQL input was generated under that exact source before this DDL change; normal operations and initialization reject its unknown development layout without modifying it. It is a refusal/rollback control, not a promise to migrate an unreleased layout or permission to edit user_version. The owner remains on the unchanged released schema-7 Edge.5 pair. Complete production scheduling, relative sources, durable calendar/review attention and the remaining retained requirements remain pending.
+The next production increment composes that fixed runner into both persistent native and standalone applications. Construction remains inert. The actual ASGI lifespan starts one owned thread; it attempts a bounded run immediately and waits five seconds after each attempt, including unavailable or unconfirmed attempts. This engineering polling interval does not change the rule's business time policy or human permissions. Composition selects only the current household in its trusted installation; unconfigured installations remain unconfigured, and homes with no active rule create no frontier or business effects. No human proof or submitted scope is used for technical execution.
+
+The worker carries cancellation into every run and joins before runtime identity/storage disposal and installation-lease release. The lifespan preserves the existing application's state and exits its prior context after stopping the worker, including on startup/application failure. Ordinary failures expose only bounded categories and permit a later attempt; a denied technical scope terminates that worker. Last-run observations remain process-local diagnostics, not durable calendar freshness or delivery proof. Identity-only preview has no worker. Actual current-source image controls exercise both ASGI lifetimes, scoped SQLite work and shutdown. Relative lifecycle, current-authorized calendar/command surfaces, durable attention, configurable household timing defaults and complete product/UI/HA acceptance remain required.
+
## 19. Definition of Done
- [ ] The four additional cases and every retained requirement/case have truthful current-source evidence; no checkpoint is described as full feature acceptance.
diff --git a/tests/backend/test_recurrence_runtime.py b/tests/backend/test_recurrence_runtime.py
new file mode 100644
index 0000000..edff284
--- /dev/null
+++ b/tests/backend/test_recurrence_runtime.py
@@ -0,0 +1,280 @@
+"""Both production lifetimes bind local technical authority and perform real SQLite work."""
+
+from contextlib import asynccontextmanager
+from dataclasses import replace
+from threading import Event, Thread
+
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+from test_fixed_materialization import configured
+from test_recurrence_creation import request
+from test_task_runtime import api as api
+
+from domus.application.fixed_recurrence import FixedRecurrencePlanner
+from domus.application.recurrence_commands import RecurrenceCommands
+from domus.composition.recurrence import bind_lifespan, recurrence_worker
+from domus.domain.errors import Forbidden, StorageBusy
+from domus.domain.nominal_recurrence import CalendarNominalExpansion
+from domus.domain.recurrence_records import ScheduleState
+from domus.domain.storage import Installation
+from domus.infrastructure.application_runtime import SecureIdentifiers, SystemClock
+from domus.infrastructure.owned_storage import open_storage
+from domus.infrastructure.recurrence_time import PinnedRecurrenceTime
+from domus.infrastructure.sqlite import SQLiteDatabase
+
+
+@pytest.fixture(autouse=True)
+def fixed_clock(monkeypatch):
+ monkeypatch.setattr(SystemClock, "now", lambda _: 1_780_272_000_000)
+
+
+def observed(worker, monkeypatch):
+ """Signal only after the real production tick has completed, without polling SQLite."""
+ completed = Event()
+ original = worker._tick
+
+ def tick(should_continue):
+ result = original(should_continue)
+ completed.set()
+ return result
+
+ monkeypatch.setattr(worker, "_tick", tick)
+ return completed
+
+
+def test_composed_worker_uses_owned_installation_and_actual_household_without_an_ha_request(
+ tmp_path, monkeypatch
+):
+ _, store, actor, _, rule_id, _ = configured(tmp_path)
+ calls = actor.calls
+ worker = recurrence_worker(store, store.installation)
+ done = observed(worker, monkeypatch)
+ try:
+ worker.start()
+ assert done.wait(5)
+ finally:
+ worker.close()
+ assert worker.last_run.created == 1 and worker.last_failure is None
+ assert actor.calls == calls
+ with store.read() as unit:
+ assert len(unit.recurrence.slots("home", rule_id)) == 1
+ assert unit.tasks.occurrences("home")[0].current_cycle_id is None
+
+
+def test_composition_refuses_a_different_installation_before_admitting_any_rule(tmp_path):
+ _, store, _, _, rule_id, _ = configured(tmp_path)
+ worker = recurrence_worker(store, replace(store.installation, channel="stable"))
+ with pytest.raises(Forbidden):
+ worker._tick(lambda: True)
+ worker.close()
+ with store.read() as unit:
+ assert unit.recurrence.frontier("home") is None
+ assert unit.recurrence.slots("home", rule_id) == ()
+
+
+def test_stop_before_composition_read_has_no_database_effect(tmp_path):
+ _, store, _, _, _, _ = configured(tmp_path)
+ worker = recurrence_worker(store, store.installation)
+ assert worker._tick(lambda: False).status == "stopped"
+ worker.close()
+ with store.read() as unit:
+ assert unit.recurrence.frontier("home") is None
+
+
+def test_unconfigured_installation_stays_unconfigured_and_no_empty_frontier_is_created(tmp_path):
+ store = SQLiteDatabase(
+ tmp_path / "domus.sqlite3", Installation("047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6", "edge")
+ )
+ store.initialize()
+ worker = recurrence_worker(store, store.installation)
+ try:
+ assert worker._tick(lambda: True).status == "unconfigured"
+ finally:
+ worker.close()
+ with store.read() as unit:
+ assert unit.households.current() is None and unit.outbox.pending() == ()
+ assert unit.recurrence.frontier("home") is None
+
+
+def test_configured_home_without_active_rules_does_not_churn_frontier_or_business_records(tmp_path):
+ _, store, _, _, rule_id, _ = configured(tmp_path)
+ with store.write() as unit:
+ rule = unit.recurrence.schedule("home", rule_id)
+ unit.recurrence.save_schedule(replace(rule, state=ScheduleState.PAUSED, revision=2), 1)
+ worker = recurrence_worker(store, store.installation)
+ try:
+ assert worker._tick(lambda: True).status == "complete"
+ assert worker._tick(lambda: True).selected_schedule_ids == ()
+ finally:
+ worker.close()
+ with store.read() as unit:
+ assert unit.recurrence.frontier("home") is None and unit.tasks.occurrences("home") == ()
+
+
+@pytest.mark.parametrize("paused", [False, True])
+def test_both_business_runtimes_start_only_during_lifespan_and_resume_after_restart(
+ api, monkeypatch, paused
+):
+ from domus.composition import ingress, server
+
+ original = api.runtime.recurrence
+ assert original is not None and not original.is_running
+ # This setup uses the same fixture-confirmed HA subject; it does not claim owner-HA access.
+ human = (
+ api.source.people[api.client.headers["X-Remote-User-Id"]]
+ if api.mode == "native"
+ else api.source.human
+ )
+
+ class Actor:
+ def current(self):
+ return human
+
+ creator = RecurrenceCommands(
+ api.store,
+ Actor(),
+ SystemClock(),
+ SecureIdentifiers(),
+ FixedRecurrencePlanner(CalendarNominalExpansion(), PinnedRecurrenceTime()),
+ )
+ value = request()
+ receipt = creator.create_fixed(
+ "runtime-fixed", replace(value, pattern=replace(value.pattern, count=1))
+ )
+ rule_id = receipt.result.decode()["schedule_id"]
+ if paused:
+ with api.store.write() as unit:
+ rule = unit.recurrence.schedule("home", rule_id)
+ unit.recurrence.save_schedule(replace(rule, state=ScheduleState.PAUSED, revision=2), 1)
+ done = observed(original, monkeypatch)
+ try:
+ with TestClient(api.runtime.app, base_url=api.origin):
+ assert done.wait(5) and original.is_running
+ assert not original.is_running
+ with api.store.read() as unit:
+ assert len(unit.recurrence.slots("home", rule_id)) == (0 if paused else 1)
+ finally:
+ api.runtime.close()
+ # Reopening production composition over the same schema reads durable cursor/slot facts.
+ if api.mode == "native":
+ runtime = ingress.create_business_runtime(
+ "private-supervisor-token",
+ "edge",
+ api.store.path,
+ api.store.installation.instance_id,
+ (api.origin,),
+ )
+ else:
+ runtime = server.create_runtime(
+ "http://ha.local",
+ api.origin,
+ api.store.installation.instance_id,
+ "edge",
+ database=api.store.path,
+ )
+ done = observed(runtime.recurrence, monkeypatch)
+ try:
+ with TestClient(runtime.app, base_url=api.origin):
+ assert done.wait(5)
+ finally:
+ runtime.close()
+ with api.store.read() as unit:
+ assert len(unit.recurrence.slots("home", rule_id)) == (0 if paused else 1)
+
+
+def test_lifespan_preserves_prior_state_and_closes_worker_on_failure(monkeypatch):
+ events = []
+ app = FastAPI()
+
+ @asynccontextmanager
+ async def previous(_):
+ events.append("entered")
+ try:
+ yield {"retained": "state"}
+ finally:
+ events.append("exited")
+
+ class Worker:
+ def start(self):
+ events.append("started")
+
+ def close(self):
+ events.append("closed")
+
+ app.router.lifespan_context = previous
+ bind_lifespan(app, Worker())
+ with pytest.raises(RuntimeError, match="fixture-application"):
+ with TestClient(app) as client:
+ assert client.app_state == {"retained": "state"}
+ raise RuntimeError("fixture-application")
+ assert events == ["entered", "started", "closed", "exited"]
+
+
+def test_startup_failure_disposes_worker_and_exits_the_previous_lifespan():
+ events = []
+ app = FastAPI()
+
+ @asynccontextmanager
+ async def previous(_):
+ try:
+ yield
+ finally:
+ events.append("previous-closed")
+
+ class Worker:
+ def start(self):
+ raise RuntimeError("fixture-startup")
+
+ def close(self):
+ events.append("worker-closed")
+
+ app.router.lifespan_context = previous
+ bind_lifespan(app, Worker())
+ with pytest.raises(RuntimeError, match="fixture-startup"):
+ with TestClient(app):
+ pytest.fail("Startup was rejected")
+ assert events == ["worker-closed", "previous-closed"]
+
+
+def test_owned_runtime_keeps_actual_installation_lease_until_its_worker_has_joined(
+ api, tmp_path, monkeypatch
+):
+ from domus.application.fair_recurrence import FixedRun
+ from domus.composition import ingress, server
+
+ directory = tmp_path / "owned-runtime"
+ directory.mkdir(mode=0o700)
+ runtime = (
+ ingress.create_owned_runtime("private-supervisor-token", "edge", directory, (api.origin,))
+ if api.mode == "native"
+ else server.create_owned_runtime("http://ha.local", api.origin, "edge", directory)
+ )
+ entered, release, closed = Event(), Event(), Event()
+
+ def tick(_):
+ entered.set()
+ assert release.wait(5)
+ return FixedRun("unconfigured")
+
+ monkeypatch.setattr(runtime.recurrence, "_tick", tick)
+ runtime.recurrence.start()
+ assert entered.wait(5)
+
+ def close():
+ runtime.close()
+ closed.set()
+
+ closer = Thread(target=close)
+ closer.start()
+ try:
+ assert runtime.recurrence._stop.wait(5) and not closed.is_set()
+ with pytest.raises(StorageBusy, match="installation_in_use"):
+ open_storage(directory, "edge")
+ finally:
+ release.set()
+ closer.join(5)
+ runtime.close()
+ assert closed.is_set() and not runtime.recurrence.is_running
+ reopened = open_storage(directory, "edge")
+ reopened.close()
diff --git a/tests/backend/test_recurrence_worker.py b/tests/backend/test_recurrence_worker.py
new file mode 100644
index 0000000..da03e27
--- /dev/null
+++ b/tests/backend/test_recurrence_worker.py
@@ -0,0 +1,136 @@
+"""Owned recurrence polling starts explicitly, waits between attempts and joins before disposal."""
+
+from threading import Event, Thread
+
+import pytest
+
+from domus.application.fair_recurrence import FixedRun
+from domus.domain.errors import Forbidden, StorageBusy
+from domus.infrastructure.recurrence_worker import RecurrenceWorker
+
+
+@pytest.mark.parametrize("interval", [True, None, "5", 0, -1, 61, float("inf"), float("nan")])
+def test_invalid_poll_interval_never_creates_a_worker(interval):
+ with pytest.raises(ValueError, match="invalid_recurrence_poll_interval"):
+ RecurrenceWorker(lambda _: pytest.fail("No tick"), interval=interval)
+
+
+def test_construction_is_inert_and_close_without_start_prevents_late_work():
+ worker = RecurrenceWorker(lambda _: pytest.fail("No tick"))
+ assert not worker.is_running and worker.last_run is None and worker.last_failure is None
+ worker.close()
+ worker.close()
+ with pytest.raises(RuntimeError, match="recurrence_worker_closed"):
+ worker.start()
+
+
+def test_close_waits_for_admitted_step_and_prevents_another_tick():
+ entered, release, stopping, closed = Event(), Event(), Event(), Event()
+ calls = []
+
+ def tick(should_continue):
+ calls.append(True)
+ entered.set()
+ while not release.wait(0.005):
+ if not should_continue():
+ stopping.set()
+ return FixedRun("stopped")
+
+ worker = RecurrenceWorker(tick)
+ worker.start()
+ worker.start()
+ assert entered.wait(5) and worker.is_running
+
+ def close():
+ worker.close()
+ closed.set()
+
+ closer = Thread(target=close)
+ closer.start()
+ try:
+ assert stopping.wait(5) and not closed.is_set()
+ finally:
+ release.set()
+ closer.join(5)
+ worker.close()
+ assert closed.is_set() and not worker.is_running and len(calls) == 1
+ assert worker.last_run == FixedRun("stopped") and worker.last_failure is None
+
+
+@pytest.mark.parametrize("error", [StorageBusy("private-fixture-detail"), RuntimeError("secret")])
+def test_unconfirmed_tick_is_reported_safely_and_a_later_poll_can_recover(error, caplog):
+ recovered = Event()
+ calls = []
+
+ def tick(_):
+ calls.append(True)
+ if len(calls) == 1:
+ raise error
+ recovered.set()
+ return FixedRun("unconfigured")
+
+ worker = RecurrenceWorker(tick, interval=0.01)
+ try:
+ worker.start()
+ assert recovered.wait(5)
+ finally:
+ worker.close()
+ assert len(calls) >= 2 and worker.last_run == FixedRun("unconfigured")
+ assert worker.last_failure is None
+ assert "recurrence_run_unconfirmed" in caplog.text
+ assert "private-fixture-detail" not in caplog.text and "secret" not in caplog.text
+
+
+def test_scope_denial_stops_polling_without_disclosing_its_diagnostic(caplog):
+ entered = Event()
+ calls = []
+
+ def tick(_):
+ calls.append(True)
+ entered.set()
+ raise Forbidden("private-wrong-installation")
+
+ worker = RecurrenceWorker(tick, interval=0.01)
+ worker.start()
+ assert entered.wait(5)
+ worker.close()
+ assert len(calls) == 1 and worker.last_run is None and worker.last_failure == "scope_denied"
+ assert "private-wrong-installation" not in caplog.text
+
+
+def test_thread_start_failure_leaves_no_claim_of_a_running_worker(monkeypatch):
+ worker = RecurrenceWorker(lambda _: FixedRun("complete"))
+
+ def fail(_):
+ raise RuntimeError("fixture-thread-start")
+
+ with monkeypatch.context() as patch:
+ patch.setattr(Thread, "start", fail)
+ with pytest.raises(RuntimeError, match="fixture-thread-start"):
+ worker.start()
+ assert not worker.is_running and worker.last_run is None
+ worker.close()
+
+
+def test_stop_before_scheduled_thread_entry_prevents_its_first_tick(monkeypatch):
+ worker = RecurrenceWorker(lambda _: pytest.fail("A cancelled entry cannot start a tick"))
+ entered, release = Event(), Event()
+ original = worker._run
+
+ def delayed():
+ entered.set()
+ assert release.wait(5)
+ original()
+
+ monkeypatch.setattr(worker, "_run", delayed)
+ worker.start()
+ assert entered.wait(5)
+ closer = Thread(target=worker.close)
+ closer.start()
+ try:
+ assert worker._stop.wait(5)
+ finally:
+ release.set()
+ closer.join(5)
+ worker.close()
+ assert not worker.is_running and worker.last_run is None
diff --git a/tests/containers/test_recurrence_image.py b/tests/containers/test_recurrence_image.py
index df042ae..13a21c6 100644
--- a/tests/containers/test_recurrence_image.py
+++ b/tests/containers/test_recurrence_image.py
@@ -17,6 +17,7 @@ def test_current_image_executes_recurrence_creation_planning_and_closed_record_c
image,
"-c",
"""
+import asyncio
from dataclasses import replace
from datetime import datetime
from pathlib import Path
@@ -27,6 +28,7 @@ def test_current_image_executes_recurrence_creation_planning_and_closed_record_c
from domus.application.household_commands import HouseholdCommands, SetupHousehold
from domus.application.recurrence_commands import CreateFixedTask, RecurrenceCommands
from domus.application.task_requests import CreateTask
+from domus.composition import ingress, server
from domus.domain.errors import InvalidInput
from domus.domain.identity import HumanIdentity
from domus.domain.nominal_recurrence import CalendarNominalExpansion
@@ -113,11 +115,41 @@ def get(self, user_id):
assert len(unit.tasks.occurrences('home')) == 4
assert unit.recurrence.frontier('home') is not None
assert unit.recurrence.progress('home', other['schedule_id']).cursor.closed
-print('Current-source bounded recurrence, materialization and pinned-time codecs passed.')
+
+ async def serve_once(runtime):
+ async with runtime.app.router.lifespan_context(runtime.app):
+ deadline = asyncio.get_running_loop().time() + 10
+ while runtime.recurrence.last_run is None:
+ assert asyncio.get_running_loop().time() < deadline, 'Owned worker did not run'
+ await asyncio.sleep(0.01)
+ assert runtime.recurrence.is_running
+ assert runtime.recurrence.last_run.created == 2
+ assert not runtime.recurrence.is_running
+
+ for mode in ('standalone', 'native'):
+ scheduled = creation.create_fixed(mode + '-create', request).result.decode()
+ runtime = (
+ server.create_runtime('http://ha.local', 'https://domus.example',
+ installation.instance_id, installation.channel, database=store.path)
+ if mode == 'standalone' else
+ ingress.create_business_runtime('fictional-supervisor-token', installation.channel,
+ store.path, installation.instance_id, ('http://ha.local',))
+ )
+ try:
+ assert not runtime.recurrence.is_running
+ asyncio.run(serve_once(runtime))
+ finally:
+ runtime.close()
+ with store.read() as unit:
+ assert len(unit.recurrence.slots('home', scheduled['schedule_id'])) == 2
+ assert unit.recurrence.progress('home', scheduled['schedule_id']).cursor.closed
+ with store.read() as unit:
+ assert len(unit.tasks.occurrences('home')) == 8
+print('Current-source recurrence, both owned runtimes and pinned-time codecs passed.')
""",
)
assert result.returncode == 0, result.stderr
assert (
- "Current-source bounded recurrence, materialization and pinned-time codecs passed."
+ "Current-source recurrence, both owned runtimes and pinned-time codecs passed."
in result.stdout
)
From abf6403b569c39101df2001eb90ad013c40972c1 Mon Sep 17 00:00:00 2001
From: Efra Espada
Date: Mon, 2 Nov 2026 09:58:55 +0100
Subject: [PATCH 09/16] fix: Reject altered recurrence text and preserve
bounded inspection contention
---
backend/src/domus/infrastructure/sqlite.py | 5 +-
.../sqlite_recurrence_records.py | 5 +-
docs/architecture-recurrence.md | 23 ++++
.../recurrence-review-corrections.json | 119 ++++++++++++++++++
docs/evidence/recurrence-storage.json | 2 +-
frontend/tests/e2e/entry.spec.ts | 3 +
specs/recurrence-storage-continuation.md | 7 ++
tests/backend/test_fixed_materialization.py | 5 +-
.../test_recurrence_canonical_storage.py | 72 +++++++++++
tests/backend/test_recurrence_frontier.py | 8 +-
.../test_sqlite_recurrence_integrity.py | 3 +-
.../backend/test_sqlite_recurrence_records.py | 10 +-
tests/backend/test_sqlite_transactions.py | 15 +++
tests/backend/test_storage_contention_http.py | 18 +++
tests/containers/test_recurrence_image.py | 25 +++-
15 files changed, 307 insertions(+), 13 deletions(-)
create mode 100644 docs/evidence/recurrence-review-corrections.json
create mode 100644 tests/backend/test_recurrence_canonical_storage.py
create mode 100644 tests/backend/test_storage_contention_http.py
diff --git a/backend/src/domus/infrastructure/sqlite.py b/backend/src/domus/infrastructure/sqlite.py
index 38393e3..8324daa 100644
--- a/backend/src/domus/infrastructure/sqlite.py
+++ b/backend/src/domus/infrastructure/sqlite.py
@@ -405,7 +405,10 @@ def _inspect(
self.installation.channel,
):
raise DatabaseRejected("installation_mismatch")
- except sqlite3.Error:
+ except sqlite3.Error as error:
+ mapped = _storage_error(error)
+ if isinstance(mapped, StorageBusy):
+ raise mapped from None
raise DatabaseRejected("invalid_database") from None
return int(version)
diff --git a/backend/src/domus/infrastructure/sqlite_recurrence_records.py b/backend/src/domus/infrastructure/sqlite_recurrence_records.py
index f2efe75..8c7d499 100644
--- a/backend/src/domus/infrastructure/sqlite_recurrence_records.py
+++ b/backend/src/domus/infrastructure/sqlite_recurrence_records.py
@@ -318,7 +318,10 @@ def _slot(value: JsonValue) -> ScheduledSlot:
def _read[T](document: str, build: Callable[[JsonValue], T]) -> T:
try:
- return build(JsonDocument(document).decode())
+ canonical = JsonDocument(document)
+ if canonical.encoded != document:
+ raise InvalidInput("noncanonical_recurrence_record")
+ return build(canonical.decode())
except DomusError, KeyError, TypeError, ValueError, OverflowError:
raise StorageFailure("invalid_recurrence_record") from None
diff --git a/docs/architecture-recurrence.md b/docs/architecture-recurrence.md
index 66c7bb5..e1bd376 100644
--- a/docs/architecture-recurrence.md
+++ b/docs/architecture-recurrence.md
@@ -228,3 +228,26 @@ host smoke execution and fresh hosted CPU image results from owner-HA deployment
Relative execution, durable attention/calendar predicates, protected public interfaces, reviewed
schedule changes and the complete retained recurrence contract remain unfinished.
+
+## Strict stored text and initial inspection contention
+
+Stored recurrence text now has to equal its exact canonical JSON representation before typed
+construction. Semantically equivalent whitespace, key reordering and escaped keys are rejected
+for rules, progress, slots and selection frontiers. Independent reads and snapshot validation
+share this boundary and retain the altered database bytes rather than repairing them. Existing
+semantic corruption fixtures use canonical serialization so they continue to exercise their
+original source/effect invariants independently of syntax rejection.
+
+A real exclusive SQLite writer can also block initial database inspection. That condition now
+remains bounded `storage_busy` and a 503 response; other inspection failures still refuse invalid
+databases. Native and standalone HTTP tests use actual independent exclusive writers and verify
+explicit read recovery without another business effect.
+
+The observed hosted browser trace had a rejected busy POST, one successful setup and a subsequent
+authorization read returning `500 internal_error`. It did not retain the internal exception stack.
+The exclusive-lock tests independently reproduce the inspection misclassification; the historical
+stack is not inferred as proven. The concurrency browser assertion explicitly refreshes status
+after both original POST responses, then checks the retained rejection and deliberately replays
+the exact original request under current authority. Refresh never resubmits a POST. See the
+[review corrections evidence](evidence/recurrence-review-corrections.json) for original failures,
+complete-source validation, current images and remaining acceptance limits.
diff --git a/docs/evidence/recurrence-review-corrections.json b/docs/evidence/recurrence-review-corrections.json
new file mode 100644
index 0000000..6730f87
--- /dev/null
+++ b/docs/evidence/recurrence-review-corrections.json
@@ -0,0 +1,119 @@
+{
+ "status": "partial; valid Bugbot stored-text finding corrected and independently reproduced SQLite inspection contention fixed; complete recurrence remains in progress",
+ "recordedAtUTC": "2026-11-02T08:58:24.129851+00:00",
+ "issue": "https://github.com/vypdev/domus/issues/20",
+ "pullRequest": "https://github.com/vypdev/domus/pull/22",
+ "sourceBase": "872bdba55dc7fa9ec942a9f0ff27a635b62d0169",
+ "backendSourceInventorySHA256": "e21e7eec6efae5109bb6ffe3e8edaf77d0c202824d37445d5641892bd8bac851",
+ "sourceManifestMethod": "SHA256 of compact sorted JSON mapping every authored Python module path, including package initializers, to its byte SHA256 value.",
+ "bugbotFinding": {
+ "url": "https://github.com/vypdev/domus/pull/22#discussion_r4440363749",
+ "id": "sqlite_recurrence_records:321:reject-noncanonical-record-json",
+ "assessment": "Valid; fixed locally, fresh published-source verification required. Not dismissed or reported as a Copilot finding defect.",
+ "fix": "Compare stored text with its exact canonical encoding before typed rule/progress/slot/frontier construction; no byte repair."
+ },
+ "testsFirst": {
+ "canonicalStorage": "36 original failing executions: four formatting transformations across date/time codecs plus real rule/progress/slot/frontier reads and checkpoint refusal.",
+ "inspectionContention": "Four original failing executions: read/write repository entry under a real exclusive writer, plus actual native/standalone authorization HTTP responses.",
+ "semanticFixtureCorrection": "Existing corruption tests now serialize semantic corruption canonically, preserving their original deeper assertions. One first complete campaign had3706 passes and one expected-message mismatch in a noncanonical future-snapshot fixture; that fixture now preserves canonical encoding and the same original effect-validation assertion."
+ },
+ "observedFrontendFailure": {
+ "run": 41414946149,
+ "job": 125176414528,
+ "artifact": 13127212450,
+ "artifactSHA256": "e52360e4f26b7bc60cacf78801c806218d4621067e562ed85776c75907f431d3",
+ "observations": "Concurrent native same-account setup: original POST503 storage_busy, one POST200; immediate authorization GET500 internal_error; accessible UI safely showed Household status unavailable.",
+ "limit": "No internal exception stack was retained. Independent exclusive-writer tests prove the concrete inspection misclassification, not the unlogged historical stack.",
+ "fix": "Keep temporary inspection contention as bounded storage_busy/503. The browser assertion explicitly refreshes read-only status after both original responses, then checks retained rejection and deliberately replays the original request under current authority. No automatic POST replay and no longer timeout are introduced."
+ },
+ "completeBackend": {
+ "executions": 3707,
+ "durationSeconds": 110.6,
+ "authoredModules": 130,
+ "coverageTotals": {
+ "covered_lines": 9248,
+ "num_statements": 9248,
+ "percent_covered": 100.0,
+ "percent_covered_display": "100.00",
+ "missing_lines": 0,
+ "excluded_lines": 272,
+ "percent_statements_covered": 100.0,
+ "percent_statements_covered_display": "100.00",
+ "num_branches": 2634,
+ "num_partial_branches": 0,
+ "covered_branches": 2634,
+ "missing_branches": 0,
+ "percent_branches_covered": 100.0,
+ "percent_branches_covered_display": "100.00"
+ },
+ "independentGlobalAndPerModuleAudit": "passed"
+ },
+ "focusedRegression": {
+ "executions": 151,
+ "durationSeconds": 2.98,
+ "scope": "Canonical codec/integrity/frontier and real SQLite/HTTP contention campaign before the final semantic-fixture correction; final complete campaign covers that correction."
+ },
+ "browser": {
+ "executions": 49,
+ "durationSeconds": 30.0,
+ "result": "passed",
+ "scope": "Real local browser/native/standalone compositions and SQLite, fictional HA transports; owner HA not used.",
+ "postInvariant": "Exactly two original competing setup keys; explicit refresh adds no POST; one household/member/receipt/audit/outbox remains."
+ },
+ "checks": {
+ "strictTypes": "passed;130 modules",
+ "frontendRuntimeAndCatalogTypes": "passed",
+ "ruff": "check/format passed;328 files",
+ "architecture": "3 contracts kept,0 broken;174 files/1112 dependencies",
+ "originalPinnedSdd": {
+ "upstream": "3e7ceea08e6131360e026937d1c0cc1b120472e0",
+ "base": "0806a38f8a2de1f102a017bff25ad6856fb17bcd",
+ "selected": "specs/recurrence-storage-continuation.md",
+ "originalPolicyModulesVerified": 7,
+ "format": "PASS",
+ "consistency": "PASS",
+ "composition": "explicit contributor integration; original publication retained in Git",
+ "unchangedCatalog": "PASS",
+ "immutableOwnerDocumentsUsed": 18,
+ "retainedCompanionCases": 52,
+ "newCases": 4,
+ "rawCharacters": 30029,
+ "renderedCharacters": 30029,
+ "composedCharacters": 30029,
+ "applicableDraftMaximumCharacters": 70000,
+ "sha256": "9395e06f14fd8939950816c707ff3d764bf5489fb7c0cfff8591cf2beae87303",
+ "scope": "Review input only; no gate admission, branch publication, source implementation or runtime acceptance.",
+ "retainedOwningFilesUnchanged": true,
+ "workingDocumentValidated": true
+ },
+ "packageAndArchitecture": {
+ "executions": 52,
+ "durationSeconds": 6.89,
+ "freshCurrentWheel": "passed"
+ }
+ },
+ "images": {
+ "exactCurrentScriptOnHost": "Current-source recurrence, both owned runtimes and pinned-time codecs passed.",
+ "scope": "Actual fixed/fair SQLite work, both production ASGI lifetimes, all four canonical storage negatives and real exclusive initial-inspection contention. Host execution is not CPU-image evidence.",
+ "freshHosted": "Required after publication of these corrections.",
+ "prior872": "Both real current-source CPU images passed: aarch64 job125176414375 and amd64 job125176414139, run41414946149. Hosted backend passed; frontend failed as separately retained above. This prior result does not validate new correction code."
+ },
+ "reviewAt872": {
+ "partitionsCompleted": 8,
+ "partitionsRequired": 8,
+ "diffFragments": 82,
+ "assignedFiles": 71,
+ "candidateFindings": 1,
+ "publishedFindings": 1,
+ "openFindings": 1,
+ "fixedFindings": 1,
+ "overall": "partial/neutral",
+ "humanConversation": "3 fetched/2 retained/1 omitted/0 truncated/limit reached; maintainer technical inputs manually inspected; no whole-prompt absence inferred."
+ },
+ "copilot": "Existing upstream851/843 reports updated with exact872 telemetry and progress error reference95884a3e-20f2-476b-972f-4c5fa710e671. No Copilot source/configuration/credential/bound was touched.",
+ "rawEvidence": {
+ "privateCampaign": "/private/tmp/domus-recurrence-review-872bdba",
+ "consumer": "Current PR review: original36+4 failing tests, actual hosted failure ZIP and native telemetry, complete source/coverage/SDK proofs,49 browser results, package and image-host controls. Compact cleanup receipt retains necessary evidence."
+ },
+ "pending": "Fresh CI/native review, then complete relative lifecycle, durable attention/calendar, authorized public commands/queries/operation recovery, reviewed schedule changes and the full retained52+37 recurrence cases and all-domain Stable/Edge product release. Owner Edge.5/schema7 and retained Edge.3 are untouched."
+}
diff --git a/docs/evidence/recurrence-storage.json b/docs/evidence/recurrence-storage.json
index 2b69f8d..cc87b31 100644
--- a/docs/evidence/recurrence-storage.json
+++ b/docs/evidence/recurrence-storage.json
@@ -85,7 +85,7 @@
"policyModulesByteVerified": 7,
"immutableBaseOwners": 18,
"retainedOwnersUnchanged": true,
- "currentCompanionSHA256": "753accab3ba73c603a57692abe21477ae92710b4ed5d4e9f70a639383ad5b6de",
+ "currentCompanionSHA256": "9395e06f14fd8939950816c707ff3d764bf5489fb7c0cfff8591cf2beae87303",
"scope": "Explicit contributor integration; original first publication remains in Git. Format, reciprocal inventory/path consistency and current catalog pass."
},
"packageAndArchitectureTests": {
diff --git a/frontend/tests/e2e/entry.spec.ts b/frontend/tests/e2e/entry.spec.ts
index 827ae1c..1a148c5 100644
--- a/frontend/tests/e2e/entry.spec.ts
+++ b/frontend/tests/e2e/entry.spec.ts
@@ -334,6 +334,9 @@ for (const mode of modes) test.describe(mode, () => {
expect(reply.headers()['retry-after']).toBe('1');
// The bounded writer may time out under CI load. After the winner completes, the exact
// original request still needs current authority and cannot create a second household.
+ // Its immediate status read can also contend with that commit. An explicit read refresh
+ // after both responses restores verified facts and never resubmits the rejected command.
+ await [page, peerPage][index].getByRole('button', {name: 'Refresh status', exact: true}).click();
await expect([page, peerPage][index].getByRole('heading', {name: 'Request not accepted', exact: true})).toBeVisible();
const retried = await replaySetup([page, peerPage][index], reply);
expect(retried.status()).toBe(actors === 'same_account' ? 409 : 403);
diff --git a/specs/recurrence-storage-continuation.md b/specs/recurrence-storage-continuation.md
index 7635004..460b9b9 100644
--- a/specs/recurrence-storage-continuation.md
+++ b/specs/recurrence-storage-continuation.md
@@ -170,6 +170,8 @@ Use real independent SQLite connections and exact before/after version/row check
| tests/backend/test_recurrence_frontier.py | create |
| tests/backend/test_recurrence_worker.py | create |
| tests/backend/test_recurrence_runtime.py | create |
+| tests/backend/test_recurrence_canonical_storage.py | create |
+| tests/backend/test_storage_contention_http.py | create |
| tests/backend/test_recurrence_slot_membership.py | create |
| tests/backend/test_sqlite_recurrence_records.py | create |
| tests/backend/test_sqlite_recurrence_integrity.py | create |
@@ -192,6 +194,7 @@ Use real independent SQLite connections and exact before/after version/row check
| docs/evidence/recurrence-materialization.json | create |
| docs/evidence/recurrence-fairness.json | create |
| docs/evidence/recurrence-lifecycle.json | create |
+| docs/evidence/recurrence-review-corrections.json | create |
| docs/tasks/migration.md | update |
| docs/evidence/recurrence-execution.json | update |
@@ -235,6 +238,10 @@ Selection metadata is added to schema 8 before its first release. Supported hist
The next production increment composes that fixed runner into both persistent native and standalone applications. Construction remains inert. The actual ASGI lifespan starts one owned thread; it attempts a bounded run immediately and waits five seconds after each attempt, including unavailable or unconfirmed attempts. This engineering polling interval does not change the rule's business time policy or human permissions. Composition selects only the current household in its trusted installation; unconfigured installations remain unconfigured, and homes with no active rule create no frontier or business effects. No human proof or submitted scope is used for technical execution.
+Review identified a strict-storage gap: parsing JSON could normalize altered source bytes before the closed typed codec checked them. Compare the original stored text with the exact canonical representation before constructing any recurrence record. Whitespace, key order and equivalent escaped keys must fail safely in direct rule/progress/slot/frontier reads and full checkpoint validation, without repairing source bytes. Keep existing semantic-negative tests canonically serialized so they continue to exercise typed integrity independently of syntax rejection. This refines recurrence-storage-R2 and retained record integrity, without inventing another product case count.
+
+Concurrent browser evidence also exposes unavailable follow-up reads during a setup winner's commit. An exclusive SQLite writer must yield bounded storage_busy during initial database inspection, not a corruption classification or internal_error. Preserve corrupt-file refusal for other inspection failures. An explicit user status refresh after the two concurrent POST responses can restore verified current facts before inspecting the retained rejection and deliberately replaying the exact original request; it never retries a POST automatically. Verify both read/write repository entry points, actual native/standalone authorization HTTP responses and the real browser flows. The original CI trace has no internal exception stack; the exclusive-lock reproduction establishes the concrete inspection defect rather than proving an unlogged historical stack.
+
The worker carries cancellation into every run and joins before runtime identity/storage disposal and installation-lease release. The lifespan preserves the existing application's state and exits its prior context after stopping the worker, including on startup/application failure. Ordinary failures expose only bounded categories and permit a later attempt; a denied technical scope terminates that worker. Last-run observations remain process-local diagnostics, not durable calendar freshness or delivery proof. Identity-only preview has no worker. Actual current-source image controls exercise both ASGI lifetimes, scoped SQLite work and shutdown. Relative lifecycle, current-authorized calendar/command surfaces, durable attention, configurable household timing defaults and complete product/UI/HA acceptance remain required.
## 19. Definition of Done
diff --git a/tests/backend/test_fixed_materialization.py b/tests/backend/test_fixed_materialization.py
index f030f08..5a385b8 100644
--- a/tests/backend/test_fixed_materialization.py
+++ b/tests/backend/test_fixed_materialization.py
@@ -13,6 +13,7 @@
from domus.domain.recurrence_records import ScheduleState
from domus.domain.recurrence_rules import NominalDate
from domus.domain.recurrence_time import NominalTime
+from domus.domain.storage import JsonDocument
from domus.infrastructure.sqlite import SQLiteAudit, SQLiteDatabase
from domus.infrastructure.sqlite_recurrence import SQLiteRecurrences
from domus.infrastructure.sqlite_tasks import SQLiteTasks
@@ -367,7 +368,9 @@ def test_corrupt_current_original_effect_is_not_recreated_or_consumed_as_valid_p
row = connection.execute("SELECT document FROM recurrence_slots").fetchone()
data = json.loads(row[0])
data["schedule"]["revision"] = 2
- connection.execute("UPDATE recurrence_slots SET document=?", (json.dumps(data),))
+ connection.execute(
+ "UPDATE recurrence_slots SET document=?", (JsonDocument.from_value(data).encoded,)
+ )
initial = counts(store)
with pytest.raises(StorageFailure, match="invalid_recurrence_effect"):
service.step(rule_id, before)
diff --git a/tests/backend/test_recurrence_canonical_storage.py b/tests/backend/test_recurrence_canonical_storage.py
new file mode 100644
index 0000000..13bd438
--- /dev/null
+++ b/tests/backend/test_recurrence_canonical_storage.py
@@ -0,0 +1,72 @@
+"""Equivalent but noncanonical stored JSON fails both direct reads and checkpoint inspection."""
+
+import json
+
+import pytest
+from test_fixed_materialization import configured
+from test_household_commands import Clock
+from test_recurrence_records import values
+
+from domus.domain.errors import DatabaseRejected, StorageFailure
+from domus.domain.recurrence_frontier import RecurrenceFrontier
+from domus.infrastructure.sqlite_recurrence_records import (
+ frontier_document,
+ progress_document,
+ read_frontier,
+ read_progress,
+ read_schedule,
+ read_slot,
+ schedule_document,
+ slot_document,
+)
+from domus.infrastructure.sqlite_snapshots import SQLiteSnapshots
+
+
+@pytest.mark.parametrize("timed", [False, True])
+@pytest.mark.parametrize("kind", ["rule", "progress", "slot", "frontier"])
+@pytest.mark.parametrize("change", ["outer-space", "pretty", "reordered", "escaped-key"])
+def test_semantically_equal_noncanonical_documents_are_refused_before_record_construction(
+ timed, kind, change
+):
+ rule, progress, slot = values(timed)
+ value, encode, decode = {
+ "rule": (rule, schedule_document, read_schedule),
+ "progress": (progress, progress_document, read_progress),
+ "slot": (slot, slot_document, read_slot),
+ "frontier": (RecurrenceFrontier("home", "last", 100), frontier_document, read_frontier),
+ }[kind]
+ original = encode(value).encoded
+ row = json.loads(original)
+ altered = {
+ "outer-space": " " + original + "\n",
+ "pretty": json.dumps(row, indent=2),
+ "reordered": json.dumps(dict(reversed(tuple(row.items()))), separators=(",", ":")),
+ "escaped-key": original.replace('"household_id"', r'"\u0068ousehold_id"'),
+ }[change]
+ assert altered != original and json.loads(altered) == row
+ assert decode(original) == value
+ with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
+ decode(altered)
+
+
+@pytest.mark.parametrize("kind", ["rules", "progress", "slots", "frontiers"])
+def test_actual_noncanonical_storage_is_not_repaired_by_read_or_checkpoint(tmp_path, kind):
+ service, store, _, _, rule_id, before = configured(tmp_path)
+ created = service.step(rule_id, before)
+ with store.write() as unit:
+ unit.recurrence.save_frontier(RecurrenceFrontier("home", rule_id, 100), 0)
+ with store._connection() as connection:
+ connection.execute(f"UPDATE recurrence_{kind} SET document=' ' || document")
+ source = store.path.read_bytes()
+ with store.read() as unit:
+ read = {
+ "rules": lambda: unit.recurrence.schedule("home", rule_id),
+ "progress": lambda: unit.recurrence.progress("home", rule_id),
+ "slots": lambda: unit.recurrence.slot("home", created.receipt.command.key),
+ "frontiers": lambda: unit.recurrence.frontier("home"),
+ }[kind]
+ with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
+ read()
+ with pytest.raises(DatabaseRejected, match="invalid_snapshot_records"):
+ SQLiteSnapshots(store, tmp_path / "checkpoint", store.installation, Clock()).create("bad")
+ assert store.path.read_bytes() == source
diff --git a/tests/backend/test_recurrence_frontier.py b/tests/backend/test_recurrence_frontier.py
index 8211fb0..dd7b59f 100644
--- a/tests/backend/test_recurrence_frontier.py
+++ b/tests/backend/test_recurrence_frontier.py
@@ -1,6 +1,5 @@
"""Fair rule selection has a small scoped durable cursor, separate from task/work progress."""
-import json
from concurrent.futures import ThreadPoolExecutor
from dataclasses import FrozenInstanceError, replace
from threading import Barrier
@@ -11,6 +10,7 @@
from domus.domain.errors import Conflict, DatabaseRejected, InvalidInput, StorageFailure
from domus.domain.recurrence_frontier import RecurrenceFrontier
+from domus.domain.storage import JsonDocument
from domus.infrastructure.sqlite import SQLiteDatabase
from domus.infrastructure.sqlite_recurrence_records import frontier_document, read_frontier
from domus.infrastructure.sqlite_snapshots import SQLiteSnapshots
@@ -106,7 +106,11 @@ def test_closed_frontier_codec_never_guesses_or_coerces_stored_data(change):
document["unexpected"] = None
elif change == "bool-revision":
document["revision"] = True
- raw = json.dumps(document) if change != "duplicate-key" else '{"version":1,"version":1}'
+ raw = (
+ JsonDocument.from_value(document).encoded
+ if change != "duplicate-key"
+ else '{"version":1,"version":1}'
+ )
with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
read_frontier(raw)
diff --git a/tests/backend/test_sqlite_recurrence_integrity.py b/tests/backend/test_sqlite_recurrence_integrity.py
index 445ebc7..f25e4ea 100644
--- a/tests/backend/test_sqlite_recurrence_integrity.py
+++ b/tests/backend/test_sqlite_recurrence_integrity.py
@@ -13,6 +13,7 @@
from domus.domain.errors import Conflict, DatabaseRejected, StorageFailure
from domus.domain.recurrence_planning import FixedCursor, PlannedFixedSlot, cursor_binding
from domus.domain.recurrence_rules import NominalDate
+from domus.domain.storage import JsonDocument
from domus.infrastructure.sqlite import SQLiteDatabase
from domus.infrastructure.sqlite_recurrence_integrity import verify_recurrence_records
from domus.infrastructure.sqlite_recurrence_records import (
@@ -72,7 +73,7 @@ def test_self_consistent_slot_headers_and_exact_receipt_cannot_bypass_nominal_co
)
connection.execute(
"UPDATE recurrence_slots SET slot_key=?,nominal_value=?,document=?",
- (later.key, "2026-01-02", json.dumps(document)),
+ (later.key, "2026-01-02", JsonDocument.from_value(document).encoded),
)
connection.execute(
"UPDATE receipts SET command_key=?,result=? WHERE command_key=?",
diff --git a/tests/backend/test_sqlite_recurrence_records.py b/tests/backend/test_sqlite_recurrence_records.py
index 3c70278..d2b49e4 100644
--- a/tests/backend/test_sqlite_recurrence_records.py
+++ b/tests/backend/test_sqlite_recurrence_records.py
@@ -1,6 +1,5 @@
"""Closed persistence codecs preserve every nominal/time-policy fact and reject corrupt bytes."""
-import json
from dataclasses import replace
from datetime import date
@@ -11,6 +10,7 @@
from domus.domain.recurrence_planning import cursor_binding
from domus.domain.recurrence_rules import ByDay, FixedPattern, Frequency, NominalDate, Weekday
from domus.domain.recurrence_time import MAX_INSTANT_MS
+from domus.domain.storage import JsonDocument
from domus.infrastructure.sqlite_recurrence_records import (
progress_document,
read_progress,
@@ -66,7 +66,7 @@ def test_unknown_or_extra_record_fields_never_receive_defaults(index, encode, de
row = encode(values()[index]).decode()
row[field] = value
with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
- decode(json.dumps(row))
+ decode(JsonDocument.from_value(row).encoded)
@pytest.mark.parametrize("index,encode,decode", CODECS)
@@ -95,7 +95,7 @@ def test_stored_nominal_values_require_canonical_tagged_date_or_naive_time(timed
row = schedule_document(values(timed)[0]).decode()
row["pattern"]["start"]["local"] = local
with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
- read_schedule(json.dumps(row))
+ read_schedule(JsonDocument.from_value(row).encoded)
@pytest.mark.parametrize(
@@ -113,11 +113,11 @@ def test_corrupt_nested_pattern_values_are_not_coerced(field, value):
row = schedule_document(values()[0]).decode()
row["pattern"][field] = value
with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
- read_schedule(json.dumps(row))
+ read_schedule(JsonDocument.from_value(row).encoded)
def test_a_resolved_time_cannot_be_retagged_as_a_date():
row = slot_document(values(True)[2]).decode()
row["planned"]["resolved"]["nominal"] = {"kind": "date", "local": "2026-01-01"}
with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
- read_slot(json.dumps(row))
+ read_slot(JsonDocument.from_value(row).encoded)
diff --git a/tests/backend/test_sqlite_transactions.py b/tests/backend/test_sqlite_transactions.py
index 132da73..52db425 100644
--- a/tests/backend/test_sqlite_transactions.py
+++ b/tests/backend/test_sqlite_transactions.py
@@ -152,6 +152,21 @@ def test_reader_cannot_write_and_an_external_writer_returns_bounded_busy(tmp_pat
assert unit.households.current() is None
+@pytest.mark.parametrize("operation", ["read", "write"])
+def test_exclusive_writer_does_not_misclassify_inspection_contention_as_corrupt_database(
+ tmp_path: Path, operation: str
+) -> None:
+ store = database(tmp_path / "domus.sqlite3")
+ with store._connection() as holder:
+ holder.execute("BEGIN EXCLUSIVE")
+ with pytest.raises(StorageBusy, match="storage_busy"):
+ with getattr(store, operation)():
+ pytest.fail("An exclusive external writer still owns this database")
+ holder.execute("ROLLBACK")
+ with store.read() as unit:
+ assert unit.households.current() is None
+
+
@pytest.mark.parametrize(
"change", ["future", "unrelated", "missing_metadata", "wrong_channel", "wrong_instance"]
)
diff --git a/tests/backend/test_storage_contention_http.py b/tests/backend/test_storage_contention_http.py
new file mode 100644
index 0000000..ae4fe5a
--- /dev/null
+++ b/tests/backend/test_storage_contention_http.py
@@ -0,0 +1,18 @@
+"""Authorized reads report real inspection contention as temporary unavailability."""
+
+from test_task_runtime import api as api
+
+
+def test_exclusive_writer_returns_safe_busy_then_explicit_read_refresh_recovers(api):
+ with api.store._connection() as holder:
+ holder.execute("BEGIN EXCLUSIVE")
+ response = api.client.get("/api/v1/authorization")
+ assert response.status_code == 503, response.text
+ assert response.json()["code"] == "storage_busy"
+ assert response.headers["Retry-After"] == "1"
+ assert "database" not in response.text.lower()
+ holder.execute("ROLLBACK")
+ assert api.client.get("/api/v1/authorization").json()["state"] == "active"
+ with api.store.read() as unit:
+ assert unit.households.current().revision == 1
+ assert len(unit.audit.entries("home")) == 1
diff --git a/tests/containers/test_recurrence_image.py b/tests/containers/test_recurrence_image.py
index 13a21c6..237fcb2 100644
--- a/tests/containers/test_recurrence_image.py
+++ b/tests/containers/test_recurrence_image.py
@@ -29,7 +29,8 @@ def test_current_image_executes_recurrence_creation_planning_and_closed_record_c
from domus.application.recurrence_commands import CreateFixedTask, RecurrenceCommands
from domus.application.task_requests import CreateTask
from domus.composition import ingress, server
-from domus.domain.errors import InvalidInput
+from domus.domain.errors import InvalidInput, StorageBusy, StorageFailure
+from domus.domain.recurrence_frontier import RecurrenceFrontier
from domus.domain.identity import HumanIdentity
from domus.domain.nominal_recurrence import CalendarNominalExpansion
from domus.domain.recurrence_records import FixedProgress, FixedSchedule, ScheduledSlot
@@ -42,6 +43,7 @@ def test_current_image_executes_recurrence_creation_planning_and_closed_record_c
from domus.infrastructure.sqlite import SCHEMA_VERSION, SQLiteDatabase
from domus.infrastructure.sqlite_recurrence_records import (
schedule_document, read_schedule, progress_document, read_progress, slot_document, read_slot,
+ frontier_document, read_frontier,
)
assert SCHEMA_VERSION == 8
policy = TimePolicy('Europe/Madrid', TIMEZONE_DATA_IDENTITY)
@@ -59,6 +61,18 @@ def test_current_image_executes_recurrence_creation_planning_and_closed_record_c
assert read_schedule(schedule_document(rule).encoded) == rule
assert read_progress(progress_document(progress).encoded) == progress
assert read_slot(slot_document(slot).encoded) == slot
+for value, encode, decode in (
+ (rule, schedule_document, read_schedule),
+ (progress, progress_document, read_progress),
+ (slot, slot_document, read_slot),
+ (RecurrenceFrontier('home', None, 100), frontier_document, read_frontier),
+):
+ try:
+ decode(' ' + encode(value).encoded)
+ except StorageFailure:
+ pass
+ else:
+ raise AssertionError('Noncanonical stored text was accepted')
assert progress.matches_schedule(rule) and slot.consistent_with(rule)
try:
ScheduledSlot(installation, replace(rule, pattern=replace(pattern, count=1)),
@@ -145,6 +159,15 @@ async def serve_once(runtime):
assert unit.recurrence.progress('home', scheduled['schedule_id']).cursor.closed
with store.read() as unit:
assert len(unit.tasks.occurrences('home')) == 8
+ contended = SQLiteDatabase(store.path, installation, busy_timeout=0.01)
+ with store._connection() as holder:
+ holder.execute('BEGIN EXCLUSIVE')
+ try:
+ with contended.read():
+ raise AssertionError('An exclusive writer was bypassed')
+ except StorageBusy:
+ pass
+ holder.execute('ROLLBACK')
print('Current-source recurrence, both owned runtimes and pinned-time codecs passed.')
""",
)
From 05951170603132fa17db92b49b1174d4fefa2708 Mon Sep 17 00:00:00 2001
From: Efra Espada
Date: Mon, 2 Nov 2026 10:31:53 +0100
Subject: [PATCH 10/16] feat: Plan relative work from original completion and
actual local time
---
.../src/domus/application/recurrence_ports.py | 10 +-
.../domus/application/relative_recurrence.py | 62 +++++
.../src/domus/domain/recurrence_planning.py | 7 +-
backend/src/domus/domain/recurrence_time.py | 36 +++
.../src/domus/domain/relative_recurrence.py | 229 +++++++++++++++
backend/src/domus/domain/task_events.py | 28 +-
backend/src/domus/domain/tasks.py | 16 ++
.../domus/infrastructure/recurrence_time.py | 16 ++
docs/architecture-recurrence.md | 30 ++
.../recurrence-relative-planning.json | 110 ++++++++
docs/evidence/recurrence-storage.json | 2 +-
specs/recurrence-storage-continuation.md | 15 +
.../backend/test_recurrence_local_instant.py | 68 +++++
.../test_relative_recurrence_planning.py | 195 +++++++++++++
.../test_relative_recurrence_records.py | 262 ++++++++++++++++++
tests/containers/test_recurrence_image.py | 20 +-
16 files changed, 1076 insertions(+), 30 deletions(-)
create mode 100644 backend/src/domus/application/relative_recurrence.py
create mode 100644 backend/src/domus/domain/relative_recurrence.py
create mode 100644 docs/evidence/recurrence-relative-planning.json
create mode 100644 tests/backend/test_recurrence_local_instant.py
create mode 100644 tests/backend/test_relative_recurrence_planning.py
create mode 100644 tests/backend/test_relative_recurrence_records.py
diff --git a/backend/src/domus/application/recurrence_ports.py b/backend/src/domus/application/recurrence_ports.py
index bdb1515..06d48cf 100644
--- a/backend/src/domus/application/recurrence_ports.py
+++ b/backend/src/domus/application/recurrence_ports.py
@@ -10,7 +10,13 @@
ScheduleState,
)
from domus.domain.recurrence_rules import FixedPattern, PeriodCandidates
-from domus.domain.recurrence_time import NominalTime, RelativeInterval, ResolvedTime, TimePolicy
+from domus.domain.recurrence_time import (
+ LocalInstant,
+ NominalTime,
+ RelativeInterval,
+ ResolvedTime,
+ TimePolicy,
+)
from domus.domain.storage import Installation
@@ -22,6 +28,8 @@ def require_policy(self, policy: TimePolicy) -> None: ...
def resolve(self, nominal: NominalTime, policy: TimePolicy) -> ResolvedTime: ...
+ def local_at(self, instant_ms: int, policy: TimePolicy) -> LocalInstant: ...
+
def after_local_days(
self, performed_at: int, interval: RelativeInterval, policy: TimePolicy
) -> ResolvedTime: ...
diff --git a/backend/src/domus/application/relative_recurrence.py b/backend/src/domus/application/relative_recurrence.py
new file mode 100644
index 0000000..9885b0c
--- /dev/null
+++ b/backend/src/domus/application/relative_recurrence.py
@@ -0,0 +1,62 @@
+"""Plan one completion-relative successor from actual work; no storage or execution claim."""
+
+from dataclasses import dataclass
+from datetime import timedelta
+
+from domus.application.recurrence_ports import RecurrenceTimes
+from domus.domain.errors import IntegrationUnavailable, InvalidInput
+from domus.domain.recurrence_planning import PlannedSlot
+from domus.domain.recurrence_records import ScheduleState
+from domus.domain.recurrence_rules import NominalDate
+from domus.domain.recurrence_time import LocalInstant, NominalTime, ResolvedTime
+from domus.domain.relative_recurrence import RelativeSchedule, RelativeSource
+from domus.domain.task_values import VerificationState
+from domus.domain.tasks import TaskCycle
+
+
+@dataclass(frozen=True, slots=True)
+class RelativePlan:
+ status: str
+ planned: PlannedSlot | None = None
+ source: RelativeSource | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class RelativeRecurrencePlanner:
+ times: RecurrenceTimes
+
+ def plan(self, schedule: RelativeSchedule, cycle: TaskCycle) -> RelativePlan:
+ if not schedule.allows_successor(cycle):
+ status = (
+ schedule.state.value
+ if schedule.state is not ScheduleState.ACTIVE
+ else "source_closed"
+ if cycle.closed_at is not None or cycle.verification is VerificationState.REJECTED
+ else "waiting_verification"
+ )
+ return RelativePlan(status)
+ local = self.times.local_at(cycle.performed_at, schedule.policy)
+ if (
+ not isinstance(local, LocalInstant)
+ or local.instant_ms != cycle.performed_at
+ or local.policy != schedule.policy
+ ):
+ raise IntegrationUnavailable("recurrence_time_source_invalid")
+ source = RelativeSource.from_cycle(cycle, local)
+ try:
+ expected = local.local + timedelta(days=schedule.interval.days)
+ except OverflowError:
+ raise InvalidInput("recurrence_time_out_of_range") from None
+ if isinstance(schedule.initial.nominal, NominalDate):
+ return RelativePlan("ready", PlannedSlot(NominalDate(expected.date()), None), source)
+ resolved = self.times.after_local_days(
+ cycle.performed_at, schedule.interval, schedule.policy
+ )
+ if (
+ not isinstance(resolved, ResolvedTime)
+ or resolved.nominal != NominalTime(expected)
+ or resolved.policy != schedule.policy
+ or resolved.zone_digest != local.zone_digest
+ ):
+ raise IntegrationUnavailable("recurrence_time_source_invalid")
+ return RelativePlan("ready", PlannedSlot(resolved.nominal, resolved), source)
diff --git a/backend/src/domus/domain/recurrence_planning.py b/backend/src/domus/domain/recurrence_planning.py
index 31e70ec..a6271ed 100644
--- a/backend/src/domus/domain/recurrence_planning.py
+++ b/backend/src/domus/domain/recurrence_planning.py
@@ -41,7 +41,9 @@ def __post_init__(self) -> None:
@dataclass(frozen=True, slots=True)
-class PlannedFixedSlot:
+class PlannedSlot:
+ """Nominal/resolved facts shared by timing modes; rule membership is checked separately."""
+
nominal: NominalSlot
resolved: ResolvedTime | None
@@ -60,6 +62,9 @@ def __post_init__(self) -> None:
raise InvalidInput("invalid_recurrence_slot")
+PlannedFixedSlot = PlannedSlot
+
+
@dataclass(frozen=True, slots=True)
class FixedBatch:
slots: tuple[PlannedFixedSlot, ...]
diff --git a/backend/src/domus/domain/recurrence_time.py b/backend/src/domus/domain/recurrence_time.py
index 62aeb08..ed0acb6 100644
--- a/backend/src/domus/domain/recurrence_time.py
+++ b/backend/src/domus/domain/recurrence_time.py
@@ -117,3 +117,39 @@ def instant_ms(self) -> int:
+ delta.microseconds // 1000
- self.offset_seconds * 1000
)
+
+
+@dataclass(frozen=True, slots=True)
+class LocalInstant:
+ """An actual UTC instant's local fact; future fold preference cannot reinterpret it."""
+
+ instant_ms: int
+ local: datetime
+ offset_seconds: int
+ policy: TimePolicy
+ zone_digest: str
+
+ def __post_init__(self) -> None:
+ if (
+ type(self.instant_ms) is not int
+ or not 0 <= self.instant_ms <= MAX_INSTANT_MS
+ or type(self.local) is not datetime
+ or self.local.tzinfo is not None
+ or self.local.fold != 0
+ or self.local.microsecond % 1000 != 0
+ or type(self.offset_seconds) is not int
+ or not -86400 < self.offset_seconds < 86400
+ or not isinstance(self.policy, TimePolicy)
+ or not isinstance(self.zone_digest, str)
+ or re.fullmatch(r"[a-f0-9]{64}", self.zone_digest) is None
+ ):
+ raise InvalidInput("invalid_recurrence_local_instant")
+ delta = self.local - _EPOCH
+ actual = (
+ delta.days * 86_400_000
+ + delta.seconds * 1000
+ + delta.microseconds // 1000
+ - self.offset_seconds * 1000
+ )
+ if actual != self.instant_ms:
+ raise InvalidInput("invalid_recurrence_local_instant")
diff --git a/backend/src/domus/domain/relative_recurrence.py b/backend/src/domus/domain/relative_recurrence.py
new file mode 100644
index 0000000..0b56050
--- /dev/null
+++ b/backend/src/domus/domain/relative_recurrence.py
@@ -0,0 +1,229 @@
+"""Completion-relative rule, progress and original-cycle facts contain no authority grants."""
+
+from dataclasses import dataclass
+from datetime import timedelta
+from enum import StrEnum
+
+from domus.domain.errors import InvalidInput
+from domus.domain.identity import validate_revision
+from domus.domain.recurrence_planning import PlannedSlot
+from domus.domain.recurrence_records import MAINTENANCE_ACTOR, ScheduleState
+from domus.domain.recurrence_rules import NominalDate
+from domus.domain.recurrence_time import LocalInstant, NominalTime, RelativeInterval, TimePolicy
+from domus.domain.storage import CommandKey, Installation, JsonDocument, validate_identifier
+from domus.domain.task_values import VerificationState, validate_task_time
+from domus.domain.tasks import TaskCycle
+
+
+class SuccessorVisibility(StrEnum):
+ AT_COMPLETION = "at_completion"
+ AT_VERIFICATION = "at_verification"
+
+
+@dataclass(frozen=True, slots=True)
+class RelativeSchedule:
+ schedule_id: str
+ household_id: str
+ definition_id: str
+ initial: PlannedSlot
+ interval: RelativeInterval
+ policy: TimePolicy
+ created_at: int
+ visibility: SuccessorVisibility = SuccessorVisibility.AT_COMPLETION
+ state: ScheduleState = ScheduleState.ACTIVE
+ revision: int = 1
+ pattern_revision: int = 1
+
+ def __post_init__(self) -> None:
+ for value in (self.schedule_id, self.household_id, self.definition_id):
+ validate_identifier(value)
+ validate_revision(self.revision)
+ validate_revision(self.pattern_revision)
+ validate_task_time(self.created_at)
+ if (
+ not isinstance(self.initial, PlannedSlot)
+ or not isinstance(self.interval, RelativeInterval)
+ or not isinstance(self.policy, TimePolicy)
+ or not isinstance(self.visibility, SuccessorVisibility)
+ or not isinstance(self.state, ScheduleState)
+ or self.pattern_revision > self.revision
+ or (self.initial.resolved is not None and self.initial.resolved.policy != self.policy)
+ ):
+ raise InvalidInput("invalid_relative_schedule")
+
+ def same_timing(self, other: RelativeSchedule) -> bool:
+ return (self.initial, self.interval, self.policy, self.visibility) == (
+ other.initial,
+ other.interval,
+ other.policy,
+ other.visibility,
+ )
+
+ def allows_successor(self, cycle: TaskCycle) -> bool:
+ if not isinstance(cycle, TaskCycle) or cycle.household_id != self.household_id:
+ raise InvalidInput("invalid_relative_source")
+ return (
+ self.state is ScheduleState.ACTIVE
+ and cycle.closed_at is None
+ and cycle.verification is not VerificationState.REJECTED
+ and (
+ self.visibility is SuccessorVisibility.AT_COMPLETION
+ or cycle.verification in (VerificationState.NONE, VerificationState.APPROVED)
+ )
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class RelativeProgress:
+ schedule_id: str
+ household_id: str
+ pattern_revision: int
+ current_occurrence_id: str | None = None
+ current_slot_key: str | None = None
+ source_after: int = 0
+ last_run_at: int | None = None
+ revision: int = 1
+
+ def __post_init__(self) -> None:
+ validate_identifier(self.schedule_id)
+ validate_identifier(self.household_id)
+ validate_revision(self.pattern_revision)
+ validate_revision(self.revision)
+ if self.current_occurrence_id is not None:
+ validate_identifier(self.current_occurrence_id)
+ if self.current_slot_key is not None:
+ validate_identifier(self.current_slot_key)
+ if self.last_run_at is not None:
+ validate_task_time(self.last_run_at)
+ if (
+ (self.current_occurrence_id is None) != (self.current_slot_key is None)
+ or type(self.source_after) is not int
+ or not 0 <= self.source_after <= 2**63 - 1
+ ):
+ raise InvalidInput("invalid_relative_progress")
+
+ def matches_schedule(self, schedule: RelativeSchedule) -> bool:
+ return (
+ self.schedule_id == schedule.schedule_id
+ and self.household_id == schedule.household_id
+ and self.pattern_revision == schedule.pattern_revision
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class RelativeSource:
+ cycle: TaskCycle
+ performed_local: LocalInstant
+
+ def __post_init__(self) -> None:
+ if (
+ not isinstance(self.cycle, TaskCycle)
+ or not isinstance(self.performed_local, LocalInstant)
+ or self.cycle != self.cycle.completion_snapshot()
+ or self.cycle.performed_at != self.performed_local.instant_ms
+ ):
+ raise InvalidInput("invalid_relative_source")
+
+ @classmethod
+ def from_cycle(cls, cycle: TaskCycle, local: LocalInstant) -> RelativeSource:
+ if not isinstance(cycle, TaskCycle):
+ raise InvalidInput("invalid_relative_source")
+ return cls(cycle.completion_snapshot(), local)
+
+
+@dataclass(frozen=True, slots=True)
+class RelativeSlot:
+ installation: Installation
+ schedule: RelativeSchedule
+ planned: PlannedSlot
+ occurrence_id: str
+ created_at: int
+ source: RelativeSource | None = None
+
+ def __post_init__(self) -> None:
+ validate_identifier(self.occurrence_id)
+ validate_task_time(self.created_at)
+ if (
+ not isinstance(self.installation, Installation)
+ or not isinstance(self.schedule, RelativeSchedule)
+ or not isinstance(self.planned, PlannedSlot)
+ or self.created_at < self.schedule.created_at
+ or type(self.planned.nominal) is not type(self.schedule.initial.nominal)
+ or (
+ self.planned.resolved is not None
+ and self.planned.resolved.policy != self.schedule.policy
+ )
+ ):
+ raise InvalidInput("invalid_relative_slot")
+ if self.source is None:
+ if self.planned != self.schedule.initial:
+ raise InvalidInput("invalid_relative_slot")
+ return
+ source = self.source
+ if (
+ not isinstance(source, RelativeSource)
+ or source.cycle.household_id != self.schedule.household_id
+ or source.cycle.occurrence_id == self.occurrence_id
+ or source.cycle.recorded_at > self.created_at
+ or source.performed_local.policy != self.schedule.policy
+ or (
+ self.planned.resolved is not None
+ and self.planned.resolved.zone_digest != source.performed_local.zone_digest
+ )
+ ):
+ raise InvalidInput("invalid_relative_slot")
+ try:
+ local = source.performed_local.local + timedelta(days=self.schedule.interval.days)
+ except OverflowError:
+ raise InvalidInput("recurrence_time_out_of_range") from None
+ expected = (
+ NominalDate(local.date())
+ if isinstance(self.schedule.initial.nominal, NominalDate)
+ else NominalTime(local)
+ )
+ if self.planned.nominal != expected:
+ raise InvalidInput("invalid_relative_slot")
+
+ def consistent_with(self, current: RelativeSchedule) -> bool:
+ original = self.schedule
+ return (
+ original.schedule_id == current.schedule_id
+ and original.household_id == current.household_id
+ and original.definition_id == current.definition_id
+ and original.created_at == current.created_at
+ and original.revision <= current.revision
+ and original.pattern_revision <= current.pattern_revision
+ and (
+ original.pattern_revision < current.pattern_revision
+ or original.same_timing(current)
+ )
+ )
+
+ @property
+ def key(self) -> str:
+ source = (
+ None
+ if self.source is None
+ else {
+ "occurrence": self.source.cycle.occurrence_id,
+ "cycle": self.source.cycle.cycle_id,
+ }
+ )
+ return JsonDocument.from_value(
+ {
+ "version": 1,
+ "instance": self.installation.instance_id,
+ "channel": self.installation.channel,
+ "household": self.schedule.household_id,
+ "schedule": self.schedule.schedule_id,
+ "pattern_revision": self.schedule.pattern_revision,
+ "timing": "completion_relative",
+ "source": source,
+ }
+ ).fingerprint
+
+ @property
+ def command(self) -> CommandKey:
+ return CommandKey(
+ self.schedule.household_id, MAINTENANCE_ACTOR, "recurrence.materialize", self.key
+ )
diff --git a/backend/src/domus/domain/task_events.py b/backend/src/domus/domain/task_events.py
index c1e7dd7..d14f7c2 100644
--- a/backend/src/domus/domain/task_events.py
+++ b/backend/src/domus/domain/task_events.py
@@ -27,19 +27,7 @@ class TaskEventKind(StrEnum):
def _event_cycle(kind: TaskEventKind, value: TaskCycle) -> TaskCycle:
- original = TaskCycle.record(
- value.cycle_id,
- value.household_id,
- value.occurrence_id,
- recorded_by=value.recorded_by,
- performers=value.performers,
- rules=value.rules,
- definition_revision=value.definition_revision,
- task_policy_revision=value.task_policy_revision,
- identity_policy_revision=value.identity_policy_revision,
- performed_at=value.performed_at,
- recorded_at=value.recorded_at,
- )
+ original = value.completion_snapshot()
completed = original
if value.verification in (VerificationState.APPROVED, VerificationState.REJECTED):
original = original.verify(
@@ -191,19 +179,7 @@ def _root(evidence: OperationEvidence, cycle: TaskCycle, action: str) -> JsonObj
def validate_completion(evidence: OperationEvidence, cycle: TaskCycle) -> JsonObject:
result = _root(evidence, cycle, "task.complete")
request = operation_request(evidence)
- original = TaskCycle.record(
- cycle.cycle_id,
- cycle.household_id,
- cycle.occurrence_id,
- recorded_by=cycle.recorded_by,
- performers=cycle.performers,
- rules=cycle.rules,
- definition_revision=cycle.definition_revision,
- task_policy_revision=cycle.task_policy_revision,
- identity_policy_revision=cycle.identity_policy_revision,
- performed_at=cycle.performed_at,
- recorded_at=cycle.recorded_at,
- )
+ original = cycle.completion_snapshot()
if (
result.get("state") != "completed"
or JsonDocument.from_value({"cycle": result.get("cycle")})
diff --git a/backend/src/domus/domain/tasks.py b/backend/src/domus/domain/tasks.py
index aa2d8f3..4f6054b 100644
--- a/backend/src/domus/domain/tasks.py
+++ b/backend/src/domus/domain/tasks.py
@@ -239,6 +239,22 @@ def record(
else VerificationState.NONE,
)
+ def completion_snapshot(self) -> Self:
+ """Original recorded work survives later verification and closure unchanged."""
+ return self.record(
+ self.cycle_id,
+ self.household_id,
+ self.occurrence_id,
+ recorded_by=self.recorded_by,
+ performers=self.performers,
+ rules=self.rules,
+ definition_revision=self.definition_revision,
+ task_policy_revision=self.task_policy_revision,
+ identity_policy_revision=self.identity_policy_revision,
+ performed_at=self.performed_at,
+ recorded_at=self.recorded_at,
+ )
+
def verify(
self, decision: VerificationState, verifier: TaskPerformer, now: int, reason: str = ""
) -> Self:
diff --git a/backend/src/domus/infrastructure/recurrence_time.py b/backend/src/domus/infrastructure/recurrence_time.py
index 7bd3fef..a7fbd63 100644
--- a/backend/src/domus/infrastructure/recurrence_time.py
+++ b/backend/src/domus/infrastructure/recurrence_time.py
@@ -13,6 +13,7 @@
from domus.domain.recurrence_time import (
MAX_INSTANT_MS,
FoldPolicy,
+ LocalInstant,
NominalTime,
RelativeInterval,
ResolvedTime,
@@ -114,6 +115,21 @@ def resolve(self, nominal: NominalTime, policy: TimePolicy) -> ResolvedTime:
zone, digest = self._zone(policy)
return self._resolve(nominal, policy, zone, digest)
+ def local_at(self, instant_ms: int, policy: TimePolicy) -> LocalInstant:
+ if type(instant_ms) is not int or not 0 <= instant_ms <= MAX_INSTANT_MS:
+ raise InvalidInput("recurrence_time_out_of_range")
+ zone, digest = self._zone(policy)
+ try:
+ actual = (_EPOCH + timedelta(milliseconds=instant_ms)).astimezone(zone)
+ offset = actual.utcoffset()
+ assert offset is not None # ZoneInfo always supplies an actual offset.
+ seconds = offset.days * 86400 + offset.seconds
+ return LocalInstant(
+ instant_ms, actual.replace(tzinfo=None, fold=0), seconds, policy, digest
+ )
+ except OverflowError:
+ raise InvalidInput("recurrence_time_out_of_range") from None
+
def _resolve(
self, nominal: NominalTime, policy: TimePolicy, zone: ZoneInfo, digest: str
) -> ResolvedTime:
diff --git a/docs/architecture-recurrence.md b/docs/architecture-recurrence.md
index e1bd376..c8192ec 100644
--- a/docs/architecture-recurrence.md
+++ b/docs/architecture-recurrence.md
@@ -251,3 +251,33 @@ after both original POST responses, then checks the retained rejection and delib
the exact original request under current authority. Refresh never resubmits a POST. See the
[review corrections evidence](evidence/recurrence-review-corrections.json) for original failures,
complete-source validation, current images and remaining acceptance limits.
+
+## Completion-relative source and planning values
+
+[Relative records](../backend/src/domus/domain/relative_recurrence.py) now distinguish an initial
+obligation from a successor derived from an original task cycle. Their generation/cycle keys
+exclude approval time, mutable record revision and generated task IDs. Record construction
+validates facts; it cannot authorize maintenance or prove current source state. Historical source
+values survive later closure. The future durable consumer must verify live references, original
+receipts and its retained inbox/closure before committing an effect.
+
+LocalInstant retains the actual UTC performed instant, realized wall time/offset and pinned source
+identity independently of a future fold preference. Actual work in the second autumn appearance
+remains there under a first-appearance household policy. The internal epoch-local basis can lie
+in late1969 under a negative offset; user NominalTime continues to require1970 or later. One pure
+TaskCycle completion snapshot helper now supplies existing lifecycle checks and relative sources
+without rewriting performers, actual work time or original rules after verification/reopening.
+
+The [read-only planner](../backend/src/domus/application/relative_recurrence.py) receives the
+current cycle and produces ready, waiting-verification, closed-source or inactive-rule facts.
+It derives local calendar days from performed_at and retains milliseconds for timed deadlines.
+Date-only work stays a date, including a wholly missing local date, without fabricating an instant.
+Invalid provider facts and range overflow remain explicit failures. Paused/retired rules and
+ineligible source states require no provider I/O.
+
+The shared PlannedSlot retains the existing PlannedFixedSlot source name and encoding; existing
+fixed rule membership and materialization remain unchanged. The
+[planning evidence](evidence/recurrence-relative-planning.json) includes real SQLite lifecycle
+facts and preserved task/evidence counts, not a persisted successor. Relative storage, original
+proof/recovery, durable cycle closure and invalidation, global mixed-rule fairness and production
+integration remain required. This increment changes no SQLite layout or owner HA installation.
diff --git a/docs/evidence/recurrence-relative-planning.json b/docs/evidence/recurrence-relative-planning.json
new file mode 100644
index 0000000..3653764
--- /dev/null
+++ b/docs/evidence/recurrence-relative-planning.json
@@ -0,0 +1,110 @@
+{
+ "status": "partial; relative records and read-only planning implemented, durable relative execution remains required",
+ "recordedAtUTC": "2026-11-02T09:30:54.573700+00:00",
+ "issue": "https://github.com/vypdev/domus/issues/20",
+ "pullRequest": "https://github.com/vypdev/domus/pull/22",
+ "sourceBase": "abf6403b569c39101df2001eb90ad013c40972c1",
+ "backendSourceInventorySHA256": "0b1ae506eb98d959de6776d7ffea8f1324c381701ab9e86ed5078d85617ff76c",
+ "sourceManifestMethod": "SHA256 of compact sorted JSON mapping every authored Python module path, including package initializers, to its byte SHA256 value.",
+ "implemented": [
+ "Immutable typed relative rule, progress, original completion source and initial/successor slot facts; scoped generation/cycle identity with no authority or persistence grant.",
+ "LocalInstant preserves an actual UTC instant and realized local offset independently of the future fold preference, with strict integer arithmetic, policy and actual pinned zone-byte identity.",
+ "One shared pure TaskCycle.completion_snapshot preserves actual original work through later approval/reopening and is reused by current lifecycle validation.",
+ "A pure application planner returns ready, waiting_verification, source_closed, paused or retired facts and performs no provider I/O for ineligible current sources.",
+ "Successor calendar-day arithmetic starts at performed_at, preserves time milliseconds and original overdue dates; date-only remains a date even when that date is absent in a timezone.",
+ "Current source/policy/zone-digest/provider mismatches and out-of-range future dates fail explicitly. The UTC epoch may have a valid internal local source in late1969 without widening user-nominal dates.",
+ "PlannedSlot is the shared nominal/resolved value; PlannedFixedSlot remains a source-compatible name with unchanged encoding and existing fixed membership enforcement."
+ ],
+ "testsFirst": {
+ "records": "Initial collection failed because LocalInstant did not exist; original output retained.",
+ "planner": "Separate initial collection failed because domus.application.relative_recurrence did not exist; original output retained."
+ },
+ "completeBackend": {
+ "executions": 3783,
+ "durationSeconds": 96.8,
+ "authoredModules": 132,
+ "coverageTotals": {
+ "covered_lines": 9406,
+ "num_statements": 9406,
+ "percent_covered": 100.0,
+ "percent_covered_display": "100.00",
+ "missing_lines": 0,
+ "excluded_lines": 274,
+ "percent_statements_covered": 100.0,
+ "percent_statements_covered_display": "100.00",
+ "num_branches": 2676,
+ "num_partial_branches": 0,
+ "covered_branches": 2676,
+ "missing_branches": 0,
+ "percent_branches_covered": 100.0,
+ "percent_branches_covered_display": "100.00"
+ },
+ "independentGlobalAndPerModuleAudit": "passed"
+ },
+ "focusedTemporalAndSource": {
+ "executions": 229,
+ "durationSeconds": 2.09,
+ "authoredModules": 4,
+ "statements": 311,
+ "branches": 84,
+ "coveragePercent": 100,
+ "scope": "Includes prior timezone, creation and lifecycle regressions, not229 distinct relative product journeys."
+ },
+ "actualObservations": [
+ "Both actual autumn fold instants remain distinct and keep their original offsets under either future fold preference.",
+ "A spring-DST successor one local day later is23 UTC hours later; approval five days later retains that original derived date and milliseconds.",
+ "The existing real SQLite task flow supplies pending completion, accepted approval and reopening facts. A current cycle read after reopening keeps old delivered events source_closed.",
+ "Repeated read-only plans preserve the original normalized source and leave current task/audit/outbox counts unchanged. No relative task or consumer receipt is created.",
+ "The initial obligation, each actual source cycle and each installation/channel/generation have separate stable keys, independent of later generated task IDs or approval time.",
+ "A wholly missing date-only local deadline remains its nominal date with no fabricated UTC midnight; local-date overflow is refused."
+ ],
+ "checks": {
+ "strictTypes": "passed;132 modules",
+ "ruff": "check and format passed;333 files",
+ "architecture": {
+ "contractsKept": 3,
+ "contractsBroken": 0,
+ "filesAnalyzed": 176,
+ "dependencies": 1135
+ },
+ "originalPinnedSdd": {
+ "upstream": "3e7ceea08e6131360e026937d1c0cc1b120472e0",
+ "base": "0806a38f8a2de1f102a017bff25ad6856fb17bcd",
+ "selected": "specs/recurrence-storage-continuation.md",
+ "originalPolicyModulesVerified": 7,
+ "format": "PASS",
+ "consistency": "PASS",
+ "composition": "explicit contributor integration; original publication retained in Git",
+ "unchangedCatalog": "PASS",
+ "immutableOwnerDocumentsUsed": 18,
+ "retainedCompanionCases": 52,
+ "newCases": 4,
+ "rawCharacters": 32945,
+ "renderedCharacters": 32945,
+ "composedCharacters": 32945,
+ "applicableDraftMaximumCharacters": 70000,
+ "sha256": "fed3d9b88e9d4b1aea507e80a8da61f41e30ce64b5a3f934989644b76a1646be",
+ "scope": "Review input only; no gate admission, branch publication, source implementation or runtime acceptance.",
+ "retainedOwningFilesUnchanged": true,
+ "workingDocumentValidated": true
+ },
+ "packageAndArchitecture": {
+ "executions": 52,
+ "durationSeconds": 6.82,
+ "freshCurrentWheel": "passed; actual duration retained in original output"
+ }
+ },
+ "imageControls": {
+ "hostExecution": "Current-source recurrence, both owned runtimes and pinned-time codecs passed.",
+ "scope": "Exact current-image script executes actual SQLite task completion and original lifecycle export, then packaged read-only relative planning, alongside prior fixed/fair runtime controls. It does not claim relative persistence or materialization.",
+ "freshHostedCpuImages": "Required after this source is published; a host execution is not CPU-image proof.",
+ "priorCheckpoint": "abf6403 passed every applicable hosted product CI suite and both actual CPU images. Native review completed8/8 partitions,86 fragments and75 assigned files with0 open/candidate/published findings and both valid findings fixed. Overall review remained partial/neutral because of the reported human-conversation limit; technical inputs were manually inspected."
+ },
+ "compatibility": "No SQLite schema/layout, current stored encoding, public HTTP surface, app version or owner installation changed by this planning phase. Current runtimes still execute only the fixed runner.",
+ "copilot": "Known progress-capacity and human-conversation limits remain reported upstream851/843. No Copilot source/configuration/credential/bound changed; fresh review is required for this source.",
+ "rawEvidence": {
+ "privateCampaign": "/private/tmp/domus-relative-abf6403",
+ "consumer": "Current PR22 review: original two missing-symbol failures, exact complete/focused coverage and source inventory, source/SDK/package proofs and current image-host control. Necessary evidence is retained compactly at cleanup."
+ },
+ "pending": "Actual relative rule/progress/slot persistence, atomic initial creation, original-receipt/restore reconciliation, durable inbox and per-cycle closure, successor invalidation/review, a shared global mixed-rule budget and actual runtime integration; all retained52+37 recurrence cases, protected calendar/operation APIs, reviewed schedule changes and full UI/HA/all-domain Stable/Edge release remain required."
+}
diff --git a/docs/evidence/recurrence-storage.json b/docs/evidence/recurrence-storage.json
index cc87b31..c4bc260 100644
--- a/docs/evidence/recurrence-storage.json
+++ b/docs/evidence/recurrence-storage.json
@@ -85,7 +85,7 @@
"policyModulesByteVerified": 7,
"immutableBaseOwners": 18,
"retainedOwnersUnchanged": true,
- "currentCompanionSHA256": "9395e06f14fd8939950816c707ff3d764bf5489fb7c0cfff8591cf2beae87303",
+ "currentCompanionSHA256": "fed3d9b88e9d4b1aea507e80a8da61f41e30ce64b5a3f934989644b76a1646be",
"scope": "Explicit contributor integration; original first publication remains in Git. Format, reciprocal inventory/path consistency and current catalog pass."
},
"packageAndArchitectureTests": {
diff --git a/specs/recurrence-storage-continuation.md b/specs/recurrence-storage-continuation.md
index 460b9b9..d2aebbc 100644
--- a/specs/recurrence-storage-continuation.md
+++ b/specs/recurrence-storage-continuation.md
@@ -141,6 +141,7 @@ Use real independent SQLite connections and exact before/after version/row check
| backend/src/domus/application/recurrence_commands.py | create |
| backend/src/domus/application/fixed_materialization.py | create |
| backend/src/domus/application/fair_recurrence.py | create |
+| backend/src/domus/application/relative_recurrence.py | create |
| backend/src/domus/application/commands.py | update |
| backend/src/domus/application/task_creation.py | create |
| backend/src/domus/application/task_commands.py | update |
@@ -149,6 +150,10 @@ Use real independent SQLite connections and exact before/after version/row check
| backend/src/domus/domain/recurrence_records.py | create |
| backend/src/domus/domain/recurrence_membership.py | create |
| backend/src/domus/domain/recurrence_frontier.py | create |
+| backend/src/domus/domain/relative_recurrence.py | create |
+| backend/src/domus/domain/tasks.py | update |
+| backend/src/domus/domain/task_events.py | update |
+| backend/src/domus/domain/recurrence_time.py | update |
| backend/src/domus/domain/recurrence_rules.py | update |
| backend/src/domus/infrastructure/recurrence_time.py | update |
| backend/src/domus/infrastructure/recurrence_worker.py | create |
@@ -172,6 +177,9 @@ Use real independent SQLite connections and exact before/after version/row check
| tests/backend/test_recurrence_runtime.py | create |
| tests/backend/test_recurrence_canonical_storage.py | create |
| tests/backend/test_storage_contention_http.py | create |
+| tests/backend/test_relative_recurrence_records.py | create |
+| tests/backend/test_relative_recurrence_planning.py | create |
+| tests/backend/test_recurrence_local_instant.py | create |
| tests/backend/test_recurrence_slot_membership.py | create |
| tests/backend/test_sqlite_recurrence_records.py | create |
| tests/backend/test_sqlite_recurrence_integrity.py | create |
@@ -195,6 +203,7 @@ Use real independent SQLite connections and exact before/after version/row check
| docs/evidence/recurrence-fairness.json | create |
| docs/evidence/recurrence-lifecycle.json | create |
| docs/evidence/recurrence-review-corrections.json | create |
+| docs/evidence/recurrence-relative-planning.json | create |
| docs/tasks/migration.md | update |
| docs/evidence/recurrence-execution.json | update |
@@ -242,6 +251,12 @@ Review identified a strict-storage gap: parsing JSON could normalize altered sou
Concurrent browser evidence also exposes unavailable follow-up reads during a setup winner's commit. An exclusive SQLite writer must yield bounded storage_busy during initial database inspection, not a corruption classification or internal_error. Preserve corrupt-file refusal for other inspection failures. An explicit user status refresh after the two concurrent POST responses can restore verified current facts before inspecting the retained rejection and deliberately replaying the exact original request; it never retries a POST automatically. Verify both read/write repository entry points, actual native/standalone authorization HTTP responses and the real browser flows. The original CI trace has no internal exception stack; the exclusive-lock reproduction establishes the concrete inspection defect rather than proving an unlogged historical stack.
+The next relative increment establishes typed rule/progress/source/slot values and read-only successor planning under the complete retained execution contract. LocalInstant retains an actual UTC instant's realized wall time and offset independently of the household's preference for resolving a future repeated time. In particular, actual work in the second autumn fold remains in that fold even when future work uses the first. Integer UTC/local arithmetic, millisecond precision, policy and pinned zone-byte identity are explicit. The UTC epoch can have an actual local basis in late 1969 under a negative offset; that internal fact does not broaden the existing 1970..9999 user-nominal contract.
+
+TaskCycle supplies its original completion snapshot through one shared pure helper used by existing lifecycle validation and the relative source value. Approval and reopening never rewrite actual performed time, performers or snapshotted rules. Planning receives the current cycle: paused/retired rules, closed/rejected cycles and pending at_verification work produce explicit nonready states without provider work. Ready plans use local calendar days from performed_at, preserve milliseconds for time deadlines, keep date-only deadlines as dates even for a wholly missing local date, and reject inconsistent provider facts or overflow. Original cycle identity, not approval time or a generated task ID, determines the successor slot key within its generation. Historical slot values preserve original source facts after later closure; constructing one confers no execution authority.
+
+The shared nominal/resolved PlannedSlot value retains PlannedFixedSlot as a source-compatible name; its encoding and existing fixed rule membership checks remain unchanged. Actual SQLite task lifecycle reads exercise pending, approved and reopened source facts without persisting a relative plan or generating an event. The future durable consumer must revalidate the live cycle and its own retained inbox/closure before command receipt lookup and commit; feeding an old event snapshot alone is insufficient. Relative rule/progress/slot storage, atomic initial creation, original-proof reconciliation, durable source/inbox closure, successor invalidation, global mixed-rule fairness and runtime wiring remain required. No SQLite layout or owner installation is changed by this planning increment.
+
The worker carries cancellation into every run and joins before runtime identity/storage disposal and installation-lease release. The lifespan preserves the existing application's state and exits its prior context after stopping the worker, including on startup/application failure. Ordinary failures expose only bounded categories and permit a later attempt; a denied technical scope terminates that worker. Last-run observations remain process-local diagnostics, not durable calendar freshness or delivery proof. Identity-only preview has no worker. Actual current-source image controls exercise both ASGI lifetimes, scoped SQLite work and shutdown. Relative lifecycle, current-authorized calendar/command surfaces, durable attention, configurable household timing defaults and complete product/UI/HA acceptance remain required.
## 19. Definition of Done
diff --git a/tests/backend/test_recurrence_local_instant.py b/tests/backend/test_recurrence_local_instant.py
new file mode 100644
index 0000000..429d0df
--- /dev/null
+++ b/tests/backend/test_recurrence_local_instant.py
@@ -0,0 +1,68 @@
+"""An actual performed instant keeps its real fold, independently of future fold preference."""
+
+from dataclasses import replace
+from datetime import UTC, datetime
+
+import pytest
+
+from domus.domain.errors import InvalidInput
+from domus.domain.recurrence_time import MAX_INSTANT_MS, FoldPolicy, TimePolicy
+from domus.infrastructure.recurrence_time import PinnedRecurrenceTime
+
+
+def instant(value):
+ delta = value - datetime(1970, 1, 1, tzinfo=UTC)
+ return delta.days * 86_400_000 + delta.seconds * 1000 + delta.microseconds // 1000
+
+
+@pytest.mark.parametrize("fold", list(FoldPolicy))
+def test_actual_fold_is_preserved_even_when_household_prefers_the_other_appearance(fold):
+ times = PinnedRecurrenceTime()
+ policy = TimePolicy("Europe/Madrid", times.data_identity, fold)
+ first = times.local_at(instant(datetime(2026, 10, 25, 0, 30, tzinfo=UTC)), policy)
+ second = times.local_at(instant(datetime(2026, 10, 25, 1, 30, tzinfo=UTC)), policy)
+ assert first.local == second.local == datetime(2026, 10, 25, 2, 30)
+ assert first.offset_seconds == 7200 and second.offset_seconds == 3600
+ assert first.policy == second.policy == policy
+ assert first.instant_ms + 3_600_000 == second.instant_ms
+ assert first.local.fold == second.local.fold == 0
+ assert first.zone_digest == second.zone_digest
+
+
+@pytest.mark.parametrize("value", [True, -1, MAX_INSTANT_MS + 1, None])
+def test_local_at_rejects_noninstant_or_out_of_range_input(value):
+ times = PinnedRecurrenceTime()
+ with pytest.raises(InvalidInput, match="recurrence_time_out_of_range"):
+ times.local_at(value, TimePolicy("UTC", times.data_identity))
+
+
+def test_actual_local_overflow_is_not_clamped_or_wrapped():
+ times = PinnedRecurrenceTime()
+ with pytest.raises(InvalidInput, match="recurrence_time_out_of_range"):
+ times.local_at(MAX_INSTANT_MS, TimePolicy("Europe/Madrid", times.data_identity))
+
+
+@pytest.mark.parametrize(
+ "change",
+ [
+ {"instant_ms": True},
+ {"instant_ms": -1},
+ {"instant_ms": MAX_INSTANT_MS + 1},
+ {"local": None},
+ {"local": datetime(2026, 1, 1, tzinfo=UTC)},
+ {"local": datetime(2026, 1, 1, microsecond=1)},
+ {"local": datetime(2026, 1, 1, fold=1)},
+ {"offset_seconds": True},
+ {"offset_seconds": 86400},
+ {"policy": None},
+ {"zone_digest": "not-a-hash"},
+ {"instant_ms": 0},
+ ],
+)
+def test_local_facts_require_exact_integer_utc_arithmetic_and_closed_types(change):
+ times = PinnedRecurrenceTime()
+ value = times.local_at(
+ instant(datetime(2026, 1, 1, tzinfo=UTC)), TimePolicy("UTC", times.data_identity)
+ )
+ with pytest.raises(InvalidInput):
+ replace(value, **change)
diff --git a/tests/backend/test_relative_recurrence_planning.py b/tests/backend/test_relative_recurrence_planning.py
new file mode 100644
index 0000000..0c01042
--- /dev/null
+++ b/tests/backend/test_relative_recurrence_planning.py
@@ -0,0 +1,195 @@
+"""Real pinned local-day planning uses the original performed instant, never approval time."""
+
+from dataclasses import replace
+from datetime import UTC, datetime, timedelta
+
+import pytest
+from test_recurrence_local_instant import instant
+from test_relative_recurrence_records import values
+from test_tasks import restricted_cycle
+
+from domus.application.relative_recurrence import RelativeRecurrencePlanner
+from domus.domain.errors import IntegrationUnavailable
+from domus.domain.identity import Role
+from domus.domain.recurrence_planning import PlannedSlot
+from domus.domain.recurrence_records import ScheduleState
+from domus.domain.recurrence_rules import NominalDate
+from domus.domain.recurrence_time import NominalTime, RelativeInterval, TimePolicy
+from domus.domain.relative_recurrence import SuccessorVisibility
+from domus.domain.task_values import TaskPerformer, VerificationState
+from domus.infrastructure.recurrence_time import PinnedRecurrenceTime
+
+
+@pytest.mark.parametrize("timed", [False, True])
+def test_approval_days_later_keeps_the_actual_performed_local_day_and_original_source(timed):
+ schedule, _, _, _ = values(timed)
+ times = PinnedRecurrenceTime()
+ policy = TimePolicy("Europe/Madrid", times.data_identity)
+ initial = NominalTime(datetime(2026, 3, 1, 13))
+ schedule = replace(
+ schedule,
+ policy=policy,
+ interval=RelativeInterval(1),
+ initial=PlannedSlot(initial, times.resolve(initial, policy))
+ if timed
+ else PlannedSlot(NominalDate(initial.local.date()), None),
+ )
+ performed = instant(datetime(2026, 3, 28, 12, 0, 0, 123000, tzinfo=UTC))
+ child = restricted_cycle(performed_at=performed, recorded_at=performed + 1)
+ approved = child.verify(
+ VerificationState.APPROVED, TaskPerformer("adult", Role.ADULT), performed + 5 * 86_400_000
+ )
+ planner = RelativeRecurrencePlanner(times)
+ first = planner.plan(schedule, child)
+ assert first.status == "ready"
+ assert planner.plan(schedule, approved) == first
+ verified = replace(schedule, visibility=SuccessorVisibility.AT_VERIFICATION)
+ assert planner.plan(verified, child).status == "waiting_verification"
+ later = planner.plan(verified, approved)
+ assert (
+ later.status == "ready" and later.source == first.source and later.planned == first.planned
+ )
+ assert first.source.cycle == child and first.source.performed_local.instant_ms == performed
+ if timed:
+ assert first.planned.nominal.local == datetime(2026, 3, 29, 13, 0, 0, 123000)
+ assert first.planned.resolved.instant_ms - performed == 23 * 3_600_000
+ else:
+ assert first.planned.nominal.local == datetime(2026, 3, 29).date()
+ assert first.planned.resolved is None
+
+
+@pytest.mark.parametrize("change", ["paused", "retired", "pending", "rejected", "reopened"])
+def test_ineligible_or_inactive_source_requires_no_provider_work(change):
+ schedule, _, _, completed = values(restricted=True)
+ if change in ("paused", "retired"):
+ schedule = replace(schedule, state=ScheduleState(change))
+ elif change == "pending":
+ schedule = replace(schedule, visibility=SuccessorVisibility.AT_VERIFICATION)
+ elif change == "rejected":
+ completed = completed.verify(
+ VerificationState.REJECTED,
+ TaskPerformer("adult", Role.ADULT),
+ completed.recorded_at + 1,
+ "Repeat",
+ )
+ else:
+ completed = completed.close(completed.recorded_at + 1)
+
+ class Unavailable:
+ def local_at(self, *_):
+ pytest.fail("No provider should be needed for ineligible work")
+
+ result = RelativeRecurrencePlanner(Unavailable()).plan(schedule, completed)
+ assert result.status == (
+ change
+ if change in ("paused", "retired")
+ else "waiting_verification"
+ if change == "pending"
+ else "source_closed"
+ )
+ assert result.planned is None and result.source is None
+
+
+def test_date_only_successor_can_name_a_wholly_missing_local_date_without_inventing_an_instant():
+ schedule, _, _, _ = values(False)
+ times = PinnedRecurrenceTime()
+ policy = TimePolicy("Pacific/Apia", times.data_identity)
+ schedule = replace(schedule, policy=policy, interval=RelativeInterval(1))
+ performed = instant(datetime(2011, 12, 29, 22, tzinfo=UTC))
+ completed = restricted_cycle(performed_at=performed, recorded_at=performed + 1)
+ result = RelativeRecurrencePlanner(times).plan(schedule, completed)
+ assert result.planned.nominal.local == datetime(2011, 12, 30).date()
+ assert result.planned.resolved is None
+
+
+@pytest.mark.parametrize(
+ "change", ["instant", "policy", "digest", "nominal", "type", "local-type", "due-policy"]
+)
+def test_provider_facts_must_match_original_work_and_the_exact_time_policy(change):
+ schedule, _, _, completed = values()
+ times = PinnedRecurrenceTime()
+
+ class Changed:
+ def local_at(self, value, policy):
+ if change == "local-type":
+ return None
+ result = times.local_at(value + 1 if change == "instant" else value, policy)
+ return (
+ replace(result, policy=replace(policy, revision=2))
+ if change == "policy"
+ else result
+ )
+
+ def after_local_days(self, *args):
+ result = times.after_local_days(*args)
+ if change == "due-policy":
+ return replace(result, policy=replace(result.policy, revision=2))
+ if change == "digest":
+ return replace(result, zone_digest="a" * 64)
+ if change == "nominal":
+ return times.resolve(
+ NominalTime(result.nominal.local + timedelta(days=1)), result.policy
+ )
+ return None if change == "type" else result
+
+ with pytest.raises(IntegrationUnavailable, match="recurrence_time_source_invalid"):
+ RelativeRecurrencePlanner(Changed()).plan(schedule, completed)
+
+
+def test_relative_planner_keeps_out_of_range_future_work_explicit():
+ from test_tasks import cycle
+
+ from domus.domain.errors import InvalidInput
+ from domus.domain.recurrence_time import MAX_INSTANT_MS
+
+ schedule, _, _, _ = values(False)
+ completed = cycle(performed_at=MAX_INSTANT_MS, recorded_at=MAX_INSTANT_MS)
+ with pytest.raises(InvalidInput, match="recurrence_time_out_of_range"):
+ RelativeRecurrencePlanner(PinnedRecurrenceTime()).plan(schedule, completed)
+
+
+def test_real_task_lifecycle_facts_plan_the_same_work_after_approval_and_close_after_reopening(
+ tmp_path,
+):
+ from test_task_commands import counts
+ from test_task_events import prepared
+
+ from domus.application.task_commands import ReopenTask, VerifyTask
+ from domus.application.task_events import TaskEventFeed
+ from domus.domain.relative_recurrence import RelativeSlot
+
+ commands, store, _, _, oid, done = prepared(tmp_path, child=True)
+ cid = done.result.decode()["cycle"]["cycle_id"]
+ feed = TaskEventFeed(store, store.installation)
+ first = feed.read().items[0]
+ schedule, _, _, _ = values()
+ schedule = replace(schedule, definition_id=first.definition_id)
+ planner = RelativeRecurrencePlanner(PinnedRecurrenceTime())
+ original = planner.plan(schedule, first.cycle)
+ waiting = replace(schedule, visibility=SuccessorVisibility.AT_VERIFICATION)
+ assert (
+ original.status == "ready"
+ and planner.plan(waiting, first.cycle).status == "waiting_verification"
+ )
+ commands.verify("verify", VerifyTask(oid, 2, cid, VerificationState.APPROVED))
+ with store.read() as unit:
+ actual = unit.tasks.cycle("home", cid)
+ assert planner.plan(schedule, actual) == original
+ verified = planner.plan(waiting, actual)
+ assert verified.source == original.source and verified.planned == original.planned
+ commands.reopen("reopen", ReopenTask(oid, 3, cid, "Repeat work"))
+ before = counts(store)
+ events = feed.read().items
+ for event in events:
+ with store.read() as unit:
+ current = unit.tasks.cycle("home", event.cycle.cycle_id)
+ assert planner.plan(schedule, current).status == "source_closed"
+ assert len(events) == 3 and counts(store) == before
+ # Planning is read-only; this value is not a persisted successor or consumer receipt.
+ slot = RelativeSlot(
+ store.installation, schedule, original.planned, "successor", 100, original.source
+ )
+ assert (
+ slot.source.cycle == first.cycle
+ and slot.planned.resolved.instant_ms == 90 + 30 * 86_400_000
+ )
diff --git a/tests/backend/test_relative_recurrence_records.py b/tests/backend/test_relative_recurrence_records.py
new file mode 100644
index 0000000..e8b1d2c
--- /dev/null
+++ b/tests/backend/test_relative_recurrence_records.py
@@ -0,0 +1,262 @@
+"""Relative obligations preserve actual work, exact source scope and cycle-stable identity."""
+
+from dataclasses import FrozenInstanceError, replace
+from datetime import datetime, timedelta
+
+import pytest
+from test_tasks import cycle, restricted_cycle
+
+from domus.domain.errors import InvalidInput
+from domus.domain.identity import Role
+from domus.domain.recurrence_planning import PlannedSlot
+from domus.domain.recurrence_records import ScheduleState
+from domus.domain.recurrence_rules import NominalDate
+from domus.domain.recurrence_time import LocalInstant, NominalTime, RelativeInterval, TimePolicy
+from domus.domain.relative_recurrence import (
+ RelativeProgress,
+ RelativeSchedule,
+ RelativeSlot,
+ RelativeSource,
+ SuccessorVisibility,
+)
+from domus.domain.storage import Installation
+from domus.domain.task_values import TaskPerformer, VerificationState
+from domus.infrastructure.recurrence_time import PinnedRecurrenceTime
+
+
+def values(timed=True, *, restricted=False):
+ times = PinnedRecurrenceTime()
+ policy = TimePolicy("UTC", times.data_identity)
+ initial_time = NominalTime(datetime(2026, 3, 1, 12))
+ initial = PlannedSlot(
+ initial_time if timed else NominalDate(initial_time.local.date()),
+ times.resolve(initial_time, policy) if timed else None,
+ )
+ schedule = RelativeSchedule(
+ "relative", "home", "definition", initial, RelativeInterval(30), policy, 100
+ )
+ local = times.local_at(1_774_872_000_123, policy)
+ completed = (restricted_cycle if restricted else cycle)(
+ performed_at=local.instant_ms, recorded_at=local.instant_ms + 1
+ )
+ source = RelativeSource.from_cycle(completed, local)
+ due = times.after_local_days(completed.performed_at, schedule.interval, policy)
+ planned = PlannedSlot(
+ due.nominal if timed else NominalDate(due.nominal.local.date()), due if timed else None
+ )
+ slot = RelativeSlot(
+ Installation("00000000-0000-0000-0000-000000000001", "edge"),
+ schedule,
+ planned,
+ "next",
+ local.instant_ms + 100,
+ source,
+ )
+ return schedule, RelativeProgress("relative", "home", 1), slot, completed
+
+
+@pytest.mark.parametrize("timed", [False, True])
+def test_initial_obligation_and_successor_have_distinct_cycle_stable_scoped_keys(timed):
+ schedule, progress, successor, _ = values(timed)
+ initial = RelativeSlot(successor.installation, schedule, schedule.initial, "first", 101)
+ assert initial.key != successor.key and len(initial.key) == len(successor.key) == 64
+ assert initial.command.actor_id == successor.command.actor_id == "system:recurrence"
+ assert initial.command.action == successor.command.action == "recurrence.materialize"
+ assert progress.matches_schedule(schedule) and progress.current_occurrence_id is None
+ paused = replace(schedule, state=ScheduleState.PAUSED, revision=2)
+ assert replace(successor, schedule=paused).key == successor.key
+ assert (
+ replace(successor, occurrence_id="another", created_at=successor.created_at + 1).key
+ == successor.key
+ )
+ assert (
+ replace(
+ successor,
+ source=replace(
+ successor.source, cycle=replace(successor.source.cycle, cycle_id="new-cycle")
+ ),
+ ).key
+ != successor.key
+ )
+ assert (
+ replace(successor, installation=replace(successor.installation, channel="stable")).key
+ != successor.key
+ )
+ assert initial.consistent_with(paused) and successor.consistent_with(paused)
+ with pytest.raises(FrozenInstanceError):
+ schedule.revision = 2
+
+
+@pytest.mark.parametrize("visibility", list(SuccessorVisibility))
+def test_successor_eligibility_observes_verification_without_changing_actual_work(visibility):
+ schedule, _, _, adult = values()
+ schedule = replace(schedule, visibility=visibility)
+ child = restricted_cycle(performed_at=adult.performed_at, recorded_at=adult.recorded_at)
+ approved = child.verify(
+ VerificationState.APPROVED, TaskPerformer("adult", Role.ADULT), child.recorded_at + 100
+ )
+ rejected = child.verify(
+ VerificationState.REJECTED,
+ TaskPerformer("adult", Role.ADULT),
+ child.recorded_at + 100,
+ "Repeat",
+ )
+ assert schedule.allows_successor(adult)
+ assert schedule.allows_successor(child) == (visibility is SuccessorVisibility.AT_COMPLETION)
+ assert schedule.allows_successor(approved)
+ assert not schedule.allows_successor(rejected) and not schedule.allows_successor(
+ adult.close(adult.recorded_at + 1)
+ )
+ assert not replace(schedule, state=ScheduleState.PAUSED).allows_successor(adult)
+
+
+def test_approval_and_reopening_cannot_rewrite_the_original_completion_basis():
+ schedule, _, slot, completed = values(restricted=True)
+ approved = completed.verify(
+ VerificationState.APPROVED, TaskPerformer("adult", Role.ADULT), completed.recorded_at + 100
+ )
+ assert RelativeSource.from_cycle(approved, slot.source.performed_local) == slot.source
+ assert (
+ RelativeSource.from_cycle(
+ approved.close(approved.verified_at + 1), slot.source.performed_local
+ )
+ == slot.source
+ )
+ with pytest.raises(InvalidInput):
+ replace(slot.source, cycle=approved)
+ assert schedule.visibility is SuccessorVisibility.AT_COMPLETION
+
+
+@pytest.mark.parametrize(
+ "field,value",
+ [
+ ("schedule_id", ""),
+ ("household_id", ""),
+ ("definition_id", ""),
+ ("initial", None),
+ ("interval", 30),
+ ("policy", None),
+ ("state", "active"),
+ ("visibility", "at_completion"),
+ ("revision", True),
+ ("pattern_revision", 0),
+ ("pattern_revision", 2),
+ ("created_at", -1),
+ ],
+)
+def test_relative_rules_reject_untyped_or_unscoped_configuration(field, value):
+ with pytest.raises(InvalidInput):
+ replace(values()[0], **{field: value})
+
+
+@pytest.mark.parametrize(
+ "field,value",
+ [
+ ("schedule_id", ""),
+ ("household_id", ""),
+ ("pattern_revision", True),
+ ("source_after", -1),
+ ("source_after", True),
+ ("source_after", 2**63),
+ ("revision", 0),
+ ("last_run_at", -1),
+ ("current_occurrence_id", "next"),
+ ("current_slot_key", "slot"),
+ ],
+)
+def test_progress_never_invents_half_a_current_obligation_or_a_source_position(field, value):
+ with pytest.raises(InvalidInput):
+ replace(values()[1], **{field: value})
+
+
+@pytest.mark.parametrize(
+ "field,value", [("schedule_id", "other"), ("household_id", "other"), ("pattern_revision", 2)]
+)
+def test_relative_progress_requires_the_exact_rule_generation(field, value):
+ schedule, progress, _, _ = values()
+ assert not replace(progress, **{field: value}).matches_schedule(schedule)
+
+
+def test_successor_validates_actual_performed_instant_household_and_local_day_arithmetic():
+ schedule, _, slot, _ = values()
+ with pytest.raises(InvalidInput):
+ replace(
+ slot,
+ source=replace(
+ slot.source,
+ performed_local=replace(
+ slot.source.performed_local, policy=replace(schedule.policy, revision=2)
+ ),
+ ),
+ )
+ with pytest.raises(InvalidInput):
+ replace(
+ slot.source,
+ cycle=replace(slot.source.cycle, performed_at=slot.source.cycle.performed_at - 1),
+ )
+ with pytest.raises(InvalidInput):
+ replace(
+ slot,
+ source=replace(slot.source, cycle=replace(slot.source.cycle, household_id="other")),
+ )
+ with pytest.raises(InvalidInput):
+ replace(slot, occurrence_id=slot.source.cycle.occurrence_id)
+ with pytest.raises(InvalidInput):
+ replace(slot, created_at=100)
+ wrong = NominalTime(slot.planned.nominal.local + timedelta(days=1))
+ with pytest.raises(InvalidInput):
+ replace(
+ slot, planned=PlannedSlot(wrong, PinnedRecurrenceTime().resolve(wrong, schedule.policy))
+ )
+
+
+def test_relative_slot_rejects_wrong_initial_due_and_future_or_contradictory_generations():
+ schedule, _, slot, _ = values()
+ with pytest.raises(InvalidInput):
+ replace(slot, source=None)
+ assert slot.consistent_with(
+ replace(schedule, interval=RelativeInterval(31), pattern_revision=2, revision=2)
+ )
+ assert not slot.consistent_with(replace(schedule, interval=RelativeInterval(31)))
+ assert not slot.consistent_with(replace(schedule, definition_id="other"))
+ assert not replace(slot, schedule=replace(schedule, revision=2)).consistent_with(schedule)
+
+
+def test_epoch_local_fact_may_precede_1970_without_accepting_a_user_nominal_before_1970():
+ policy = TimePolicy("America/New_York", PinnedRecurrenceTime().data_identity)
+ local = LocalInstant(0, datetime(1969, 12, 31, 19), -18000, policy, "a" * 64)
+ assert local.instant_ms == 0
+ with pytest.raises(InvalidInput):
+ NominalTime(local.local)
+
+
+@pytest.mark.parametrize("source", [None, "cycle"])
+def test_relative_source_and_eligibility_refuse_untyped_or_foreign_cycles(source):
+ schedule, _, slot, _ = values()
+ with pytest.raises(InvalidInput):
+ RelativeSource.from_cycle(source, slot.source.performed_local)
+ with pytest.raises(InvalidInput):
+ schedule.allows_successor(source)
+ with pytest.raises(InvalidInput):
+ schedule.allows_successor(replace(slot.source.cycle, household_id="other"))
+
+
+@pytest.mark.parametrize(
+ "field,value",
+ [("installation", None), ("schedule", None), ("planned", None), ("source", "cycle")],
+)
+def test_relative_slots_never_coerce_untyped_records(field, value):
+ with pytest.raises(InvalidInput):
+ replace(values()[2], **{field: value})
+
+
+def test_relative_sources_fail_when_local_calendar_addition_overflows():
+ from domus.domain.recurrence_time import MAX_INSTANT_MS
+
+ schedule, _, slot, _ = values(False)
+ local = PinnedRecurrenceTime().local_at(MAX_INSTANT_MS, schedule.policy)
+ source = RelativeSource.from_cycle(
+ cycle(performed_at=MAX_INSTANT_MS, recorded_at=MAX_INSTANT_MS), local
+ )
+ with pytest.raises(InvalidInput, match="recurrence_time_out_of_range"):
+ replace(slot, source=source, created_at=MAX_INSTANT_MS)
diff --git a/tests/containers/test_recurrence_image.py b/tests/containers/test_recurrence_image.py
index 237fcb2..28ca68d 100644
--- a/tests/containers/test_recurrence_image.py
+++ b/tests/containers/test_recurrence_image.py
@@ -28,14 +28,18 @@ def test_current_image_executes_recurrence_creation_planning_and_closed_record_c
from domus.application.household_commands import HouseholdCommands, SetupHousehold
from domus.application.recurrence_commands import CreateFixedTask, RecurrenceCommands
from domus.application.task_requests import CreateTask
+from domus.application.task_commands import TaskCommands, CompleteTask
+from domus.application.task_events import TaskEventFeed
+from domus.application.relative_recurrence import RelativeRecurrencePlanner
from domus.composition import ingress, server
from domus.domain.errors import InvalidInput, StorageBusy, StorageFailure
from domus.domain.recurrence_frontier import RecurrenceFrontier
+from domus.domain.relative_recurrence import RelativeSchedule
from domus.domain.identity import HumanIdentity
from domus.domain.nominal_recurrence import CalendarNominalExpansion
from domus.domain.recurrence_records import FixedProgress, FixedSchedule, ScheduledSlot
from domus.domain.recurrence_rule_text import parse_fixed_pattern
-from domus.domain.recurrence_time import NominalTime, TimePolicy, TimeResolution
+from domus.domain.recurrence_time import NominalTime, TimePolicy, TimeResolution, RelativeInterval
from domus.domain.storage import Installation
from domus.domain.task_values import TaskContent
from domus.infrastructure.application_runtime import SecureIdentifiers, SystemClock, TimeZoneCatalog
@@ -125,6 +129,20 @@ def get(self, user_id):
RunLimits(max_slots=1))
assert fair.tick().created == 1
assert fair.tick().created == 1
+ with store.read() as unit:
+ member = unit.members.by_user('home', human.current().ha_user_id)
+ work = unit.tasks.occurrence('home', first_step.occurrence_id)
+ performed = clock.now()
+ TaskCommands(store, human, clock, identifiers).complete('perform-planning-control',
+ CompleteTask(work.occurrence_id, work.revision, (member.member_id,), performed))
+ source = TaskEventFeed(store, installation).read().items[0]
+ relative = RelativeSchedule('relative-plan-control', 'home', source.definition_id,
+ planned.slots[0], RelativeInterval(30), policy, 100)
+ relative_plan = RelativeRecurrencePlanner(times).plan(relative, source.cycle)
+ assert relative_plan.status == 'ready'
+ assert relative_plan.source.cycle == source.cycle
+ assert relative_plan.source.performed_local.instant_ms == performed
+ # This is a packaged read-only plan, not relative persistence or materialization.
with store.read() as unit:
assert len(unit.tasks.occurrences('home')) == 4
assert unit.recurrence.frontier('home') is not None
From b2bcc693d6be5065b1be8dc378334199c927735a Mon Sep 17 00:00:00 2001
From: Efra Espada
Date: Mon, 2 Nov 2026 11:04:10 +0100
Subject: [PATCH 11/16] fix: Bind durable recurrence cursors to their consumed
nominal rank
---
.../src/domus/domain/recurrence_membership.py | 38 ++++-
.../src/domus/domain/recurrence_records.py | 3 +-
.../domus/infrastructure/sqlite_recurrence.py | 7 +-
docs/architecture-recurrence.md | 20 +++
.../evidence/recurrence-cursor-integrity.json | 113 ++++++++++++++
docs/evidence/recurrence-storage.json | 2 +-
specs/recurrence-storage-continuation.md | 6 +
tests/backend/test_fixed_materialization.py | 7 +-
.../test_recurrence_cursor_integrity.py | 142 ++++++++++++++++++
tests/backend/test_recurrence_records.py | 1 +
tests/containers/test_recurrence_image.py | 25 ++-
11 files changed, 358 insertions(+), 6 deletions(-)
create mode 100644 docs/evidence/recurrence-cursor-integrity.json
create mode 100644 tests/backend/test_recurrence_cursor_integrity.py
diff --git a/backend/src/domus/domain/recurrence_membership.py b/backend/src/domus/domain/recurrence_membership.py
index 8f7e780..c0dde37 100644
--- a/backend/src/domus/domain/recurrence_membership.py
+++ b/backend/src/domus/domain/recurrence_membership.py
@@ -5,7 +5,8 @@
from math import gcd
from domus.domain.nominal_recurrence import CalendarNominalExpansion
-from domus.domain.recurrence_planning import PlannedFixedSlot
+from domus.domain.recurrence_periods import period_bounds
+from domus.domain.recurrence_planning import FixedCursor, PlannedFixedSlot
from domus.domain.recurrence_rules import FixedPattern, Frequency, NominalDate, slot_datetime
@@ -90,3 +91,38 @@ def accepts_fixed_slot(pattern: FixedPattern, planned: PlannedFixedSlot) -> bool
return (
pattern.count is None or _rank(pattern, index, candidates.index(nominal)) <= pattern.count
)
+
+
+def accepts_fixed_cursor(pattern: FixedPattern, cursor: FixedCursor) -> bool:
+ """Consumed nominal rank includes truncated DTSTART and empty original periods."""
+ bounds = period_bounds(pattern, cursor.period_index)
+ if bounds is None and period_bounds(pattern, max(0, cursor.period_index - 1)) is None:
+ return False
+ candidates = CalendarNominalExpansion().period(pattern, cursor.period_index).slots
+ if cursor.offset > len(candidates):
+ return False
+ if isinstance(pattern.until, NominalDate):
+ if cursor.offset:
+ if slot_datetime(candidates[cursor.offset - 1]).date() > pattern.until.local:
+ return False
+ elif cursor.period_index:
+ previous_bounds = period_bounds(pattern, cursor.period_index - 1)
+ assert previous_bounds is not None # Valid periods form one contiguous prefix.
+ previous = CalendarNominalExpansion().period(pattern, cursor.period_index - 1).slots
+ if previous_bounds[0].date() > pattern.until.local or (
+ previous and slot_datetime(previous[-1]).date() > pattern.until.local
+ ):
+ return False
+ consumed = _rank(pattern, cursor.period_index, 0) - 1 + cursor.offset
+ if consumed != cursor.nominal_count:
+ return False
+ if not cursor.closed or bounds is None:
+ return True
+ if pattern.count is not None:
+ return consumed == pattern.count
+ if isinstance(pattern.until, NominalDate):
+ return bounds[0].date() > pattern.until.local or (
+ cursor.offset < len(candidates)
+ and slot_datetime(candidates[cursor.offset]).date() > pattern.until.local
+ )
+ return pattern.until is not None
diff --git a/backend/src/domus/domain/recurrence_records.py b/backend/src/domus/domain/recurrence_records.py
index 1f1b045..dbb2d24 100644
--- a/backend/src/domus/domain/recurrence_records.py
+++ b/backend/src/domus/domain/recurrence_records.py
@@ -5,7 +5,7 @@
from domus.domain.errors import InvalidInput
from domus.domain.identity import validate_revision
-from domus.domain.recurrence_membership import accepts_fixed_slot
+from domus.domain.recurrence_membership import accepts_fixed_cursor, accepts_fixed_slot
from domus.domain.recurrence_planning import FixedCursor, PlannedFixedSlot, cursor_binding
from domus.domain.recurrence_rules import FixedPattern, NominalDate
from domus.domain.recurrence_time import TimePolicy
@@ -83,6 +83,7 @@ def matches_schedule(self, schedule: FixedSchedule) -> bool:
schedule.pattern.count is None
or self.cursor.nominal_count <= schedule.pattern.count
)
+ and accepts_fixed_cursor(schedule.pattern, self.cursor)
)
diff --git a/backend/src/domus/infrastructure/sqlite_recurrence.py b/backend/src/domus/infrastructure/sqlite_recurrence.py
index b69ba70..365f917 100644
--- a/backend/src/domus/infrastructure/sqlite_recurrence.py
+++ b/backend/src/domus/infrastructure/sqlite_recurrence.py
@@ -150,7 +150,7 @@ def schedule(self, household_id: str, schedule_id: str) -> FixedSchedule | None:
def progress(self, household_id: str, schedule_id: str) -> FixedProgress | None:
validate_identifier(household_id)
validate_identifier(schedule_id)
- return _model(
+ value = _model(
self.connection.execute(
"SELECT * FROM recurrence_progress WHERE household_id=? AND schedule_id=?",
(household_id, schedule_id),
@@ -158,6 +158,11 @@ def progress(self, household_id: str, schedule_id: str) -> FixedProgress | None:
read_progress,
progress_headers,
)
+ if value is not None:
+ rule = self.schedule(household_id, schedule_id)
+ if rule is None or not value.matches_schedule(rule):
+ raise StorageFailure("invalid_recurrence_record")
+ return value
def slot(self, household_id: str, slot_key: str) -> ScheduledSlot | None:
validate_identifier(household_id)
diff --git a/docs/architecture-recurrence.md b/docs/architecture-recurrence.md
index c8192ec..081638d 100644
--- a/docs/architecture-recurrence.md
+++ b/docs/architecture-recurrence.md
@@ -281,3 +281,23 @@ fixed rule membership and materialization remain unchanged. The
facts and preserved task/evidence counts, not a persisted successor. Relative storage, original
proof/recovery, durable cycle closure and invalidation, global mixed-rule fairness and production
integration remain required. This increment changes no SQLite layout or owner HA installation.
+
+## Fixed cursor rank integrity
+
+A correct binding and a plausible count are insufficient for a durable continuation. The
+[shared pure membership logic](../backend/src/domus/domain/recurrence_membership.py) now checks
+that period_index/offset describe the exact consumed nominal prefix. The bounded Gregorian-cycle
+calculation retains interval phase, DTSTART truncation, omitted dates and genuinely empty periods.
+A position beyond the first exhausted calendar period or an offset beyond its actual candidates
+is invalid. Count/date exhaustion and date-only UNTIL consumption also retain their original bounds.
+
+[Repository progress reads](../backend/src/domus/infrastructure/sqlite_recurrence.py) verify the
+rule relationship as well as canonical row headers. Writes, materialization and checkpoint
+inspection share it; malformed progress is refused without repairing bytes or generating work.
+These relations prove cursor consistency, not original effect processing, caller authority or
+calendar completeness. Instant-UNTIL resolution retains the existing pinned provider boundary.
+
+The [cursor evidence](evidence/recurrence-cursor-integrity.json) includes a replay of the meaningful
+negative tests against every byte-verified original059 module, real canonical-row corruption and
+genuine sparse/end continuations. The original test fixture binding mismatch was corrected
+explicitly. No historical source or fixture bytes, SQLite layout or owner installation changed.
diff --git a/docs/evidence/recurrence-cursor-integrity.json b/docs/evidence/recurrence-cursor-integrity.json
new file mode 100644
index 0000000..d1047a8
--- /dev/null
+++ b/docs/evidence/recurrence-cursor-integrity.json
@@ -0,0 +1,113 @@
+{
+ "status": "partial; fixed cursor position/rank integrity corrected; full retained recurrence execution remains in progress",
+ "recordedAtUTC": "2026-11-02T10:03:05.140880+00:00",
+ "issue": "https://github.com/vypdev/domus/issues/20",
+ "pullRequest": "https://github.com/vypdev/domus/pull/22",
+ "sourceBase": "05951170603132fa17db92b49b1174d4fefa2708",
+ "backendSourceInventorySHA256": "ba4c64c5914ad967e5c254fa448830a2df54fd605230b55bf39f9522f6d2d8cc",
+ "sourceManifestMethod": "SHA256 of compact sorted JSON mapping every authored Python module path, including package initializers, to its byte SHA256 value.",
+ "finding": {
+ "url": "https://github.com/vypdev/domus/pull/22#discussion_r4440784569",
+ "id": "recurrence_records:progress-cursor-position-not-bound-to-count",
+ "assessment": "Valid; corrected in source, fresh native confirmation required. Not dismissed or reported as a Copilot finding defect."
+ },
+ "implemented": [
+ "FixedProgress binds its period_index/offset to the exact consumed original nominal prefix, beyond matching binding and COUNT upper bound.",
+ "The pure bounded Gregorian-cycle/interval-phase rank handles truncated DTSTART, omitted invalid dates and genuinely empty original periods; arbitrary far-out calendar positions are refused before rank work.",
+ "Offsets beyond the actual period, invalid count/date exhaustion and consumed dates beyond date-only UNTIL are refused; an unlimited rule cannot close inside its representable calendar.",
+ "Repository progress reads now validate the current rule relationship. Writes, materialization source reads and snapshot inspection share it and do not normalize/repair bad stored bytes.",
+ "The existing nominal-count fixture now identifies one actually consumed candidate with offset1; a wrong stored binding fails precisely at the earlier strict record boundary."
+ ],
+ "testsFirst": {
+ "initialFixtureCorrection": "An initial four-frequency fixture mixed a pinned policy with a fixture policy. The corrected binding was replayed against the exact published059 wheel; all132 module bytes were independently matched to its original source inventory. No patched original code or weakened assertion was used.",
+ "originalSourceEvidence": {
+ "source": "05951170603132fa17db92b49b1174d4fefa2708",
+ "backendManifest": "0b1ae506eb98d959de6776d7ffea8f1324c381701ab9e86ed5078d85617ff76c",
+ "originalWheelSHA256": "a42cfd338ab44d06ef1ca7e1a0d1dbce5b2cb6cc84e73976b834fd59133aa1ce",
+ "all132ModulesByteVerified": true
+ },
+ "originalFinalNegativeReplay": "20 failures and7 positive controls against the exact published original runtime; complete original output retained.",
+ "currentFocused": "All27 new cursor tests pass, including real canonical storage corruption at direct-read/write/checkpoint/materialization boundaries."
+ },
+ "completeBackend": {
+ "executions": 3810,
+ "durationSeconds": 97.26,
+ "authoredModules": 132,
+ "coverageTotals": {
+ "covered_lines": 9439,
+ "num_statements": 9439,
+ "percent_covered": 100.0,
+ "percent_covered_display": "100.00",
+ "missing_lines": 0,
+ "excluded_lines": 274,
+ "percent_statements_covered": 100.0,
+ "percent_statements_covered_display": "100.00",
+ "num_branches": 2702,
+ "num_partial_branches": 0,
+ "covered_branches": 2702,
+ "missing_branches": 0,
+ "percent_branches_covered": 100.0,
+ "percent_branches_covered_display": "100.00"
+ },
+ "independentGlobalAndPerModuleAudit": "passed"
+ },
+ "focusedRegression": {
+ "executions": 282,
+ "durationSeconds": 8.8,
+ "authoredModules": 3,
+ "statements": 265,
+ "branches": 96,
+ "coveragePercent": 100
+ },
+ "checks": {
+ "strictTypes": "passed;132 modules",
+ "ruff": "check/format passed;334 files",
+ "architecture": "3 contracts kept,0 broken;176 files/1136 dependencies",
+ "originalPinnedSdd": {
+ "upstream": "3e7ceea08e6131360e026937d1c0cc1b120472e0",
+ "base": "0806a38f8a2de1f102a017bff25ad6856fb17bcd",
+ "selected": "specs/recurrence-storage-continuation.md",
+ "originalPolicyModulesVerified": 7,
+ "format": "PASS",
+ "consistency": "PASS",
+ "composition": "explicit contributor integration; original publication retained in Git",
+ "unchangedCatalog": "PASS",
+ "immutableOwnerDocumentsUsed": 18,
+ "retainedCompanionCases": 52,
+ "newCases": 4,
+ "rawCharacters": 34660,
+ "renderedCharacters": 34660,
+ "composedCharacters": 34660,
+ "applicableDraftMaximumCharacters": 70000,
+ "sha256": "650da5217fcacea8cce54f144365bc1fc4d61bfac6337864018e05bfc7a943b0",
+ "scope": "Review input only; no gate admission, branch publication, source implementation or runtime acceptance.",
+ "retainedOwningFilesUnchanged": true,
+ "workingDocumentValidated": true
+ },
+ "freshPackageAndArchitecture": {
+ "executions": 52,
+ "durationSeconds": 6.85,
+ "result": "passed"
+ }
+ },
+ "actualBoundaryObservations": [
+ "A canonical header-consistent progress record with the original binding and count0 cannot point100 daily periods ahead; read, write, step and snapshot all refuse it without task creation or byte repair.",
+ "Actual bounded progress remains valid across sparse monthly31, weekly interval/week-start truncation, leap-day yearly, impossible empty periods, COUNT, date UNTIL and the final calendar day.",
+ "Date-ended multi-slot periods cannot consume excluded later dates or falsely advance past their original end; the first genuine exhausted calendar period stays valid."
+ ],
+ "imageControls": {
+ "exactCurrentScriptOnHost": "Current-source recurrence, both owned runtimes and pinned-time codecs passed.",
+ "scope": "Actual SQLite fixed/fair and native/standalone lifetime controls, relative read-only planning, then a canonical altered real progress row rejected by direct read and actual checkpoint without source-byte repair.",
+ "freshHosted": "Required after publication; host execution is not CPU-image proof.",
+ "prior059": "All applicable product CI and both real CPU images passed at059. Its native review completed9/9 partitions,95 fragments and84 files with this one valid open cursor finding and2 previously fixed findings."
+ },
+ "limits": "Cursor consistency does not authenticate a caller, prove original processed effects or establish complete calendar freshness. Closed instant-UNTIL resolution retains the existing pinned provider contract; no new timezone-provider I/O is added to domain records.",
+ "copilot": "Progress error18107c69-3e73-42dd-9d71-09238a19dbff and partial human context4 fetched/3 retained/1 omitted remain reported in upstream851/843. No Copilot source/configuration/credential/bound changed.",
+ "compatibility": "No SQLite layout/encoding, public HTTP contract, timeout, app version or owner HA installation changed. Genuine historical fixtures and Edge.5/schema7 remain untouched.",
+ "rawEvidence": {
+ "privateCampaign": "/private/tmp/domus-cursor-0595117",
+ "originalWheelConsumer": "Original059 wheel remains in the prior relative-phase compact archive for byte-identical replay; the duplicate extracted runtime can be retired after retaining these original measurements.",
+ "consumer": "Current PR22 cursor review: original implementation and final negative/positive outputs, full/focused coverage, exact source inventory, SDK/package and current image-host controls."
+ },
+ "pending": "Fresh CI/native review and complete relative persistence/initial creation/source inbox/closure/invalidation/restore/global fairness/runtime wiring; protected calendar/operation APIs, reviewed schedule changes and the full retained52+37 cases and first all-domain Stable/Edge release remain required."
+}
diff --git a/docs/evidence/recurrence-storage.json b/docs/evidence/recurrence-storage.json
index c4bc260..d59f11a 100644
--- a/docs/evidence/recurrence-storage.json
+++ b/docs/evidence/recurrence-storage.json
@@ -85,7 +85,7 @@
"policyModulesByteVerified": 7,
"immutableBaseOwners": 18,
"retainedOwnersUnchanged": true,
- "currentCompanionSHA256": "fed3d9b88e9d4b1aea507e80a8da61f41e30ce64b5a3f934989644b76a1646be",
+ "currentCompanionSHA256": "650da5217fcacea8cce54f144365bc1fc4d61bfac6337864018e05bfc7a943b0",
"scope": "Explicit contributor integration; original first publication remains in Git. Format, reciprocal inventory/path consistency and current catalog pass."
},
"packageAndArchitectureTests": {
diff --git a/specs/recurrence-storage-continuation.md b/specs/recurrence-storage-continuation.md
index d2aebbc..512d1b3 100644
--- a/specs/recurrence-storage-continuation.md
+++ b/specs/recurrence-storage-continuation.md
@@ -180,6 +180,7 @@ Use real independent SQLite connections and exact before/after version/row check
| tests/backend/test_relative_recurrence_records.py | create |
| tests/backend/test_relative_recurrence_planning.py | create |
| tests/backend/test_recurrence_local_instant.py | create |
+| tests/backend/test_recurrence_cursor_integrity.py | create |
| tests/backend/test_recurrence_slot_membership.py | create |
| tests/backend/test_sqlite_recurrence_records.py | create |
| tests/backend/test_sqlite_recurrence_integrity.py | create |
@@ -204,6 +205,7 @@ Use real independent SQLite connections and exact before/after version/row check
| docs/evidence/recurrence-lifecycle.json | create |
| docs/evidence/recurrence-review-corrections.json | create |
| docs/evidence/recurrence-relative-planning.json | create |
+| docs/evidence/recurrence-cursor-integrity.json | create |
| docs/tasks/migration.md | update |
| docs/evidence/recurrence-execution.json | update |
@@ -257,6 +259,10 @@ TaskCycle supplies its original completion snapshot through one shared pure help
The shared nominal/resolved PlannedSlot value retains PlannedFixedSlot as a source-compatible name; its encoding and existing fixed rule membership checks remain unchanged. Actual SQLite task lifecycle reads exercise pending, approved and reopened source facts without persisting a relative plan or generating an event. The future durable consumer must revalidate the live cycle and its own retained inbox/closure before command receipt lookup and commit; feeding an old event snapshot alone is insufficient. Relative rule/progress/slot storage, atomic initial creation, original-proof reconciliation, durable source/inbox closure, successor invalidation, global mixed-rule fairness and runtime wiring remain required. No SQLite layout or owner installation is changed by this planning increment.
+Further native review identified that a correctly bound fixed cursor could disagree with its consumed nominal rank. Validate the exact original candidate prefix represented by period_index and offset before accepting fixed progress. Reuse the finite Gregorian-cycle/interval-phase rank logic, preserving truncated DTSTART, omitted invalid dates and genuinely empty periods. Reject offsets beyond a period and positions beyond the first exhausted calendar period; do not traverse arbitrary prior history. Count-ended and date-ended cursors must not claim early exhaustion or consume dates beyond their end. An unlimited rule cannot be marked closed inside its representable calendar. Repository progress reads recheck the rule relationship as well as canonical headers, and snapshot validation shares it. These relations establish cursor consistency, not caller authority, original effect proof or complete calendar freshness.
+
+Preserve a real source059 wheel identity when replaying the original negative regression against the prior implementation. An initial test fixture accidentally mixed a real pinned policy with a fixture policy; correct that binding and verify the meaningful original failures against every byte-verified published module. Keep genuine sparse/terminal continuations as positive controls. The new strict read exposes an invalid stored binding as invalid_recurrence_record before the higher-level source check; keep a precise failure assertion for that earlier boundary. These are refinements of retained progress/record integrity, not extra counted product journeys.
+
The worker carries cancellation into every run and joins before runtime identity/storage disposal and installation-lease release. The lifespan preserves the existing application's state and exits its prior context after stopping the worker, including on startup/application failure. Ordinary failures expose only bounded categories and permit a later attempt; a denied technical scope terminates that worker. Last-run observations remain process-local diagnostics, not durable calendar freshness or delivery proof. Identity-only preview has no worker. Actual current-source image controls exercise both ASGI lifetimes, scoped SQLite work and shutdown. Relative lifecycle, current-authorized calendar/command surfaces, durable attention, configurable household timing defaults and complete product/UI/HA acceptance remain required.
## 19. Definition of Done
diff --git a/tests/backend/test_fixed_materialization.py b/tests/backend/test_fixed_materialization.py
index 5a385b8..161ac2d 100644
--- a/tests/backend/test_fixed_materialization.py
+++ b/tests/backend/test_fixed_materialization.py
@@ -303,7 +303,12 @@ def test_inconsistent_durable_source_is_not_an_empty_or_successful_step(tmp_path
"UPDATE recurrence_progress SET document=?",
(progress_document(replace(progress, cursor=FixedCursor("a" * 64))).encoded,),
)
- with pytest.raises(StorageFailure, match="invalid_recurrence_source"):
+ with pytest.raises(
+ StorageFailure,
+ match="invalid_recurrence_record"
+ if change == "wrong-binding"
+ else "invalid_recurrence_source",
+ ):
service.step(rule_id, before)
diff --git a/tests/backend/test_recurrence_cursor_integrity.py b/tests/backend/test_recurrence_cursor_integrity.py
new file mode 100644
index 0000000..cc527ca
--- /dev/null
+++ b/tests/backend/test_recurrence_cursor_integrity.py
@@ -0,0 +1,142 @@
+"""Persisted cursor position and consumed nominal rank cannot contradict each other."""
+
+from dataclasses import replace
+from datetime import date
+
+import pytest
+from test_fixed_materialization import configured
+from test_fixed_recurrence_planning import pattern, planner, policy
+from test_recurrence_records import values
+from test_sqlite_snapshots import Clock
+
+from domus.domain.errors import Conflict, DatabaseRejected, StorageFailure
+from domus.domain.recurrence_planning import FixedCursor, cursor_binding
+from domus.domain.recurrence_records import FixedProgress
+from domus.domain.recurrence_rules import NominalDate
+from domus.infrastructure.sqlite_recurrence_records import progress_document
+from domus.infrastructure.sqlite_snapshots import SQLiteSnapshots
+
+
+@pytest.mark.parametrize(
+ "raw",
+ [
+ "FREQ=DAILY",
+ "FREQ=WEEKLY;BYDAY=MO,TH",
+ "FREQ=MONTHLY;BYMONTHDAY=1,2",
+ "FREQ=YEARLY;BYMONTH=1,2;BYMONTHDAY=1",
+ ],
+)
+def test_a_correct_binding_and_plausible_count_cannot_hide_skipped_original_periods(raw):
+ value = pattern(raw)
+ first = planner().expand(value, policy(), slot_limit=1)
+ rule = replace(values()[0], pattern=value, policy=policy())
+ original = FixedProgress(rule.schedule_id, rule.household_id, 1, first.cursor)
+ assert original.matches_schedule(rule)
+ changed = replace(
+ original, cursor=replace(first.cursor, period_index=first.cursor.period_index + 10)
+ )
+ assert not changed.matches_schedule(rule)
+
+
+@pytest.mark.parametrize(
+ "cursor",
+ [
+ {"period_index": 10},
+ {"offset": 1},
+ {"offset": 2},
+ {"period_index": 2**63 - 1},
+ {"period_index": 1, "nominal_count": 0},
+ {"closed": True},
+ ],
+)
+def test_initial_progress_cannot_claim_a_false_position_or_an_unlimited_closed_rule(cursor):
+ rule, progress, _ = values()
+ proposed = replace(
+ progress,
+ cursor=replace(progress.cursor, **cursor),
+ range_complete=bool(cursor.get("closed")),
+ )
+ assert not proposed.matches_schedule(rule)
+
+
+@pytest.mark.parametrize("mode", ["read", "write", "snapshot", "step"])
+def test_canonical_cursor_corruption_is_refused_without_work_or_byte_repair(tmp_path, mode):
+ service, store, _, _, rule_id, before = configured(tmp_path, count=None)
+ with store.read() as unit:
+ original = unit.recurrence.progress("home", rule_id)
+ altered = replace(original, cursor=replace(original.cursor, period_index=100))
+ if mode == "write":
+ with pytest.raises(Conflict, match="recurrence_cursor_mismatch"):
+ with store.write() as unit:
+ unit.recurrence.save_progress(replace(altered, revision=2), 1)
+ with store.read() as unit:
+ assert unit.recurrence.progress("home", rule_id) == original
+ return
+ with store._connection() as connection:
+ connection.execute(
+ "UPDATE recurrence_progress SET document=?", (progress_document(altered).encoded,)
+ )
+ source = store.path.read_bytes()
+ if mode == "read":
+ with store.read() as unit:
+ with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
+ unit.recurrence.progress("home", rule_id)
+ elif mode == "snapshot":
+ with pytest.raises(DatabaseRejected, match="invalid_snapshot_records"):
+ SQLiteSnapshots(store, tmp_path / "bad", store.installation, Clock()).create("bad")
+ else:
+ with pytest.raises(StorageFailure):
+ service.step(rule_id, before)
+ assert store.path.read_bytes() == source
+ with store.read() as unit:
+ assert unit.tasks.occurrences("home") == () and unit.recurrence.slots("home", rule_id) == ()
+
+
+@pytest.mark.parametrize(
+ "raw,start",
+ [
+ ("FREQ=MONTHLY;BYMONTHDAY=31", date(2026, 1, 31)),
+ ("FREQ=WEEKLY;BYDAY=MO,WE,SU;INTERVAL=3", date(2026, 1, 7)),
+ ("FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29", date(2024, 2, 29)),
+ ("FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=30", date(2026, 1, 1)),
+ ("FREQ=DAILY;COUNT=2", date(2026, 1, 1)),
+ ("FREQ=DAILY;UNTIL=20260103", date(2026, 1, 1)),
+ ("FREQ=DAILY", date(9999, 12, 31)),
+ ],
+)
+def test_actual_bounded_progress_remains_valid_through_empty_periods_count_until_and_exhaustion(
+ raw, start
+):
+ value = pattern(raw, NominalDate(start))
+ rule = replace(values()[0], pattern=value)
+ cursor = FixedCursor(cursor_binding(value, rule.policy))
+ for _ in range(6):
+ batch = planner().expand(value, rule.policy, cursor=cursor, slot_limit=1, period_limit=2)
+ cursor = batch.cursor
+ assert FixedProgress(
+ rule.schedule_id, rule.household_id, 1, cursor, range_complete=batch.range_complete
+ ).matches_schedule(rule)
+ if cursor.closed:
+ break
+
+
+@pytest.mark.parametrize(
+ "raw,index,offset,count",
+ [
+ ("FREQ=DAILY;COUNT=5", 0, 1, 1),
+ ("FREQ=DAILY;UNTIL=20260103", 0, 0, 0),
+ ("FREQ=DAILY;UNTIL=20260103", 4, 1, 5),
+ ("FREQ=MONTHLY;BYMONTHDAY=1,2,3;UNTIL=20260102", 0, 3, 3),
+ ("FREQ=MONTHLY;BYMONTHDAY=1,2,3;UNTIL=20260102", 1, 0, 3),
+ ("FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=30;UNTIL=20260103", 2, 0, 0),
+ ],
+)
+def test_correct_nominal_rank_does_not_allow_false_count_or_date_until_exhaustion(
+ raw, index, offset, count
+):
+ value = pattern(raw)
+ rule = replace(values()[0], pattern=value)
+ cursor = FixedCursor(cursor_binding(value, rule.policy), index, offset, count, True)
+ assert not FixedProgress(
+ rule.schedule_id, rule.household_id, 1, cursor, range_complete=True
+ ).matches_schedule(rule)
diff --git a/tests/backend/test_recurrence_records.py b/tests/backend/test_recurrence_records.py
index 102e6dc..db3b588 100644
--- a/tests/backend/test_recurrence_records.py
+++ b/tests/backend/test_recurrence_records.py
@@ -153,6 +153,7 @@ def test_record_relationships_bind_scope_count_and_current_or_historical_generat
progress.cursor,
binding=cursor_binding(limited.pattern, limited.policy),
nominal_count=1,
+ offset=1,
),
)
assert end.matches_schedule(limited)
diff --git a/tests/containers/test_recurrence_image.py b/tests/containers/test_recurrence_image.py
index 28ca68d..cd61f83 100644
--- a/tests/containers/test_recurrence_image.py
+++ b/tests/containers/test_recurrence_image.py
@@ -32,7 +32,7 @@ def test_current_image_executes_recurrence_creation_planning_and_closed_record_c
from domus.application.task_events import TaskEventFeed
from domus.application.relative_recurrence import RelativeRecurrencePlanner
from domus.composition import ingress, server
-from domus.domain.errors import InvalidInput, StorageBusy, StorageFailure
+from domus.domain.errors import DatabaseRejected, InvalidInput, StorageBusy, StorageFailure
from domus.domain.recurrence_frontier import RecurrenceFrontier
from domus.domain.relative_recurrence import RelativeSchedule
from domus.domain.identity import HumanIdentity
@@ -45,6 +45,7 @@ def test_current_image_executes_recurrence_creation_planning_and_closed_record_c
from domus.infrastructure.application_runtime import SecureIdentifiers, SystemClock, TimeZoneCatalog
from domus.infrastructure.recurrence_time import PinnedRecurrenceTime, TIMEZONE_DATA_IDENTITY
from domus.infrastructure.sqlite import SCHEMA_VERSION, SQLiteDatabase
+from domus.infrastructure.sqlite_snapshots import SQLiteSnapshots
from domus.infrastructure.sqlite_recurrence_records import (
schedule_document, read_schedule, progress_document, read_progress, slot_document, read_slot,
frontier_document, read_frontier,
@@ -186,6 +187,28 @@ async def serve_once(runtime):
except StorageBusy:
pass
holder.execute('ROLLBACK')
+ with store.read() as unit:
+ original_progress = unit.recurrence.progress('home', facts['schedule_id'])
+ altered = replace(original_progress,
+ cursor=replace(original_progress.cursor, period_index=original_progress.cursor.period_index+100))
+ with store._connection() as connection:
+ connection.execute('UPDATE recurrence_progress SET document=? WHERE schedule_id=?',
+ (progress_document(altered).encoded, facts['schedule_id']))
+ source_bytes = store.path.read_bytes()
+ try:
+ with store.read() as unit:
+ unit.recurrence.progress('home', facts['schedule_id'])
+ except StorageFailure:
+ pass
+ else:
+ raise AssertionError('A bound cursor skipped its actual nominal rank')
+ try:
+ SQLiteSnapshots(store, Path(directory)/'bad-checkpoint', installation, clock).create('bad')
+ except DatabaseRejected as error:
+ assert error.code == 'invalid_snapshot_records'
+ else:
+ raise AssertionError('Checkpoint accepted inconsistent progress')
+ assert store.path.read_bytes() == source_bytes
print('Current-source recurrence, both owned runtimes and pinned-time codecs passed.')
""",
)
From a6ba0263551e2d19ba07574f46bdbd7c6b9b1532 Mon Sep 17 00:00:00 2001
From: Efra Espada
Date: Mon, 2 Nov 2026 11:52:20 +0100
Subject: [PATCH 12/16] fix: Bind UTC recurrence limits to original resolved
cursor evidence
---
.../application/fixed_materialization.py | 21 +-
.../src/domus/application/fixed_recurrence.py | 3 +
.../src/domus/domain/recurrence_membership.py | 42 +-
.../src/domus/domain/recurrence_planning.py | 99 +++++
.../src/domus/domain/recurrence_records.py | 40 +-
backend/src/domus/infrastructure/sqlite.py | 73 +---
.../domus/infrastructure/sqlite_receipts.py | 78 ++++
.../domus/infrastructure/sqlite_recurrence.py | 74 +++-
.../sqlite_recurrence_records.py | 87 ++++-
.../sqlite_recurrence_schema.py | 11 +
docs/architecture-recurrence.md | 21 +
docs/evidence/recurrence-storage.json | 5 +-
docs/evidence/recurrence-until-integrity.json | 174 +++++++++
docs/tasks/migration.md | 12 +
specs/recurrence-storage-continuation.md | 11 +
tests/backend/test_fixed_materialization.py | 4 +
.../test_recurrence_cursor_evidence.py | 359 ++++++++++++++++++
tests/backend/test_recurrence_until_proof.py | 149 ++++++++
tests/backend/test_sqlite_recurrence.py | 4 +-
tests/backend/test_sqlite_task_reviews.py | 1 +
tests/containers/test_recurrence_image.py | 50 ++-
.../sqlite/unreleased-b2bcc69-schema8.sql | 71 ++++
22 files changed, 1282 insertions(+), 107 deletions(-)
create mode 100644 backend/src/domus/infrastructure/sqlite_receipts.py
create mode 100644 docs/evidence/recurrence-until-integrity.json
create mode 100644 tests/backend/test_recurrence_cursor_evidence.py
create mode 100644 tests/backend/test_recurrence_until_proof.py
create mode 100644 tests/fixtures/sqlite/unreleased-b2bcc69-schema8.sql
diff --git a/backend/src/domus/application/fixed_materialization.py b/backend/src/domus/application/fixed_materialization.py
index ada0674..a6172c8 100644
--- a/backend/src/domus/application/fixed_materialization.py
+++ b/backend/src/domus/application/fixed_materialization.py
@@ -8,7 +8,12 @@
from domus.application.fixed_recurrence import FixedRecurrencePlanner
from domus.application.ports import Clock, Identifiers, Transactions, UnitOfWork
from domus.domain.errors import Conflict, Forbidden, InvalidInput, NotFound, StorageFailure
-from domus.domain.recurrence_planning import FixedBatch, FixedCursor
+from domus.domain.recurrence_planning import (
+ CursorEvidence,
+ CursorTimeEvidence,
+ FixedBatch,
+ FixedCursor,
+)
from domus.domain.recurrence_records import (
MAINTENANCE_ACTOR,
FixedProgress,
@@ -181,6 +186,15 @@ def _commit(
"stopped", rule.schedule_id, progress.cursor, False, batch.periods_scanned
)
planned = batch.slots[0] if batch.slots else None
+ time_evidence = CursorTimeEvidence(
+ batch.cursor,
+ rule.policy,
+ batch.range_complete,
+ planned
+ if planned is not None
+ else (None if progress.evidence is None else progress.evidence.time.last),
+ batch.terminal,
+ )
probe = (
None
if planned is None
@@ -229,6 +243,7 @@ def apply(unit: UnitOfWork) -> Change:
"processed_slots": 0,
"cursor": asdict(batch.cursor),
"range_complete": batch.range_complete,
+ "cursor_evidence": time_evidence.document().decode(),
}
)
else:
@@ -250,6 +265,7 @@ def apply(unit: UnitOfWork) -> Change:
"definition_id": rule.definition_id,
"nominal": _nominal(planned.nominal),
"resolved_at": work.due_at,
+ "cursor_evidence": time_evidence.document().decode(),
}
)
return Change(
@@ -264,6 +280,8 @@ def apply(unit: UnitOfWork) -> Change:
"stopped", rule.schedule_id, progress.cursor, False, batch.periods_scanned
)
receipt = commands.in_unit(unit, command, payload, apply)
+ if receipt.result.decode().get("cursor_evidence") != time_evidence.document().decode():
+ raise StorageFailure("invalid_recurrence_effect")
occurrence_id = None
status = "scan"
if probe is not None:
@@ -296,6 +314,7 @@ def apply(unit: UnitOfWork) -> Change:
revision=progress.revision + 1,
last_run_at=self.clock.now(),
range_complete=batch.range_complete,
+ evidence=CursorEvidence(time_evidence, receipt.command, receipt.operation_id),
),
progress.revision,
)
diff --git a/backend/src/domus/application/fixed_recurrence.py b/backend/src/domus/application/fixed_recurrence.py
index 74080e0..8563c75 100644
--- a/backend/src/domus/application/fixed_recurrence.py
+++ b/backend/src/domus/application/fixed_recurrence.py
@@ -70,6 +70,7 @@ def expand(
if pattern.count is not None and current.nominal_count > pattern.count:
raise InvalidInput("invalid_recurrence_cursor")
slots: list[PlannedFixedSlot] = []
+ terminal: PlannedFixedSlot | None = None
scanned = 0
def result(complete: bool = False, *, closed: bool = False) -> FixedBatch:
@@ -78,6 +79,7 @@ def result(complete: bool = False, *, closed: bool = False) -> FixedBatch:
replace(current, closed=True) if closed else current,
scanned,
complete,
+ terminal,
)
if current.closed or (pattern.count is not None and current.nominal_count == pattern.count):
@@ -119,6 +121,7 @@ def result(complete: bool = False, *, closed: bool = False) -> FixedBatch:
and resolved.instant_ms > pattern.until
)
if beyond:
+ terminal = PlannedFixedSlot(candidate, resolved)
return result(True, closed=True)
if current.nominal_count == 2**63 - 1:
raise InvalidInput("invalid_recurrence_cursor")
diff --git a/backend/src/domus/domain/recurrence_membership.py b/backend/src/domus/domain/recurrence_membership.py
index c0dde37..6aecbc6 100644
--- a/backend/src/domus/domain/recurrence_membership.py
+++ b/backend/src/domus/domain/recurrence_membership.py
@@ -1,12 +1,12 @@
"""Pattern membership and nominal COUNT rank are independent of stored headers or receipts."""
-from datetime import timedelta
+from datetime import datetime, timedelta
from functools import lru_cache
from math import gcd
from domus.domain.nominal_recurrence import CalendarNominalExpansion
from domus.domain.recurrence_periods import period_bounds
-from domus.domain.recurrence_planning import FixedCursor, PlannedFixedSlot
+from domus.domain.recurrence_planning import CursorTimeEvidence, FixedCursor, PlannedFixedSlot
from domus.domain.recurrence_rules import FixedPattern, Frequency, NominalDate, slot_datetime
@@ -93,7 +93,9 @@ def accepts_fixed_slot(pattern: FixedPattern, planned: PlannedFixedSlot) -> bool
)
-def accepts_fixed_cursor(pattern: FixedPattern, cursor: FixedCursor) -> bool:
+def accepts_fixed_cursor(
+ pattern: FixedPattern, cursor: FixedCursor, evidence: CursorTimeEvidence | None = None
+) -> bool:
"""Consumed nominal rank includes truncated DTSTART and empty original periods."""
bounds = period_bounds(pattern, cursor.period_index)
if bounds is None and period_bounds(pattern, max(0, cursor.period_index - 1)) is None:
@@ -116,6 +118,38 @@ def accepts_fixed_cursor(pattern: FixedPattern, cursor: FixedCursor) -> bool:
consumed = _rank(pattern, cursor.period_index, 0) - 1 + cursor.offset
if consumed != cursor.nominal_count:
return False
+ if type(pattern.until) is int:
+ if evidence is None:
+ return (
+ cursor.period_index == cursor.offset == cursor.nominal_count == 0
+ and not cursor.closed
+ )
+ if evidence.cursor != cursor:
+ return False
+ last = evidence.last
+ if consumed:
+ if last is None or last.resolved is None or not accepts_fixed_slot(pattern, last):
+ return False
+ index = _period_index(pattern, last)
+ assert index is not None # Actual membership was checked above.
+ slots = CalendarNominalExpansion().period(pattern, index).slots
+ if _rank(pattern, index, slots.index(last.nominal)) != consumed:
+ return False
+ elif last is not None:
+ return False
+ if not cursor.closed or bounds is None:
+ return evidence.terminal is None
+ if evidence.terminal is not None:
+ terminal = evidence.terminal
+ return (
+ cursor.offset < len(candidates)
+ and terminal.nominal == candidates[cursor.offset]
+ and terminal.resolved is not None
+ and terminal.resolved.instant_ms > pattern.until
+ )
+ delta = bounds[0] - datetime(1970, 1, 1)
+ lower_ms = delta.days * 86_400_000 + delta.seconds * 1000
+ return cursor.offset == 0 and lower_ms - 86_400_000 > pattern.until
if not cursor.closed or bounds is None:
return True
if pattern.count is not None:
@@ -125,4 +159,4 @@ def accepts_fixed_cursor(pattern: FixedPattern, cursor: FixedCursor) -> bool:
cursor.offset < len(candidates)
and slot_datetime(candidates[cursor.offset]).date() > pattern.until.local
)
- return pattern.until is not None
+ return False
diff --git a/backend/src/domus/domain/recurrence_planning.py b/backend/src/domus/domain/recurrence_planning.py
index a6271ed..70fecd0 100644
--- a/backend/src/domus/domain/recurrence_planning.py
+++ b/backend/src/domus/domain/recurrence_planning.py
@@ -8,6 +8,7 @@
from domus.domain.errors import InvalidInput
from domus.domain.recurrence_rules import FixedPattern, NominalDate, NominalSlot
from domus.domain.recurrence_time import NominalTime, ResolvedTime, TimePolicy
+from domus.domain.storage import CommandKey, JsonDocument, JsonObject, validate_identifier
def cursor_binding(pattern: FixedPattern, policy: TimePolicy) -> str:
@@ -61,16 +62,114 @@ def __post_init__(self) -> None:
):
raise InvalidInput("invalid_recurrence_slot")
+ def value(self) -> JsonObject:
+ resolved = self.resolved
+ return {
+ "nominal": {
+ "kind": "date" if isinstance(self.nominal, NominalDate) else "time",
+ "local": self.nominal.local.isoformat(),
+ },
+ "resolved": None
+ if resolved is None
+ else {
+ "nominal": {"kind": "time", "local": resolved.nominal.local.isoformat()},
+ "policy": {
+ "zone": resolved.policy.zone,
+ "data_identity": resolved.policy.data_identity,
+ "repeated_time": resolved.policy.repeated_time.value,
+ "revision": resolved.policy.revision,
+ },
+ "resolved_local": resolved.resolved_local.isoformat(),
+ "offset_seconds": resolved.offset_seconds,
+ "resolution": resolved.resolution.value,
+ "zone_digest": resolved.zone_digest,
+ },
+ }
+
PlannedFixedSlot = PlannedSlot
+@dataclass(frozen=True, slots=True)
+class CursorTimeEvidence:
+ cursor: FixedCursor
+ policy: TimePolicy
+ range_complete: bool
+ last: PlannedSlot | None = None
+ terminal: PlannedSlot | None = None
+
+ def __post_init__(self) -> None:
+ if (
+ not isinstance(self.cursor, FixedCursor)
+ or not isinstance(self.policy, TimePolicy)
+ or type(self.range_complete) is not bool
+ or (self.cursor.closed and not self.range_complete)
+ ):
+ raise InvalidInput("invalid_recurrence_cursor_evidence")
+ for slot in (self.last, self.terminal):
+ if slot is not None and (
+ not isinstance(slot, PlannedSlot)
+ or (slot.resolved is not None and slot.resolved.policy != self.policy)
+ ):
+ raise InvalidInput("invalid_recurrence_cursor_evidence")
+ if (
+ self.last is not None
+ and self.terminal is not None
+ and self.last.resolved is not None
+ and self.terminal.resolved is not None
+ and self.last.resolved.zone_digest != self.terminal.resolved.zone_digest
+ ):
+ raise InvalidInput("invalid_recurrence_cursor_evidence")
+
+ def document(self) -> JsonDocument:
+ return JsonDocument.from_value(
+ {
+ "version": 1,
+ "cursor": asdict(self.cursor),
+ "policy": {
+ "zone": self.policy.zone,
+ "data_identity": self.policy.data_identity,
+ "repeated_time": self.policy.repeated_time.value,
+ "revision": self.policy.revision,
+ },
+ "range_complete": self.range_complete,
+ "last": None if self.last is None else self.last.value(),
+ "terminal": None if self.terminal is None else self.terminal.value(),
+ }
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class CursorEvidence:
+ time: CursorTimeEvidence
+ command: CommandKey
+ operation_id: str
+
+ def __post_init__(self) -> None:
+ validate_identifier(self.operation_id)
+ if not isinstance(self.time, CursorTimeEvidence) or not isinstance(
+ self.command, CommandKey
+ ):
+ raise InvalidInput("invalid_recurrence_cursor_evidence")
+
+ def document(self) -> JsonDocument:
+ return JsonDocument.from_value(
+ {
+ "version": 1,
+ "time": self.time.document().decode(),
+ "command": asdict(self.command),
+ "operation_id": self.operation_id,
+ }
+ )
+
+
@dataclass(frozen=True, slots=True)
class FixedBatch:
slots: tuple[PlannedFixedSlot, ...]
cursor: FixedCursor
periods_scanned: int
range_complete: bool
+ terminal: PlannedSlot | None = None
@dataclass(frozen=True, slots=True)
diff --git a/backend/src/domus/domain/recurrence_records.py b/backend/src/domus/domain/recurrence_records.py
index dbb2d24..f68a66a 100644
--- a/backend/src/domus/domain/recurrence_records.py
+++ b/backend/src/domus/domain/recurrence_records.py
@@ -6,7 +6,12 @@
from domus.domain.errors import InvalidInput
from domus.domain.identity import validate_revision
from domus.domain.recurrence_membership import accepts_fixed_cursor, accepts_fixed_slot
-from domus.domain.recurrence_planning import FixedCursor, PlannedFixedSlot, cursor_binding
+from domus.domain.recurrence_planning import (
+ CursorEvidence,
+ FixedCursor,
+ PlannedFixedSlot,
+ cursor_binding,
+)
from domus.domain.recurrence_rules import FixedPattern, NominalDate
from domus.domain.recurrence_time import TimePolicy
from domus.domain.storage import CommandKey, Installation, JsonDocument, validate_identifier
@@ -57,6 +62,7 @@ class FixedProgress:
revision: int = 1
last_run_at: int | None = None
range_complete: bool = False
+ evidence: CursorEvidence | None = None
def __post_init__(self) -> None:
validate_identifier(self.schedule_id)
@@ -69,6 +75,7 @@ def __post_init__(self) -> None:
not isinstance(self.cursor, FixedCursor)
or type(self.range_complete) is not bool
or (self.cursor.closed and not self.range_complete)
+ or (self.evidence is not None and not isinstance(self.evidence, CursorEvidence))
):
raise InvalidInput("invalid_recurrence_progress")
@@ -79,11 +86,40 @@ def matches_schedule(self, schedule: FixedSchedule) -> bool:
and self.household_id == schedule.household_id
and self.pattern_revision == schedule.pattern_revision
and self.cursor.binding == cursor_binding(schedule.pattern, schedule.policy)
+ and (
+ type(schedule.pattern.until) is not int
+ or self.evidence is not None
+ or self.pristine
+ )
and (
schedule.pattern.count is None
or self.cursor.nominal_count <= schedule.pattern.count
)
- and accepts_fixed_cursor(schedule.pattern, self.cursor)
+ and (
+ self.evidence is None
+ or (
+ self.evidence.time.cursor == self.cursor
+ and self.evidence.time.policy == schedule.policy
+ and self.evidence.time.range_complete == self.range_complete
+ and self.evidence.command.household_id == self.household_id
+ and self.evidence.command.actor_id == MAINTENANCE_ACTOR
+ and self.evidence.command.action
+ in ("recurrence.materialize", "recurrence.scan")
+ )
+ )
+ and accepts_fixed_cursor(
+ schedule.pattern, self.cursor, None if self.evidence is None else self.evidence.time
+ )
+ )
+
+ @property
+ def pristine(self) -> bool:
+ return (
+ self.cursor.period_index == self.cursor.offset == self.cursor.nominal_count == 0
+ and not self.cursor.closed
+ and not self.range_complete
+ and self.last_run_at is None
+ and self.evidence is None
)
diff --git a/backend/src/domus/infrastructure/sqlite.py b/backend/src/domus/infrastructure/sqlite.py
index 8324daa..0f840b9 100644
--- a/backend/src/domus/infrastructure/sqlite.py
+++ b/backend/src/domus/infrastructure/sqlite.py
@@ -19,16 +19,15 @@
)
from domus.domain.storage import (
AuditEntry,
- CommandKey,
Household,
Installation,
JsonDocument,
OutboxEvent,
- Receipt,
validate_identifier,
)
from domus.infrastructure.sqlite_history import HISTORY_STATEMENTS, SQLiteHistory
from domus.infrastructure.sqlite_identity import SQLiteMembers, SQLitePolicies
+from domus.infrastructure.sqlite_receipts import SQLiteReceipts, _command, _scope
from domus.infrastructure.sqlite_recovery import SQLiteRecoveries
from domus.infrastructure.sqlite_recurrence import SQLiteRecurrences
from domus.infrastructure.sqlite_recurrence_schema import RECURRENCE_STATEMENTS, RECURRENCE_TABLES
@@ -107,26 +106,6 @@
TABLES = MATERIAL_SCHEMA_TABLES | RECURRENCE_TABLES
-def _scope(command: CommandKey) -> tuple[str, str, str, str]:
- return command.household_id, command.actor_id, command.action, command.key
-
-
-def _command(row: sqlite3.Row) -> CommandKey:
- return CommandKey(row["household_id"], row["actor_id"], row["action"], row["command_key"])
-
-
-def _receipt(row: sqlite3.Row | None) -> Receipt | None:
- if row is None:
- return None
- return Receipt(
- _command(row),
- row["payload_fingerprint"],
- row["operation_id"],
- JsonDocument(row["result"]),
- row["created_at"],
- )
-
-
def _storage_error(error: sqlite3.Error) -> DomusError:
if getattr(error, "sqlite_errorcode", 0) & 0xFF in (sqlite3.SQLITE_BUSY, sqlite3.SQLITE_LOCKED):
return StorageBusy("storage_busy")
@@ -184,56 +163,6 @@ def save(self, household: Household, expected_revision: int) -> None:
raise Conflict("revision_conflict")
-@dataclass
-class SQLiteReceipts:
- connection: sqlite3.Connection
-
- def get(self, command: CommandKey) -> Receipt | None:
- original = _receipt(
- self.connection.execute(
- "SELECT * FROM receipts WHERE household_id=? AND actor_id=? "
- "AND action=? AND command_key=?",
- _scope(command),
- ).fetchone()
- )
- return SQLiteHistory(self.connection).receipt(command, original)
-
- def get_operation(self, household_id: str, actor_id: str, operation_id: str) -> Receipt | None:
- original = _receipt(
- self.connection.execute(
- "SELECT * FROM receipts WHERE household_id=? AND actor_id=? AND operation_id=?",
- (household_id, actor_id, operation_id),
- ).fetchone()
- )
- history = SQLiteHistory(self.connection)
- if original is not None:
- return history.receipt(original.command, original)
- return history.operation(household_id, actor_id, operation_id)
-
- def add(self, receipt: Receipt) -> None:
- if receipt.restoration is not None:
- raise InvalidInput("receipt_projection_not_storable")
- if (
- self.connection.execute(
- "SELECT 1 FROM retained_operations WHERE operation_id=? OR "
- "(household_id=? AND actor_id=? AND action=? AND command_key=?) LIMIT 1",
- (receipt.operation_id, *_scope(receipt.command)),
- ).fetchone()
- is not None
- ):
- raise Conflict("operation_collision")
- self.connection.execute(
- "INSERT INTO receipts VALUES (?,?,?,?,?,?,?,?)",
- (
- *_scope(receipt.command),
- receipt.payload_fingerprint,
- receipt.operation_id,
- receipt.result.encoded,
- receipt.created_at,
- ),
- )
-
-
@dataclass
class SQLiteAudit:
connection: sqlite3.Connection
diff --git a/backend/src/domus/infrastructure/sqlite_receipts.py b/backend/src/domus/infrastructure/sqlite_receipts.py
new file mode 100644
index 0000000..db77b20
--- /dev/null
+++ b/backend/src/domus/infrastructure/sqlite_receipts.py
@@ -0,0 +1,78 @@
+"""Scoped current/retained command receipts shared by storage adapters without a unit cycle."""
+
+import sqlite3
+from dataclasses import dataclass
+
+from domus.domain.errors import Conflict, InvalidInput
+from domus.domain.storage import CommandKey, JsonDocument, Receipt
+from domus.infrastructure.sqlite_history import SQLiteHistory
+
+
+def _scope(command: CommandKey) -> tuple[str, str, str, str]:
+ return command.household_id, command.actor_id, command.action, command.key
+
+
+def _command(row: sqlite3.Row) -> CommandKey:
+ return CommandKey(row["household_id"], row["actor_id"], row["action"], row["command_key"])
+
+
+def _receipt(row: sqlite3.Row | None) -> Receipt | None:
+ if row is None:
+ return None
+ return Receipt(
+ _command(row),
+ row["payload_fingerprint"],
+ row["operation_id"],
+ JsonDocument(row["result"]),
+ row["created_at"],
+ )
+
+
+@dataclass
+class SQLiteReceipts:
+ connection: sqlite3.Connection
+
+ def get(self, command: CommandKey) -> Receipt | None:
+ original = _receipt(
+ self.connection.execute(
+ "SELECT * FROM receipts WHERE household_id=? AND actor_id=? "
+ "AND action=? AND command_key=?",
+ _scope(command),
+ ).fetchone()
+ )
+ return SQLiteHistory(self.connection).receipt(command, original)
+
+ def get_operation(self, household_id: str, actor_id: str, operation_id: str) -> Receipt | None:
+ original = _receipt(
+ self.connection.execute(
+ "SELECT * FROM receipts WHERE household_id=? AND actor_id=? AND operation_id=?",
+ (household_id, actor_id, operation_id),
+ ).fetchone()
+ )
+ history = SQLiteHistory(self.connection)
+ if original is not None:
+ return history.receipt(original.command, original)
+ return history.operation(household_id, actor_id, operation_id)
+
+ def add(self, receipt: Receipt) -> None:
+ if receipt.restoration is not None:
+ raise InvalidInput("receipt_projection_not_storable")
+ if (
+ self.connection.execute(
+ "SELECT 1 FROM retained_operations WHERE operation_id=? OR "
+ "(household_id=? AND actor_id=? AND action=? AND command_key=?) LIMIT 1",
+ (receipt.operation_id, *_scope(receipt.command)),
+ ).fetchone()
+ is not None
+ ):
+ raise Conflict("operation_collision")
+ self.connection.execute(
+ "INSERT INTO receipts VALUES (?,?,?,?,?,?,?,?)",
+ (
+ *_scope(receipt.command),
+ receipt.payload_fingerprint,
+ receipt.operation_id,
+ receipt.result.encoded,
+ receipt.created_at,
+ ),
+ )
diff --git a/backend/src/domus/infrastructure/sqlite_recurrence.py b/backend/src/domus/infrastructure/sqlite_recurrence.py
index 365f917..beeb644 100644
--- a/backend/src/domus/infrastructure/sqlite_recurrence.py
+++ b/backend/src/domus/infrastructure/sqlite_recurrence.py
@@ -5,7 +5,7 @@
from dataclasses import dataclass
from typing import cast
-from domus.domain.errors import Conflict, InvalidInput, StorageFailure
+from domus.domain.errors import Conflict, DomusError, InvalidInput, StorageFailure
from domus.domain.recurrence_frontier import RecurrenceFrontier
from domus.domain.recurrence_records import (
FixedProgress,
@@ -15,6 +15,7 @@
)
from domus.domain.recurrence_rules import NominalDate
from domus.domain.storage import Installation, validate_identifier
+from domus.infrastructure.sqlite_receipts import SQLiteReceipts
from domus.infrastructure.sqlite_recurrence_records import (
frontier_document,
progress_document,
@@ -56,6 +57,34 @@ def progress_headers(value: FixedProgress) -> dict[str, object]:
}
+def _evidence_headers(value: FixedProgress) -> dict[str, object]:
+ evidence = value.evidence
+ assert evidence is not None
+ return {
+ "household_id": value.household_id,
+ "schedule_id": value.schedule_id,
+ "progress_revision": value.revision,
+ "operation_id": evidence.operation_id,
+ "evidence_fingerprint": evidence.document().fingerprint,
+ }
+
+
+def _original(connection: sqlite3.Connection, value: FixedProgress) -> bool:
+ evidence = value.evidence
+ if evidence is None:
+ return value.pristine
+ try:
+ receipt = SQLiteReceipts(connection).get(evidence.command)
+ return (
+ receipt is not None
+ and receipt.operation_id == evidence.operation_id
+ and receipt.result.decode().get("schedule_id") == value.schedule_id
+ and receipt.result.decode().get("cursor_evidence") == evidence.time.document().decode()
+ )
+ except DomusError:
+ return False
+
+
def slot_headers(value: ScheduledSlot) -> dict[str, object]:
return {
"household_id": value.schedule.household_id,
@@ -160,7 +189,27 @@ def progress(self, household_id: str, schedule_id: str) -> FixedProgress | None:
)
if value is not None:
rule = self.schedule(household_id, schedule_id)
- if rule is None or not value.matches_schedule(rule):
+ row = self.connection.execute(
+ "SELECT * FROM recurrence_progress_evidence WHERE household_id=? AND schedule_id=?",
+ (household_id, schedule_id),
+ ).fetchone()
+ headers_match = (
+ row is None
+ if value.evidence is None
+ else (
+ row is not None
+ and all(
+ type(row[key]) is type(expected) and row[key] == expected
+ for key, expected in _evidence_headers(value).items()
+ )
+ )
+ )
+ if (
+ rule is None
+ or not value.matches_schedule(rule)
+ or not headers_match
+ or not _original(self.connection, value)
+ ):
raise StorageFailure("invalid_recurrence_record")
return value
@@ -235,7 +284,11 @@ def save_progress(self, value: FixedProgress, expected_revision: int) -> None:
if value.revision != expected_revision + 1:
raise Conflict("revision_conflict")
rule = self.schedule(value.household_id, value.schedule_id)
- if rule is None or not value.matches_schedule(rule):
+ if (
+ rule is None
+ or not value.matches_schedule(rule)
+ or not _original(self.connection, value)
+ ):
raise Conflict("recurrence_cursor_mismatch")
self._write(
"recurrence_progress",
@@ -243,6 +296,21 @@ def save_progress(self, value: FixedProgress, expected_revision: int) -> None:
progress_document(value).encoded,
expected_revision,
)
+ if value.evidence is None:
+ self.connection.execute(
+ "DELETE FROM recurrence_progress_evidence WHERE household_id=? AND schedule_id=?",
+ (value.household_id, value.schedule_id),
+ )
+ else:
+ headers = _evidence_headers(value)
+ self.connection.execute(
+ f"INSERT INTO recurrence_progress_evidence({','.join(headers)}) "
+ f"VALUES ({','.join('?' for _ in headers)}) "
+ "ON CONFLICT(household_id,schedule_id) DO UPDATE SET "
+ "progress_revision=excluded.progress_revision, operation_id=excluded.operation_id, "
+ "evidence_fingerprint=excluded.evidence_fingerprint",
+ tuple(headers.values()),
+ )
def add_slot(self, value: ScheduledSlot) -> None:
if value.installation != self.installation:
diff --git a/backend/src/domus/infrastructure/sqlite_recurrence_records.py b/backend/src/domus/infrastructure/sqlite_recurrence_records.py
index 8c7d499..bbde0a5 100644
--- a/backend/src/domus/infrastructure/sqlite_recurrence_records.py
+++ b/backend/src/domus/infrastructure/sqlite_recurrence_records.py
@@ -6,7 +6,12 @@
from domus.domain.errors import DomusError, InvalidInput, StorageFailure
from domus.domain.recurrence_frontier import RecurrenceFrontier
-from domus.domain.recurrence_planning import FixedCursor, PlannedFixedSlot
+from domus.domain.recurrence_planning import (
+ CursorEvidence,
+ CursorTimeEvidence,
+ FixedCursor,
+ PlannedFixedSlot,
+)
from domus.domain.recurrence_records import (
FixedProgress,
FixedSchedule,
@@ -28,7 +33,7 @@
TimePolicy,
TimeResolution,
)
-from domus.domain.storage import Installation, JsonDocument, JsonObject, JsonValue
+from domus.domain.storage import CommandKey, Installation, JsonDocument, JsonObject, JsonValue
def _object(value: JsonValue, keys: tuple[str, ...]) -> JsonObject:
@@ -197,24 +202,24 @@ def schedule_document(value: FixedSchedule) -> JsonDocument:
def progress_document(value: FixedProgress) -> JsonDocument:
- return JsonDocument.from_value(
- {
- "version": 1,
- "schedule_id": value.schedule_id,
- "household_id": value.household_id,
- "pattern_revision": value.pattern_revision,
- "revision": value.revision,
- "last_run_at": value.last_run_at,
- "range_complete": value.range_complete,
- "cursor": {
- "binding": value.cursor.binding,
- "period_index": value.cursor.period_index,
- "offset": value.cursor.offset,
- "nominal_count": value.cursor.nominal_count,
- "closed": value.cursor.closed,
- },
- }
- )
+ data: JsonObject = {
+ "version": 1,
+ "schedule_id": value.schedule_id,
+ "household_id": value.household_id,
+ "pattern_revision": value.pattern_revision,
+ "revision": value.revision,
+ "last_run_at": value.last_run_at,
+ "range_complete": value.range_complete,
+ "cursor": {
+ "binding": value.cursor.binding,
+ "period_index": value.cursor.period_index,
+ "offset": value.cursor.offset,
+ "nominal_count": value.cursor.nominal_count,
+ "closed": value.cursor.closed,
+ },
+ "evidence": None if value.evidence is None else value.evidence.document().decode(),
+ }
+ return JsonDocument.from_value(data)
def _progress(value: JsonValue) -> FixedProgress:
@@ -228,6 +233,7 @@ def _progress(value: JsonValue) -> FixedProgress:
"last_run_at",
"range_complete",
"cursor",
+ "evidence",
),
)
cursor = _object(
@@ -247,6 +253,47 @@ def _progress(value: JsonValue) -> FixedProgress:
cast(int, row["revision"]),
cast(int | None, row["last_run_at"]),
cast(bool, row["range_complete"]),
+ None if row["evidence"] is None else _evidence(row["evidence"]),
+ )
+
+
+def _cursor(value: JsonValue) -> FixedCursor:
+ row = _object(value, ("binding", "period_index", "offset", "nominal_count", "closed"))
+ return FixedCursor(
+ cast(str, row["binding"]),
+ cast(int, row["period_index"]),
+ cast(int, row["offset"]),
+ cast(int, row["nominal_count"]),
+ cast(bool, row["closed"]),
+ )
+
+
+def _planned(value: JsonValue) -> PlannedFixedSlot:
+ row = _object(value, ("nominal", "resolved"))
+ return PlannedFixedSlot(
+ _nominal(row["nominal"]), None if row["resolved"] is None else _resolved(row["resolved"])
+ )
+
+
+def _evidence(value: JsonValue) -> CursorEvidence:
+ row = _record(value, ("time", "command", "operation_id"))
+ core = _record(row["time"], ("cursor", "policy", "range_complete", "last", "terminal"))
+ command = _object(row["command"], ("household_id", "actor_id", "action", "key"))
+ return CursorEvidence(
+ CursorTimeEvidence(
+ _cursor(core["cursor"]),
+ _policy(core["policy"]),
+ cast(bool, core["range_complete"]),
+ None if core["last"] is None else _planned(core["last"]),
+ None if core["terminal"] is None else _planned(core["terminal"]),
+ ),
+ CommandKey(
+ cast(str, command["household_id"]),
+ cast(str, command["actor_id"]),
+ cast(str, command["action"]),
+ cast(str, command["key"]),
+ ),
+ cast(str, row["operation_id"]),
)
diff --git a/backend/src/domus/infrastructure/sqlite_recurrence_schema.py b/backend/src/domus/infrastructure/sqlite_recurrence_schema.py
index faefa1d..f9a8638 100644
--- a/backend/src/domus/infrastructure/sqlite_recurrence_schema.py
+++ b/backend/src/domus/infrastructure/sqlite_recurrence_schema.py
@@ -5,6 +5,7 @@
"recurrence_progress",
"recurrence_slots",
"recurrence_frontiers",
+ "recurrence_progress_evidence",
}
RECURRENCE_STATEMENTS = (
"CREATE TABLE recurrence_frontiers(household_id TEXT PRIMARY KEY NOT NULL "
@@ -35,6 +36,16 @@
"FOREIGN KEY(household_id,schedule_id,pattern_revision) "
"REFERENCES recurrence_rules(household_id,schedule_id,pattern_revision) "
"DEFERRABLE INITIALLY DEFERRED)",
+ "CREATE UNIQUE INDEX recurrence_progress_revision "
+ "ON recurrence_progress(household_id,schedule_id,revision)",
+ "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)",
"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 "
diff --git a/docs/architecture-recurrence.md b/docs/architecture-recurrence.md
index 081638d..3dbae07 100644
--- a/docs/architecture-recurrence.md
+++ b/docs/architecture-recurrence.md
@@ -301,3 +301,24 @@ The [cursor evidence](evidence/recurrence-cursor-integrity.json) includes a repl
negative tests against every byte-verified original059 module, real canonical-row corruption and
genuine sparse/end continuations. The original test fixture binding mismatch was corrected
explicitly. No historical source or fixture bytes, SQLite layout or owner installation changed.
+
+## Original proof for UTC UNTIL continuation
+
+UTC UNTIL requires resolved boundary facts beyond nominal rank. Progress retains the last included
+slot at its exact consumed rank, with an instant at or before the inclusive cutoff. Closing a cursor
+requires the first excluded resolved candidate at that cursor, a conservative exhausted-period bound
+or natural calendar exhaustion. A bounded unclosed cursor after the last included slot remains valid
+until the following step checks the end. No provider is called by domain records or inside a writer.
+
+Each real fixed progress advance now retains a typed time-evidence core in its original slot or scan
+receipt. The progress document references that exact scoped command and operation; a separate
+revision-bound evidence header agrees with both. The repository checks current or retained historical
+receipts before writes and on independent reads. Checkpoint validation uses the same checks. A safe
+zero-work rewind clears its old evidence explicitly; a fabricated run cannot borrow that exemption.
+Actual restore reconciliation preserves absent work without new task IDs, receipts or events.
+
+This changes the unreleased schema8 layout and closed progress encoding. Released historical1..7
+inputs remain supported. The genuine prior b2bcc69 schema8 layout is a strict refusal control;
+keeping its matching application/database pair permits development rollback without relabeling it.
+See [UTC boundary evidence](evidence/recurrence-until-integrity.json) for original-source replay,
+logical-dump correction, current verification and remaining full-feature acceptance.
diff --git a/docs/evidence/recurrence-storage.json b/docs/evidence/recurrence-storage.json
index d59f11a..f04b735 100644
--- a/docs/evidence/recurrence-storage.json
+++ b/docs/evidence/recurrence-storage.json
@@ -85,7 +85,7 @@
"policyModulesByteVerified": 7,
"immutableBaseOwners": 18,
"retainedOwnersUnchanged": true,
- "currentCompanionSHA256": "650da5217fcacea8cce54f144365bc1fc4d61bfac6337864018e05bfc7a943b0",
+ "currentCompanionSHA256": "138f2b96f989fed4ac0d7eafb896895449304c698f74a0693dd8dc01abd9597a",
"scope": "Explicit contributor integration; original first publication remains in Git. Format, reciprocal inventory/path consistency and current catalog pass."
},
"packageAndArchitectureTests": {
@@ -283,5 +283,6 @@
"result": "All applicable CI Check suites passed; Copilot separately found one real nominal-membership issue, now corrected locally and awaiting publication/review. Full recurrence remains incomplete."
},
"laterMaterializationIncrement": "docs/evidence/recurrence-materialization.json; original storage-kernel results remain historical and scope-specific.",
- "laterFairnessIncrement": "docs/evidence/recurrence-fairness.json; original storage-kernel acceptance scope remains unchanged."
+ "laterFairnessIncrement": "docs/evidence/recurrence-fairness.json; original storage-kernel acceptance scope remains unchanged.",
+ "laterUntilIntegrityIncrement": "docs/evidence/recurrence-until-integrity.json; original storage-kernel results retain their historical source and scope."
}
diff --git a/docs/evidence/recurrence-until-integrity.json b/docs/evidence/recurrence-until-integrity.json
new file mode 100644
index 0000000..8b74ddd
--- /dev/null
+++ b/docs/evidence/recurrence-until-integrity.json
@@ -0,0 +1,174 @@
+{
+ "status": "partial; UTC UNTIL and original progress evidence corrected, complete recurrence execution remains in progress",
+ "recordedAtUTC": "2026-11-02T10:51:15.348591+00:00",
+ "issue": "https://github.com/vypdev/domus/issues/20",
+ "pullRequest": "https://github.com/vypdev/domus/pull/22",
+ "sourceBase": "b2bcc693d6be5065b1be8dc378334199c927735a",
+ "backendSourceInventorySHA256": "379103605c1e68b4ce45bb5f82a4ef4edbf6d7bc757261b4326706659daa2f11",
+ "sourceManifestMethod": "SHA256 of compact sorted JSON mapping every authored Python module path, including initializers, to its byte SHA256.",
+ "findings": {
+ "urls": [
+ "https://github.com/vypdev/domus/pull/22#discussion_r4441091431",
+ "https://github.com/vypdev/domus/pull/22#discussion_r4441091443"
+ ],
+ "assessment": "Both are valid consumer boundary defects. Source corrected; fresh native verification required. Neither is dismissed or reported as a Copilot defect."
+ },
+ "implemented": [
+ "UTC UNTIL requires last-included resolved proof at the exact consumed nominal rank and at/before the inclusive cutoff. Closed progress additionally requires the first excluded resolved candidate, a conservative exhausted-period lower bound or natural calendar exhaustion.",
+ "A legitimate unclosed cursor after the final included slot remains valid pending its next bounded end check. Unproved bootstrap progress cannot claim a prior run or completed range.",
+ "Every actual fixed progress advance, including date/count work and empty scans, stores its time core in the original slot/scan receipt. Progress binds that core to the exact scoped command and operation.",
+ "Strict canonical progress encoding, deferred revision-bound evidence header and current/retained original receipt must agree on independent read and write. Checkpoint verification shares the same checks.",
+ "Timezone resolution remains outside SQLite writers and domain values. Receipt access is extracted into an acyclic infrastructure module with the original public compatibility import retained.",
+ "Actual restored absent-effect reconciliation does not generate a replacement task, operation, identity or delivery. Safe explicit zero-work test rewinds clear stale evidence.",
+ "The unreleased schema8 layout gains its evidence table; genuine prior b2 development layout is refused unchanged. Supported released1..7 source inputs and owner Edge.5/schema7 remain untouched.",
+ "The late b2 container smoke E501 line is wrapped and the exact final script gains actual UTC boundary/evidence/header/checkpoint controls."
+ ],
+ "testsFirst": {
+ "originalSource": {
+ "source": "b2bcc693d6be5065b1be8dc378334199c927735a",
+ "wheelSHA256": "521fdba08df26faac001fe17ef030c1106e2bc87aa4e4b670f0bfe84b994c5ec",
+ "inventorySHA256": "ba4c64c5914ad967e5c254fa448830a2df54fd605230b55bf39f9522f6d2d8cc",
+ "all132ModulesByteVerifiedAgainstGit": true
+ },
+ "finalOriginalReplay": "All17 tests fail against the exact byte-verified original b2 wheel:15 actual value/read/write/snapshot/step negatives, absent original temporal evidence, and acceptance of the genuine previous development layout. Full original output retained.",
+ "fixtureCorrection": {
+ "reason": "SQLite iterdump omits application_id and user_version. The first replay was a version0 control, not an intact source8 layout. The logical fixture now restores the actual original header values read from the immutable captured database. The original database was neither relabeled nor modified.",
+ "originalDatabaseSHA256": "23f3d880296942c1c732e840e03f95b236d14c275613d1b6d031b9bb314675c8",
+ "originalLogicalDumpSHA256": "9e1f38f0fe8a52e11cc2ba391f0b32b65831485266d9aecc7d08a2e48562403d",
+ "completeLogicalFixtureSHA256": "6c33c79693647e6fed34e693c943012e28ef05a4b153b8901b7e35014105cd63",
+ "applicationId": 1146047829,
+ "schema": 8,
+ "previousTestAssertion": "unsupported_schema because replay had version0",
+ "correctControl": "unrelated_database because actual prior schema8 lacks the new evidence table"
+ },
+ "newRegressionExecutions": 71,
+ "actualBoundaryControls": "Actual inclusive end, separate terminal step, empty UTC periods, final calendar exhaustion, provider work without writer lock, original/header tampering, invalid replay, actual restore and atomic rollback."
+ },
+ "completeBackend": {
+ "executions": 3881,
+ "durationSeconds": 100.5,
+ "authoredModules": 133,
+ "coverageTotals": {
+ "covered_lines": 9537,
+ "num_statements": 9537,
+ "percent_covered": 100.0,
+ "percent_covered_display": "100.00",
+ "missing_lines": 0,
+ "excluded_lines": 274,
+ "percent_statements_covered": 100.0,
+ "percent_statements_covered_display": "100.00",
+ "num_branches": 2736,
+ "num_partial_branches": 0,
+ "covered_branches": 2736,
+ "missing_branches": 0,
+ "percent_branches_covered": 100.0,
+ "percent_branches_covered_display": "100.00"
+ },
+ "independentGlobalAndPerModuleAudit": "passed"
+ },
+ "focused": "312 relevant boundary/planning/storage/restore executions passed before the final two bootstrap-claim regressions; the final full3881 campaign includes both new tests and all current source bytes.",
+ "checks": {
+ "strictTypes": "passed;133 modules",
+ "ruff": "check/format passed after the final image edit;337 files",
+ "architecture": "3 contracts kept,0 broken;177 files/1144 dependencies",
+ "originalPinnedSdd": {
+ "upstream": "3e7ceea08e6131360e026937d1c0cc1b120472e0",
+ "base": "0806a38f8a2de1f102a017bff25ad6856fb17bcd",
+ "selected": "specs/recurrence-storage-continuation.md",
+ "originalPolicyModulesVerified": 7,
+ "format": "PASS",
+ "consistency": "PASS",
+ "composition": "explicit contributor integration; original publication retained in Git",
+ "unchangedCatalog": "PASS",
+ "immutableOwnerDocumentsUsed": 18,
+ "retainedCompanionCases": 52,
+ "newCases": 4,
+ "rawCharacters": 36802,
+ "renderedCharacters": 36802,
+ "composedCharacters": 36802,
+ "applicableDraftMaximumCharacters": 70000,
+ "sha256": "138f2b96f989fed4ac0d7eafb896895449304c698f74a0693dd8dc01abd9597a",
+ "scope": "Review input only; no gate admission, branch publication, source implementation or runtime acceptance.",
+ "retainedOwningFilesUnchanged": true,
+ "workingDocumentValidated": true
+ },
+ "freshPackageAndArchitecture": {
+ "result": "passed",
+ "executions": 52,
+ "durationSeconds": 6.82
+ },
+ "tooling": {
+ "result": "passed",
+ "executions": 148,
+ "durationSeconds": 9.54,
+ "coverageTotals": {
+ "covered_lines": 214,
+ "num_statements": 214,
+ "percent_covered": 100.0,
+ "percent_covered_display": "100.00",
+ "missing_lines": 0,
+ "excluded_lines": 0,
+ "percent_statements_covered": 100.0,
+ "percent_statements_covered_display": "100.00",
+ "num_branches": 98,
+ "num_partial_branches": 0,
+ "covered_branches": 98,
+ "missing_branches": 0,
+ "percent_branches_covered": 100.0,
+ "percent_branches_covered_display": "100.00"
+ }
+ }
+ },
+ "images": {
+ "exactCurrentScriptOnHost": "passed; fixed/fair materialization, both owned runtimes, relative read-only planning, UTC inclusive/terminal evidence and missing-header/checkpoint refusal",
+ "freshHosted": "Required for this correction after publication; local host script is not actual CPU-image proof.",
+ "previousB2": {
+ "runId": 41425773817,
+ "amd64": 125211894187,
+ "aarch64": 125211894296,
+ "result": "Both real images passed. Aggregate failed on the owned smoke E501 line; that source line is now corrected."
+ }
+ },
+ "schema": {
+ "current": 8,
+ "recurrenceTables": 5,
+ "supportedHistoricalSchemas": [
+ 1,
+ 2,
+ 3,
+ 4,
+ 5,
+ 6,
+ 7
+ ],
+ "priorDevelopmentControl": {
+ "source": "b2bcc693d6be5065b1be8dc378334199c927735a",
+ "completeLogicalFixtureSHA256": "6c33c79693647e6fed34e693c943012e28ef05a4b153b8901b7e35014105cd63",
+ "originalDatabaseSHA256": "23f3d880296942c1c732e840e03f95b236d14c275613d1b6d031b9bb314675c8",
+ "behavior": "read/write/initialize refuse the unknown development layout without changing its bytes; no released8 upgrade or version relabeling"
+ },
+ "owner": "No owner HA access, migration, update, release or deployment in this correction."
+ },
+ "copilot": {
+ "nativeB2": {
+ "partitions": "10/10",
+ "files": 86,
+ "fragments": 97,
+ "validOpenFindings": 2,
+ "fixedPriorFindings": 3,
+ "overall": "partial/neutral because human-conversation4 fetched/3 retained/1 omitted/0 truncated/limitReached; other issue/PR/thread/diff dimensions complete"
+ },
+ "progressReference": "3307c393-b9fc-48af-86ab-be9ff0ff49e0",
+ "reports": [
+ "https://github.com/vypdev/copilot/issues/851#issuecomment-6565229213",
+ "https://github.com/vypdev/copilot/issues/843#issuecomment-6565011301"
+ ],
+ "manualSupplement": "Two proposed-SDD inputs and three finding-fix replies manually inspected; all agent-authored maintainer-account material, not new owner policy answers. Omitted item and whole-prompt absence are not inferred.",
+ "unchanged": "No Copilot resource, configuration, credential, bound, review scope or SDK bytes changed."
+ },
+ "rawEvidence": {
+ "privateCampaign": "/private/tmp/domus-time-until-b2bcc69",
+ "consumers": "CurrentPR22 UTC boundary review and rollback: exact original b2 wheel, immutable original schema8 database/provenance, corrected logical fixture/erratum, original and current tests, coverage/source/SDK proofs and image-host/package checks. Compact completed producers after publication; keep authored original input and necessary evidence."
+ },
+ "pending": "Fresh CI/native verification; relative rule/slot/progress persistence, atomic initial creation, durable source inbox/closure/invalidation, historical source reconciliation and mixed-rule global fairness/runtime composition; protected calendar/operations, reviewed schedule changes, household timing defaults, full retained52+37 cases and all-domain UI/HA/Stable-Edge acceptance."
+}
diff --git a/docs/tasks/migration.md b/docs/tasks/migration.md
index d7b53ce..b3881e3 100644
--- a/docs/tasks/migration.md
+++ b/docs/tasks/migration.md
@@ -86,6 +86,18 @@ writes and initialization refuse that unknown development layout and preserve it
control does not extend the supported historical migration set or authorize version-number edits.
Keep its matching application for an intentional development rollback.
+The later unreleased schema8 refinement adds recurrence_progress_evidence and strict inline original
+time evidence. The genuine [b2bcc69 input](../../tests/fixtures/sqlite/unreleased-b2bcc69-schema8.sql)
+comes from its actual timed-UNTIL creation, two committed slots and terminal scan under byte-verified
+source b2bcc693d6be5065b1be8dc378334199c927735a. Its complete logical fixture SHA-256 is
+6c33c79693647e6fed34e693c943012e28ef05a4b153b8901b7e35014105cd63. SQLite iterdump omits
+application/schema pragmas; the fixture explicitly restores those actual captured header values.
+The first incomplete dump replay exercised version0 and was corrected before the final control.
+The original database remains byte-identical, with SHA-256
+23f3d880296942c1c732e840e03f95b236d14c275613d1b6d031b9bb314675c8. Current reads, writes and
+initialization refuse its previous development layout unchanged. There is no released8 upgrade
+claim, version-number downgrade or alteration to any released1..7 input.
+
Those observations are retained in [task evidence](../evidence/task-execution-foundation.json).
They are distinct from image/cross-architecture adoption, real consumer ledgers and a restoration on
the owner's HA, which remain required before integrated release claims. No automatic backup schedule
diff --git a/specs/recurrence-storage-continuation.md b/specs/recurrence-storage-continuation.md
index 512d1b3..373d935 100644
--- a/specs/recurrence-storage-continuation.md
+++ b/specs/recurrence-storage-continuation.md
@@ -161,6 +161,7 @@ Use real independent SQLite connections and exact before/after version/row check
| backend/src/domus/composition/server.py | update |
| backend/src/domus/composition/ingress.py | update |
| backend/src/domus/infrastructure/sqlite.py | update |
+| backend/src/domus/infrastructure/sqlite_receipts.py | create |
| backend/src/domus/infrastructure/sqlite_snapshots.py | update |
| backend/src/domus/infrastructure/sqlite_recurrence.py | create |
| backend/src/domus/infrastructure/sqlite_recurrence_schema.py | create |
@@ -181,12 +182,15 @@ Use real independent SQLite connections and exact before/after version/row check
| tests/backend/test_relative_recurrence_planning.py | create |
| tests/backend/test_recurrence_local_instant.py | create |
| tests/backend/test_recurrence_cursor_integrity.py | create |
+| tests/backend/test_recurrence_cursor_evidence.py | create |
+| tests/backend/test_recurrence_until_proof.py | create |
| tests/backend/test_recurrence_slot_membership.py | create |
| tests/backend/test_sqlite_recurrence_records.py | create |
| tests/backend/test_sqlite_recurrence_integrity.py | create |
| backend/src/domus/infrastructure/sqlite_recurrence_integrity.py | create |
| tests/fixtures/sqlite/v7.sql | create |
| tests/fixtures/sqlite/unreleased-c581-schema8.sql | create |
+| tests/fixtures/sqlite/unreleased-b2bcc69-schema8.sql | create |
| tests/backend/test_sqlite_transactions.py | update |
| tests/backend/test_sqlite_snapshots.py | update |
| tests/backend/test_activation_compatibility.py | update |
@@ -208,6 +212,7 @@ Use real independent SQLite connections and exact before/after version/row check
| docs/evidence/recurrence-cursor-integrity.json | create |
| docs/tasks/migration.md | update |
| docs/evidence/recurrence-execution.json | update |
+| docs/evidence/recurrence-until-integrity.json | create |
Existing retained path declarations describe their original publication baseline; the table above identifies this continuation's actual new and existing storage paths at the immutable base. Declare any additional real implementation/test path before committing it. Update exact evidence and current acceptance limits without relabeling historical results.
@@ -267,6 +272,12 @@ The worker carries cancellation into every run and joins before runtime identity
## 19. Definition of Done
+UTC UNTIL continuation additionally retains the actual last included resolved slot and, when discovered, the first excluded candidate at the cursor. The last included instant must not exceed the inclusive cutoff and its nominal rank must equal the consumed count. Closed progress requires the actual excluded boundary, a conservative exhausted-period lower bound or natural calendar exhaustion. A legitimate unclosed cursor after the final included slot remains valid until a later bounded step checks the end. Unproved bootstrap values cannot claim a run or a completed range.
+
+Every actual fixed progress advance, including empty scans and date/count rules, binds its time evidence to the exact original scoped command and operation. Slot and scan receipts retain the evidence core; a current or restored historical original must match before progress is saved. A separate deferred revision-bound evidence header and strict canonical inline document must agree on reads and checkpoint validation. Providers resolve outside the writer; domain records perform no timezone I/O. Restored absent effects remain absent without new identities or delivery.
+
+The evidence table is an unreleased schema-8 layout refinement, not a new released migration or schema9. Preserve the real b2bcc69 source/database pair and reject its previous four-table development layout without changing it. SQLite logical dumps omit header pragmas: a replay fixture must restore the actual original captured application ID and schema value, rather than silently exercising version0. Keep the raw source database unchanged and disclose the first incomplete-dump observation. Existing released1..7 inputs and their migrations remain supported. These integrity refinements add no counted product cases and do not complete retained execution/calendar acceptance.
+
- [ ] The four additional cases and every retained requirement/case have truthful current-source evidence; no checkpoint is described as full feature acceptance.
- [ ] Supported first-party coverage is 100% globally/per module; strict types, real architecture contracts and all applicable current-head CI Check suites pass.
- [ ] Original format, consistency and catalog checks pass without changing Copilot resources or weakening limits.
diff --git a/tests/backend/test_fixed_materialization.py b/tests/backend/test_fixed_materialization.py
index 161ac2d..163f169 100644
--- a/tests/backend/test_fixed_materialization.py
+++ b/tests/backend/test_fixed_materialization.py
@@ -176,6 +176,8 @@ def test_replaying_a_proved_current_slot_advances_progress_without_duplicate_wor
revision=3,
cursor=FixedCursor(progress.cursor.binding),
range_complete=False,
+ last_run_at=None,
+ evidence=None,
),
2,
)
@@ -259,6 +261,8 @@ def rewind(store, rule_id):
revision=progress.revision + 1,
cursor=FixedCursor(progress.cursor.binding),
range_complete=False,
+ last_run_at=None,
+ evidence=None,
),
progress.revision,
)
diff --git a/tests/backend/test_recurrence_cursor_evidence.py b/tests/backend/test_recurrence_cursor_evidence.py
new file mode 100644
index 0000000..bc48eda
--- /dev/null
+++ b/tests/backend/test_recurrence_cursor_evidence.py
@@ -0,0 +1,359 @@
+"""Resolved boundaries require original scoped operation proof on every durable advance."""
+
+from dataclasses import replace
+from datetime import datetime
+
+import pytest
+from test_fixed_materialization import configured as dated
+from test_recurrence_until_proof import configured
+from test_sqlite_snapshots import Clock
+from test_task_commands import counts
+
+from domus.domain.errors import Conflict, DatabaseRejected, InvalidInput, StorageFailure
+from domus.domain.recurrence_membership import accepts_fixed_cursor
+from domus.domain.recurrence_planning import FixedCursor
+from domus.domain.recurrence_time import NominalTime
+from domus.domain.storage import JsonDocument
+from domus.infrastructure.sqlite_recurrence_records import progress_document, read_progress
+from domus.infrastructure.sqlite_snapshots import SQLiteSnapshots
+
+
+def facts(tmp_path):
+ service, store, rid, before = configured(tmp_path)
+ service.step(rid, before)
+ with store.read() as unit:
+ rule = unit.recurrence.schedule("home", rid)
+ progress = unit.recurrence.progress("home", rid)
+ return service, store, rid, before, rule, progress
+
+
+@pytest.mark.parametrize(
+ "change",
+ [
+ "cursor",
+ "policy",
+ "range",
+ "closed-range",
+ "last-type",
+ "terminal-type",
+ "policy-slot",
+ "digest",
+ ],
+)
+def test_evidence_value_rejects_untyped_or_inconsistent_temporal_facts(tmp_path, change):
+ _, _, _, _, _, progress = facts(tmp_path)
+ core = progress.evidence.time
+ last = core.last
+ changes = {
+ "cursor": {"cursor": None},
+ "policy": {"policy": None},
+ "range": {"range_complete": 1},
+ "closed-range": {"cursor": replace(core.cursor, closed=True), "range_complete": False},
+ "last-type": {"last": "wrong"},
+ "terminal-type": {"terminal": "wrong"},
+ "policy-slot": {"policy": replace(core.policy, revision=2)},
+ "digest": {
+ "terminal": replace(last, resolved=replace(last.resolved, zone_digest="b" * 64))
+ },
+ }
+ with pytest.raises(InvalidInput, match="invalid_recurrence_cursor_evidence"):
+ replace(core, **changes[change])
+
+
+@pytest.mark.parametrize("field,value", [("time", None), ("command", None), ("operation_id", "")])
+def test_origin_reference_requires_typed_scope_and_operation(tmp_path, field, value):
+ *_, progress = facts(tmp_path)
+ with pytest.raises(InvalidInput):
+ replace(progress.evidence, **{field: value})
+ with pytest.raises(InvalidInput):
+ replace(progress, evidence="wrong")
+
+
+@pytest.mark.parametrize("change", ["cursor", "policy", "range", "household", "actor", "action"])
+def test_typed_origin_cannot_rebind_a_progress_relationship(tmp_path, change):
+ _, _, _, _, rule, progress = facts(tmp_path)
+ evidence = progress.evidence
+ if change == "cursor":
+ evidence = replace(
+ evidence, time=replace(evidence.time, cursor=replace(progress.cursor, offset=0))
+ )
+ elif change == "policy":
+ evidence = replace(
+ evidence,
+ time=replace(evidence.time, policy=replace(rule.policy, revision=2), last=None),
+ )
+ elif change == "range":
+ evidence = replace(evidence, time=replace(evidence.time, range_complete=True))
+ else:
+ command = replace(
+ evidence.command,
+ **{
+ "household": {"household_id": "other"},
+ "actor": {"actor_id": "ha_admin"},
+ "action": {"action": "task.complete"},
+ }[change],
+ )
+ evidence = replace(evidence, command=command)
+ assert not replace(progress, evidence=evidence).matches_schedule(rule)
+
+
+@pytest.mark.parametrize(
+ "change",
+ [
+ "wrong-cursor",
+ "missing-last",
+ "late-last",
+ "earlier-last",
+ "zero-with-last",
+ "unclosed-terminal",
+ ],
+)
+def test_instant_until_checks_actual_included_rank_and_terminal_state(tmp_path, change):
+ service, store, rid, before, rule, first = facts(tmp_path)
+ service.step(rid, before)
+ with store.read() as unit:
+ second = unit.recurrence.progress("home", rid)
+ cursor = second.cursor
+ core = second.evidence.time
+ if change == "wrong-cursor":
+ core = replace(core, cursor=first.cursor)
+ elif change == "missing-last":
+ core = replace(core, last=None)
+ elif change == "late-last":
+ nominal = NominalTime(datetime(2026, 1, 3, 12))
+ core = replace(
+ core, last=type(core.last)(nominal, service.planner.times.resolve(nominal, rule.policy))
+ )
+ elif change == "earlier-last":
+ core = replace(core, last=first.evidence.time.last)
+ elif change == "zero-with-last":
+ cursor = FixedCursor(cursor.binding)
+ core = replace(core, cursor=cursor)
+ else:
+ core = replace(core, terminal=core.last)
+ assert not accepts_fixed_cursor(rule.pattern, cursor, core)
+
+
+def test_a_legitimate_last_included_cursor_waits_for_a_separate_end_check(tmp_path):
+ service, store, rid, before, rule, _ = facts(tmp_path)
+ second = service.step(rid, before)
+ assert not second.cursor.closed and second.cursor.nominal_count == 2
+ with store.read() as unit:
+ progress = unit.recurrence.progress("home", rid)
+ assert progress.matches_schedule(rule)
+ assert progress.evidence.time.last.resolved.instant_ms == rule.pattern.until
+ assert progress.evidence.time.terminal is None
+ assert read_progress(progress_document(progress).encoded) == progress
+ final = service.step(rid, before)
+ assert final.cursor.closed and final.status == "scan"
+ assert counts(store)[1] == 2
+
+
+@pytest.mark.parametrize("surface", ["read", "snapshot"])
+@pytest.mark.parametrize(
+ "change",
+ [
+ "missing-header",
+ "header-revision",
+ "header-operation",
+ "header-fingerprint",
+ "missing-original",
+ "original-operation",
+ "original-schedule",
+ "original-core",
+ "malformed-original",
+ ],
+)
+def test_durable_temporal_evidence_cannot_outlive_or_disagree_with_its_original(
+ tmp_path, surface, change
+):
+ _, store, rid, _, _, progress = facts(tmp_path)
+ evidence = progress.evidence
+ with store._connection() as connection:
+ if change == "missing-header":
+ connection.execute("DELETE FROM recurrence_progress_evidence")
+ elif change.startswith("header-"):
+ column, value = {
+ "header-revision": ("progress_revision", 99),
+ "header-operation": ("operation_id", "wrong"),
+ "header-fingerprint": ("evidence_fingerprint", "b" * 64),
+ }[change]
+ connection.execute(f"UPDATE recurrence_progress_evidence SET {column}=?", (value,))
+ elif change == "missing-original":
+ connection.execute(
+ "DELETE FROM receipts WHERE operation_id=?", (evidence.operation_id,)
+ )
+ elif change == "original-operation":
+ connection.execute(
+ "UPDATE receipts SET operation_id='wrong' WHERE operation_id=?",
+ (evidence.operation_id,),
+ )
+ else:
+ result = progress.evidence.time.document().decode()
+ result = {"schedule_id": rid, "cursor_evidence": result}
+ if change == "original-schedule":
+ result["schedule_id"] = "wrong"
+ elif change == "original-core":
+ result["cursor_evidence"]["last"] = None
+ encoded = (
+ "not JSON"
+ if change == "malformed-original"
+ else JsonDocument.from_value(result).encoded
+ )
+ connection.execute(
+ "UPDATE receipts SET result=? WHERE operation_id=?",
+ (encoded, evidence.operation_id),
+ )
+ original = store.path.read_bytes()
+ if surface == "read":
+ with store.read() as unit:
+ with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
+ unit.recurrence.progress("home", rid)
+ else:
+ error = {
+ "header-revision": "invalid_snapshot_database",
+ "missing-original": "invalid_snapshot_database",
+ "original-operation": "incomplete_snapshot_evidence",
+ }.get(change, "invalid_snapshot_records")
+ with pytest.raises(DatabaseRejected, match=error):
+ SQLiteSnapshots(store, tmp_path / "bad", store.installation, Clock()).create("bad")
+ assert store.path.read_bytes() == original
+
+
+@pytest.mark.parametrize("claim", ["last-run", "range", "consumed"])
+def test_nominal_date_progress_also_requires_origin_for_any_execution_claim(tmp_path, claim):
+ _, store, _, _, rid, _ = dated(tmp_path)
+ with store.read() as unit:
+ progress = unit.recurrence.progress("home", rid)
+ changes = {
+ "last-run": {"last_run_at": 100},
+ "range": {"range_complete": True},
+ "consumed": {"cursor": replace(progress.cursor, offset=1, nominal_count=1)},
+ }
+ with pytest.raises(Conflict, match="recurrence_cursor_mismatch"):
+ with store.write() as unit:
+ unit.recurrence.save_progress(replace(progress, revision=2, **changes[claim]), 1)
+ with store.read() as unit:
+ assert unit.recurrence.progress("home", rid) == progress
+
+
+@pytest.mark.parametrize("stage", ["insert", "update"])
+def test_evidence_header_failure_rolls_back_progress_tasks_and_original_operation(
+ tmp_path, monkeypatch, stage
+):
+ from domus.infrastructure.sqlite_recurrence import SQLiteRecurrences
+
+ service, store, rid, before = configured(tmp_path)
+ if stage == "update":
+ service.step(rid, before)
+ with store.read() as unit:
+ previous = unit.recurrence.progress("home", rid)
+ initial = counts(store)
+ save = SQLiteRecurrences.save_progress
+
+ def fail_after_header(self, *args):
+ save(self, *args)
+ assert (
+ self.connection.execute("SELECT count(*) FROM recurrence_progress_evidence").fetchone()[
+ 0
+ ]
+ == 1
+ )
+ raise StorageFailure("injected")
+
+ with monkeypatch.context() as patch:
+ patch.setattr(SQLiteRecurrences, "save_progress", fail_after_header)
+ with pytest.raises(StorageFailure, match="injected"):
+ service.step(rid, before)
+ with store.read() as unit:
+ assert unit.recurrence.progress("home", rid) == previous
+ assert counts(store) == initial
+ assert service.step(rid, before).status == "created"
+
+
+def test_empty_utc_periods_close_only_after_the_conservative_lower_bound_proves_exhaustion():
+ from test_fixed_recurrence_planning import pattern, planner, policy
+
+ from domus.domain.recurrence_planning import CursorTimeEvidence
+
+ rule = pattern(
+ "FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=30;UNTIL=20260103T000000Z",
+ NominalTime(datetime(2026, 1, 1, 12)),
+ )
+ zone = policy("UTC")
+ first = planner().expand(rule, zone, period_limit=1)
+ assert not first.cursor.closed and first.cursor.nominal_count == 0
+ first_core = CursorTimeEvidence(first.cursor, zone, first.range_complete)
+ assert accepts_fixed_cursor(rule, first.cursor, first_core)
+ initial = FixedCursor(first.cursor.binding, closed=True)
+ assert not accepts_fixed_cursor(rule, initial, CursorTimeEvidence(initial, zone, True))
+ final = planner().expand(rule, zone, cursor=first.cursor)
+ assert final.cursor.closed and final.terminal is None
+ assert accepts_fixed_cursor(rule, final.cursor, CursorTimeEvidence(final.cursor, zone, True))
+
+
+def test_utc_natural_calendar_exhaustion_needs_no_fabricated_terminal_instant():
+ from test_fixed_recurrence_planning import pattern, planner, policy
+
+ from domus.domain.recurrence_planning import CursorTimeEvidence
+
+ rule = pattern("FREQ=DAILY", NominalTime(datetime(9999, 12, 31, 12)))
+ zone = policy("UTC")
+ rule = replace(rule, until=planner().times.resolve(rule.start, zone).instant_ms)
+ first = planner().expand(rule, zone, slot_limit=1)
+ final = planner().expand(rule, zone, cursor=first.cursor)
+ core = CursorTimeEvidence(final.cursor, zone, True, first.slots[-1])
+ assert final.cursor.closed and final.terminal is None
+ assert accepts_fixed_cursor(rule, final.cursor, core)
+
+
+@pytest.mark.parametrize("change", ["absent", "cursor"])
+def test_replay_cannot_consume_an_altered_original_temporal_boundary(tmp_path, change):
+ from test_fixed_materialization import rewind
+
+ service, store, rid, before, _, progress = facts(tmp_path)
+ original = progress.evidence
+ with store.read() as unit:
+ receipt = unit.receipts.get(original.command)
+ rewind(store, rid)
+ data = receipt.result.decode()
+ if change == "absent":
+ del data["cursor_evidence"]
+ else:
+ data["cursor_evidence"]["cursor"]["nominal_count"] = 0
+ with store._connection() as connection:
+ connection.execute(
+ "UPDATE receipts SET result=? WHERE operation_id=?",
+ (JsonDocument.from_value(data).encoded, receipt.operation_id),
+ )
+ initial = counts(store)
+ with pytest.raises(StorageFailure, match="invalid_recurrence_effect"):
+ service.step(rid, before)
+ assert counts(store) == initial
+ with store.read() as unit:
+ current = unit.recurrence.progress("home", rid)
+ assert current.pristine
+
+
+def test_first_excluded_slot_must_be_the_actual_cursor_candidate(tmp_path):
+ service, store, rid, before, rule, _ = facts(tmp_path)
+ service.step(rid, before)
+ service.step(rid, before)
+ with store.read() as unit:
+ progress = unit.recurrence.progress("home", rid)
+ core = progress.evidence.time
+ assert accepts_fixed_cursor(rule.pattern, progress.cursor, core)
+ assert not accepts_fixed_cursor(rule.pattern, progress.cursor, replace(core, terminal=None))
+ assert not accepts_fixed_cursor(
+ rule.pattern, progress.cursor, replace(core, terminal=core.last)
+ )
+
+
+@pytest.mark.parametrize("claim", [{"last_run_at": 100}, {"range_complete": True}])
+def test_utc_bootstrap_without_original_cannot_claim_a_completed_run(tmp_path, claim):
+ _, store, rid, _ = configured(tmp_path)
+ with store.read() as unit:
+ rule = unit.recurrence.schedule("home", rid)
+ progress = unit.recurrence.progress("home", rid)
+ assert progress.pristine and progress.matches_schedule(rule)
+ assert not replace(progress, **claim).matches_schedule(rule)
diff --git a/tests/backend/test_recurrence_until_proof.py b/tests/backend/test_recurrence_until_proof.py
new file mode 100644
index 0000000..fba2fa5
--- /dev/null
+++ b/tests/backend/test_recurrence_until_proof.py
@@ -0,0 +1,149 @@
+"""Instant UNTIL needs original resolved boundary proof, not an untrusted closed flag."""
+
+from dataclasses import replace
+from datetime import datetime
+
+import pytest
+from test_recurrence_creation import request, setup
+from test_sqlite_snapshots import Clock
+
+from domus.application.fixed_materialization import FixedMaintenance, MaintenanceScope
+from domus.domain.errors import Conflict, DatabaseRejected, StorageFailure
+from domus.domain.recurrence_time import NominalTime, TimePolicy
+from domus.infrastructure.sqlite_recurrence_records import progress_document
+from domus.infrastructure.sqlite_snapshots import SQLiteSnapshots
+
+
+def configured(tmp_path):
+ commands, _, store, _, ids = setup(tmp_path)
+ value = request(timed=True)
+ policy = TimePolicy("UTC", commands.planner.times.data_identity)
+ start = NominalTime(datetime(2026, 1, 1, 12))
+ end = commands.planner.times.resolve(NominalTime(datetime(2026, 1, 2, 12)), policy).instant_ms
+ value = replace(value, policy=policy, pattern=replace(value.pattern, start=start, until=end))
+ receipt = commands.create_fixed("time-until", value)
+ rule = receipt.result.decode()["schedule_id"]
+ service = FixedMaintenance(
+ store, MaintenanceScope(store.installation, "home"), commands.planner, commands.clock, ids
+ )
+ return service, store, rule, NominalTime(datetime(2026, 1, 5, 12))
+
+
+@pytest.mark.parametrize(
+ "change", ["closed-initial", "consumed-after-cutoff", "consumed-after-cutoff-closed"]
+)
+@pytest.mark.parametrize("surface", ["value", "read", "write", "snapshot", "step"])
+def test_instant_until_rejects_unproved_exhaustion_and_consumption(tmp_path, change, surface):
+ service, store, rid, before = configured(tmp_path)
+ with store.read() as unit:
+ rule = unit.recurrence.schedule("home", rid)
+ progress = unit.recurrence.progress("home", rid)
+ cursor = (
+ replace(progress.cursor, closed=True)
+ if change == "closed-initial"
+ else replace(
+ progress.cursor,
+ period_index=2,
+ offset=1,
+ nominal_count=3,
+ closed=change.endswith("closed"),
+ )
+ )
+ altered = replace(progress, cursor=cursor, range_complete=cursor.closed)
+ if surface == "value":
+ assert not altered.matches_schedule(rule)
+ return
+ if surface == "write":
+ with pytest.raises(Conflict, match="recurrence_cursor_mismatch"):
+ with store.write() as unit:
+ unit.recurrence.save_progress(replace(altered, revision=2), 1)
+ return
+ with store._connection() as connection:
+ connection.execute(
+ "UPDATE recurrence_progress SET document=?", (progress_document(altered).encoded,)
+ )
+ original = store.path.read_bytes()
+ if surface == "read":
+ with store.read() as unit:
+ with pytest.raises(StorageFailure):
+ unit.recurrence.progress("home", rid)
+ elif surface == "snapshot":
+ with pytest.raises(DatabaseRejected, match="invalid_snapshot_records"):
+ SQLiteSnapshots(store, tmp_path / "bad", store.installation, Clock()).create("bad")
+ else:
+ with pytest.raises(StorageFailure):
+ service.step(rid, before)
+ assert store.path.read_bytes() == original
+ with store.read() as unit:
+ assert unit.tasks.occurrences("home") == ()
+
+
+def test_real_inclusive_until_saves_original_last_and_excluded_boundary_without_writer_provider_io(
+ tmp_path, monkeypatch
+):
+ from domus.infrastructure.recurrence_time import PinnedRecurrenceTime
+
+ service, store, rid, before = configured(tmp_path)
+ original_resolve = PinnedRecurrenceTime.resolve
+
+ def outside_writer(self, *args):
+ # An independent writer must still be admitted while timezone work executes.
+ with store.write():
+ pass
+ return original_resolve(self, *args)
+
+ monkeypatch.setattr(PinnedRecurrenceTime, "resolve", outside_writer)
+ first = service.step(rid, before)
+ assert first.status == "created"
+ second = service.step(rid, before)
+ assert second.status == "created"
+ final = service.step(rid, before)
+ assert final.cursor.closed
+ with store.read() as unit:
+ rule = unit.recurrence.schedule("home", rid)
+ progress = unit.recurrence.progress("home", rid)
+ evidence = progress.evidence
+ assert evidence is not None and evidence.operation_id == final.receipt.operation_id
+ assert evidence.time.last.resolved.instant_ms == rule.pattern.until
+ assert evidence.time.terminal.resolved.instant_ms > rule.pattern.until
+ assert evidence.time.cursor == progress.cursor and progress.matches_schedule(rule)
+ assert service.step(rid, before).status == "current"
+ assert (
+ SQLiteSnapshots(store, tmp_path / "good", store.installation, Clock())
+ .create("good")
+ .database_bytes
+ > 0
+ )
+
+
+def test_exact_prior_unreleased_layout_is_refused_unchanged_before_temporal_evidence_use(tmp_path):
+ import hashlib
+ import sqlite3
+ from contextlib import closing
+ from pathlib import Path
+
+ from domus.domain.storage import Installation
+ from domus.infrastructure.sqlite import SQLiteDatabase
+
+ source = Path("tests/fixtures/sqlite/unreleased-b2bcc69-schema8.sql")
+ assert (
+ hashlib.sha256(source.read_bytes()).hexdigest()
+ == "6c33c79693647e6fed34e693c943012e28ef05a4b153b8901b7e35014105cd63"
+ )
+ path = tmp_path / "prior.sqlite3"
+ with closing(sqlite3.connect(path, autocommit=True)) as c:
+ c.executescript(source.read_text())
+ identity = Installation(
+ *c.execute("SELECT instance_id,channel FROM installation").fetchone()
+ )
+ assert c.execute("SELECT COUNT(*) FROM recurrence_slots").fetchone()[0] == 2
+ before = path.read_bytes()
+ store = SQLiteDatabase(path, identity)
+ for method in (store.read, store.write):
+ with pytest.raises(DatabaseRejected, match="unrelated_database"):
+ with method():
+ pytest.fail("Unknown unreleased layout was admitted")
+ assert path.read_bytes() == before
+ with pytest.raises(DatabaseRejected, match="unrelated_database"):
+ store.initialize(require_existing=True)
+ assert path.read_bytes() == before
diff --git a/tests/backend/test_sqlite_recurrence.py b/tests/backend/test_sqlite_recurrence.py
index 43365ac..36f1754 100644
--- a/tests/backend/test_sqlite_recurrence.py
+++ b/tests/backend/test_sqlite_recurrence.py
@@ -94,7 +94,7 @@ def test_rule_progress_slot_and_task_work_roll_back_as_one_transaction(tmp_path)
)
unit.tasks.save_occurrence(generated, 0)
unit.recurrence.save_schedule(replace(rule, state=ScheduleState.PAUSED, revision=2), 1)
- unit.recurrence.save_progress(replace(progress, revision=2, last_run_at=110), 1)
+ unit.recurrence.save_progress(replace(progress, revision=2), 1)
save_slot(unit, original)
raise RuntimeError("injected")
with store.read() as unit:
@@ -111,7 +111,7 @@ def test_rule_and_progress_compare_and_swap_preserve_immutable_scope_and_creatio
with store.write() as unit:
changed = replace(rule, state=ScheduleState.PAUSED, revision=2)
unit.recurrence.save_schedule(changed, 1)
- unit.recurrence.save_progress(replace(progress, revision=2, last_run_at=110), 1)
+ unit.recurrence.save_progress(replace(progress, revision=2), 1)
for value, expected in [
(rule, 0),
(changed, 1),
diff --git a/tests/backend/test_sqlite_task_reviews.py b/tests/backend/test_sqlite_task_reviews.py
index e64aba6..da146bc 100644
--- a/tests/backend/test_sqlite_task_reviews.py
+++ b/tests/backend/test_sqlite_task_reviews.py
@@ -50,6 +50,7 @@ def test_original_schema6_migration_preserves_rows_and_projects_existing_cause(t
"recurrence_progress",
"recurrence_slots",
"recurrence_frontiers",
+ "recurrence_progress_evidence",
}
assert set(upgraded) == set(original) | added
assert all(upgraded[table] == values for table, values in original.items())
diff --git a/tests/containers/test_recurrence_image.py b/tests/containers/test_recurrence_image.py
index cd61f83..3ebfcaa 100644
--- a/tests/containers/test_recurrence_image.py
+++ b/tests/containers/test_recurrence_image.py
@@ -190,7 +190,8 @@ async def serve_once(runtime):
with store.read() as unit:
original_progress = unit.recurrence.progress('home', facts['schedule_id'])
altered = replace(original_progress,
- cursor=replace(original_progress.cursor, period_index=original_progress.cursor.period_index+100))
+ cursor=replace(original_progress.cursor,
+ period_index=original_progress.cursor.period_index+100))
with store._connection() as connection:
connection.execute('UPDATE recurrence_progress SET document=? WHERE schedule_id=?',
(progress_document(altered).encoded, facts['schedule_id']))
@@ -209,6 +210,53 @@ async def serve_once(runtime):
else:
raise AssertionError('Checkpoint accepted inconsistent progress')
assert store.path.read_bytes() == source_bytes
+with TemporaryDirectory(prefix='domus-current-until-') as directory:
+ store = SQLiteDatabase(Path(directory)/'domus.sqlite3', installation)
+ store.initialize()
+ HouseholdCommands(store, human, UnusedDirectory(), TimeZoneCatalog(), clock, identifiers).setup(
+ 'setup', SetupHousehold('Fictional UTC boundary fixture', 'UTC'))
+ creation = RecurrenceCommands(store, human, clock, identifiers,
+ FixedRecurrencePlanner(CalendarNominalExpansion(), times))
+ utc_policy = TimePolicy('UTC', TIMEZONE_DATA_IDENTITY)
+ utc_pattern = parse_fixed_pattern('FREQ=DAILY;UNTIL=20260102T120000Z',
+ NominalTime(datetime(2026, 1, 1, 12)))
+ request = CreateFixedTask(CreateTask(TaskContent('UTC inclusive boundary')),
+ utc_pattern, utc_policy)
+ rid = creation.create_fixed('utc-create', request).result.decode()['schedule_id']
+ maintenance = FixedMaintenance(store, MaintenanceScope(installation, 'home'),
+ creation.planner, clock, identifiers)
+ horizon = NominalTime(datetime(2026, 1, 5, 12))
+ assert maintenance.step(rid, horizon).status == 'created'
+ included = maintenance.step(rid, horizon)
+ assert not included.cursor.closed and included.cursor.nominal_count == 2
+ terminal = maintenance.step(rid, horizon)
+ assert terminal.cursor.closed and terminal.status == 'scan'
+ with store.read() as unit:
+ progress = unit.recurrence.progress('home', rid)
+ evidence = progress.evidence
+ assert evidence.time.last.resolved.instant_ms == utc_pattern.until
+ assert evidence.time.terminal.resolved.instant_ms > utc_pattern.until
+ assert unit.receipts.get(evidence.command).operation_id == evidence.operation_id
+ assert len(unit.tasks.occurrences('home')) == 2
+ assert maintenance.step(rid, horizon).status == 'current'
+ assert SQLiteSnapshots(store, Path(directory)/'good', installation, clock).create('good')
+ with store._connection() as connection:
+ connection.execute('DELETE FROM recurrence_progress_evidence')
+ source_bytes = store.path.read_bytes()
+ try:
+ with store.read() as unit:
+ unit.recurrence.progress('home', rid)
+ except StorageFailure:
+ pass
+ else:
+ raise AssertionError('UTC progress lost its original evidence header')
+ try:
+ SQLiteSnapshots(store, Path(directory)/'bad', installation, clock).create('bad')
+ except DatabaseRejected as error:
+ assert error.code == 'invalid_snapshot_records'
+ else:
+ raise AssertionError('Checkpoint accepted unproved UTC continuation')
+ assert store.path.read_bytes() == source_bytes
print('Current-source recurrence, both owned runtimes and pinned-time codecs passed.')
""",
)
diff --git a/tests/fixtures/sqlite/unreleased-b2bcc69-schema8.sql b/tests/fixtures/sqlite/unreleased-b2bcc69-schema8.sql
new file mode 100644
index 0000000..3caf269
--- /dev/null
+++ b/tests/fixtures/sqlite/unreleased-b2bcc69-schema8.sql
@@ -0,0 +1,71 @@
+-- Exact original database header values, omitted by SQLite iterdump.
+PRAGMA application_id=1146047829;
+PRAGMA user_version=8;
+BEGIN TRANSACTION;
+CREATE TABLE audit(operation_id TEXT PRIMARY KEY, household_id TEXT NOT NULL REFERENCES households(household_id), actor_id TEXT NOT NULL, action TEXT NOT NULL, command_key TEXT NOT NULL, detail TEXT NOT NULL, created_at INTEGER NOT NULL);
+INSERT INTO "audit" VALUES('identity-2','home','ha_admin','household.setup','setup','{"administrator_member_id":"identity-1","mapping_mode":"all_ha_administrators","transition":"household.configured"}',100);
+INSERT INTO "audit" VALUES('identity-5','home','ha_admin','recurrence.fixed.create','source8-create','{"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":"Clean the filter"},"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":"identity-3","first_occurrence_id":null,"pattern_revision":1,"processing":"pending","revision":1,"schedule_id":"identity-4","state":"active"},"transition":"recurrence.fixed.created"}',100);
+INSERT INTO "audit" VALUES('identity-7','home','system:recurrence','recurrence.materialize','259a69c04141d744e9458abb6accd428b26969ec1bf245334de7e9ea7435b11a','{"request":{"definition_id":"identity-3","nominal":{"kind":"time","local":"2026-03-29T02:30:00"},"pattern":"92d046b4f7ae037572eb6a31467ba74a3041498a271dfa1cfb1ddc5c58b21727","resolved_at":1774746000000,"slot_key":"259a69c04141d744e9458abb6accd428b26969ec1bf245334de7e9ea7435b11a","version":1,"zone_digest":"ca5b321ddbfc88e07e0d03ed2fa0c832ce5d0dd8e7d90a25200a8e24898c3b21"},"result":{"definition_id":"identity-3","nominal":{"kind":"time","local":"2026-03-29T02:30:00"},"occurrence_id":"identity-6","resolved_at":1774746000000,"schedule_id":"identity-4","slot_key":"259a69c04141d744e9458abb6accd428b26969ec1bf245334de7e9ea7435b11a"}}',100);
+INSERT INTO "audit" VALUES('identity-9','home','system:recurrence','recurrence.materialize','d2d8c76a6d2ab2deefbf2d432cb0a4c2e44b50802e11af25dac2c964f7108758','{"request":{"definition_id":"identity-3","nominal":{"kind":"time","local":"2026-03-30T02:30:00"},"pattern":"92d046b4f7ae037572eb6a31467ba74a3041498a271dfa1cfb1ddc5c58b21727","resolved_at":1774830600000,"slot_key":"d2d8c76a6d2ab2deefbf2d432cb0a4c2e44b50802e11af25dac2c964f7108758","version":1,"zone_digest":"ca5b321ddbfc88e07e0d03ed2fa0c832ce5d0dd8e7d90a25200a8e24898c3b21"},"result":{"definition_id":"identity-3","nominal":{"kind":"time","local":"2026-03-30T02:30:00"},"occurrence_id":"identity-8","resolved_at":1774830600000,"schedule_id":"identity-4","slot_key":"d2d8c76a6d2ab2deefbf2d432cb0a4c2e44b50802e11af25dac2c964f7108758"}}',100);
+INSERT INTO "audit" VALUES('identity-10','home','system:recurrence','recurrence.scan','dd348639665730f015c1e34a2ed0efaa4966bb04084d06a176e0b68ae7f38a83','{"request":{"before":{"kind":"time","local":"2026-04-02T02:30:00"},"from_cursor":{"binding":"6bd1edfcf2c489f27216f5c1f782f53a052d26823e3127112d9c9e11f71bbcf6","closed":false,"nominal_count":2,"offset":1,"period_index":1},"from_revision":3,"pattern_revision":1,"schedule_id":"identity-4","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},"processed_slots":0,"range_complete":true,"schedule_id":"identity-4"}}',100);
+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,'Home','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,'047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6','edge');
+CREATE TABLE members(member_id TEXT PRIMARY KEY, household_id TEXT NOT NULL REFERENCES households(household_id), ha_user_id TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('administrator','adult','restricted','viewer')), status TEXT NOT NULL CHECK(status IN ('pending','active','suspended','archived')), revision INTEGER NOT NULL CHECK(revision>=1), exceptions TEXT NOT NULL, UNIQUE(household_id,ha_user_id));
+INSERT INTO "members" VALUES('identity-1','home','ha_admin','viewer','active',1,'{"rules":[]}');
+INSERT INTO "members" VALUES('child','home','ha_child','restricted','active',1,'{"rules":[]}');
+INSERT INTO "members" VALUES('other','home','ha_other','restricted','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,'identity-2','home','household.setup','{"household_id":"home","revision":1}',100,0);
+INSERT INTO "outbox" VALUES(2,'identity-5','home','recurrence.fixed.create','{"definition_id":"identity-3","first_occurrence_id":null,"kind":"recurrence.fixed.created","pattern_revision":1,"processing":"pending","revision":1,"schedule_id":"identity-4","state":"active","version":1}',100,0);
+INSERT INTO "outbox" VALUES(3,'identity-7','home','recurrence.materialize','{"definition_id":"identity-3","kind":"recurrence.materialize","nominal":{"kind":"time","local":"2026-03-29T02:30:00"},"occurrence_id":"identity-6","resolved_at":1774746000000,"schedule_id":"identity-4","slot_key":"259a69c04141d744e9458abb6accd428b26969ec1bf245334de7e9ea7435b11a","version":1}',100,0);
+INSERT INTO "outbox" VALUES(4,'identity-9','home','recurrence.materialize','{"definition_id":"identity-3","kind":"recurrence.materialize","nominal":{"kind":"time","local":"2026-03-30T02:30:00"},"occurrence_id":"identity-8","resolved_at":1774830600000,"schedule_id":"identity-4","slot_key":"d2d8c76a6d2ab2deefbf2d432cb0a4c2e44b50802e11af25dac2c964f7108758","version":1}',100,0);
+INSERT INTO "outbox" VALUES(5,'identity-10','home','recurrence.scan','{"cursor":{"binding":"6bd1edfcf2c489f27216f5c1f782f53a052d26823e3127112d9c9e11f71bbcf6","closed":true,"nominal_count":2,"offset":0,"period_index":2},"kind":"recurrence.scan","processed_slots":0,"range_complete":true,"schedule_id":"identity-4","version":1}',100,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','ha_admin','household.setup','setup','fd81d9a903eff49c605ff960bfae80f61f684b2f1f12ff15fb363cd7bdea3c51','identity-2','{"administrator_member_id":"identity-1","household_id":"home","mapping_mode":"all_ha_administrators","member_id":"identity-1","policy_revision":1,"revision":1,"state":"configured"}',100);
+INSERT INTO "receipts" VALUES('home','ha_admin','recurrence.fixed.create','source8-create','3dabb4a2d54b0864f80411a1ac6f95d4a090369bbe039e3b212aaec0b1366cc1','identity-5','{"definition_id":"identity-3","first_occurrence_id":null,"pattern_revision":1,"processing":"pending","revision":1,"schedule_id":"identity-4","state":"active"}',100);
+INSERT INTO "receipts" VALUES('home','system:recurrence','recurrence.materialize','259a69c04141d744e9458abb6accd428b26969ec1bf245334de7e9ea7435b11a','47f75faa1aba7c1f38c59e3d46abec0702bc0663a5b7cf149ede3f44e000da74','identity-7','{"definition_id":"identity-3","nominal":{"kind":"time","local":"2026-03-29T02:30:00"},"occurrence_id":"identity-6","resolved_at":1774746000000,"schedule_id":"identity-4","slot_key":"259a69c04141d744e9458abb6accd428b26969ec1bf245334de7e9ea7435b11a"}',100);
+INSERT INTO "receipts" VALUES('home','system:recurrence','recurrence.materialize','d2d8c76a6d2ab2deefbf2d432cb0a4c2e44b50802e11af25dac2c964f7108758','83edd3aa06dbb46082d8051b290fa554c176bd1614ceda277b6c46f36f28b93e','identity-9','{"definition_id":"identity-3","nominal":{"kind":"time","local":"2026-03-30T02:30:00"},"occurrence_id":"identity-8","resolved_at":1774830600000,"schedule_id":"identity-4","slot_key":"d2d8c76a6d2ab2deefbf2d432cb0a4c2e44b50802e11af25dac2c964f7108758"}',100);
+INSERT INTO "receipts" VALUES('home','system:recurrence','recurrence.scan','dd348639665730f015c1e34a2ed0efaa4966bb04084d06a176e0b68ae7f38a83','dd348639665730f015c1e34a2ed0efaa4966bb04084d06a176e0b68ae7f38a83','identity-10','{"cursor":{"binding":"6bd1edfcf2c489f27216f5c1f782f53a052d26823e3127112d9c9e11f71bbcf6","closed":true,"nominal_count":2,"offset":0,"period_index":2},"processed_slots":0,"range_complete":true,"schedule_id":"identity-4"}',100);
+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), document TEXT NOT NULL, UNIQUE(household_id,schedule_id,pattern_revision), FOREIGN KEY(household_id,schedule_id,pattern_revision) REFERENCES recurrence_rules(household_id,schedule_id,pattern_revision) DEFERRABLE INITIALLY DEFERRED);
+INSERT INTO "recurrence_progress" VALUES('identity-4','home',1,4,'{"cursor":{"binding":"6bd1edfcf2c489f27216f5c1f782f53a052d26823e3127112d9c9e11f71bbcf6","closed":true,"nominal_count":2,"offset":0,"period_index":2},"household_id":"home","last_run_at":100,"pattern_revision":1,"range_complete":true,"revision":4,"schedule_id":"identity-4","version":1}');
+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')), document TEXT NOT NULL, UNIQUE(household_id,schedule_id), UNIQUE(household_id,schedule_id,definition_id), UNIQUE(household_id,schedule_id,pattern_revision), FOREIGN KEY(household_id,definition_id) REFERENCES task_definitions(household_id,definition_id), FOREIGN KEY(household_id,schedule_id,pattern_revision) REFERENCES recurrence_progress(household_id,schedule_id,pattern_revision) DEFERRABLE INITIALLY DEFERRED);
+INSERT INTO "recurrence_rules" VALUES('identity-4','home','identity-3',1,1,'active','{"created_at":100,"definition_id":"identity-3","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":"identity-4","state":"active","version":1}');
+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'), 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) REFERENCES recurrence_rules(household_id,schedule_id,definition_id), 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','259a69c04141d744e9458abb6accd428b26969ec1bf245334de7e9ea7435b11a','identity-4','identity-3',1,'identity-6','time','2026-03-29T02:30:00',1774746000000,100,'system:recurrence','recurrence.materialize','{"created_at":100,"installation":{"channel":"edge","instance_id":"047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6"},"occurrence_id":"identity-6","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":100,"definition_id":"identity-3","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":"identity-4","state":"active","version":1},"version":1}');
+INSERT INTO "recurrence_slots" VALUES('home','d2d8c76a6d2ab2deefbf2d432cb0a4c2e44b50802e11af25dac2c964f7108758','identity-4','identity-3',1,'identity-8','time','2026-03-30T02:30:00',1774830600000,100,'system:recurrence','recurrence.materialize','{"created_at":100,"installation":{"channel":"edge","instance_id":"047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6"},"occurrence_id":"identity-8","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":100,"definition_id":"identity-3","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":"identity-4","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('identity-3','home',1,'active','{"content":{"description":"","priority":"normal","tags":[],"title":"Clean the filter"},"created_at":100,"definition_id":"identity-3","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('identity-6','home','identity-3',1,'available',NULL,NULL,'{"assignee_ids":[],"created_at":100,"current_cycle_id":null,"current_cycle_revision":null,"definition_snapshot":{"content":{"description":"","priority":"normal","tags":[],"title":"Clean the filter"},"created_at":100,"definition_id":"identity-3","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":"identity-6","prerequisite_ids":[],"required_subtask_ids":[],"revision":1,"state":"available","state_reason":"","version":2}');
+INSERT INTO "task_occurrences" VALUES('identity-8','home','identity-3',1,'available',NULL,NULL,'{"assignee_ids":[],"created_at":100,"current_cycle_id":null,"current_cycle_revision":null,"definition_snapshot":{"content":{"description":"","priority":"normal","tags":[],"title":"Clean the filter"},"created_at":100,"definition_id":"identity-3","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":"identity-8","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 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);
+DELETE FROM "sqlite_sequence";
+INSERT INTO "sqlite_sequence" VALUES('outbox',5);
+COMMIT;
From 06b3b9f38955a25be2511b68b2ea667a84a42d8f Mon Sep 17 00:00:00 2001
From: Efra Espada
Date: Mon, 2 Nov 2026 12:01:04 +0100
Subject: [PATCH 13/16] test: Retain actual setup responses before browser body
observation
---
docs/evidence/recurrence-storage.json | 2 +-
docs/evidence/recurrence-until-integrity.json | 188 +++++++++++++++++-
frontend/tests/e2e/entry.spec.ts | 24 ++-
specs/recurrence-storage-continuation.md | 2 +
4 files changed, 208 insertions(+), 8 deletions(-)
diff --git a/docs/evidence/recurrence-storage.json b/docs/evidence/recurrence-storage.json
index f04b735..b625caf 100644
--- a/docs/evidence/recurrence-storage.json
+++ b/docs/evidence/recurrence-storage.json
@@ -85,7 +85,7 @@
"policyModulesByteVerified": 7,
"immutableBaseOwners": 18,
"retainedOwnersUnchanged": true,
- "currentCompanionSHA256": "138f2b96f989fed4ac0d7eafb896895449304c698f74a0693dd8dc01abd9597a",
+ "currentCompanionSHA256": "233ad9bfca0af9975174dc4b058a6261a2c951aff9c516c570abe5fb21e2ec13",
"scope": "Explicit contributor integration; original first publication remains in Git. Format, reciprocal inventory/path consistency and current catalog pass."
},
"packageAndArchitectureTests": {
diff --git a/docs/evidence/recurrence-until-integrity.json b/docs/evidence/recurrence-until-integrity.json
index 8b74ddd..9b53e72 100644
--- a/docs/evidence/recurrence-until-integrity.json
+++ b/docs/evidence/recurrence-until-integrity.json
@@ -11,7 +11,7 @@
"https://github.com/vypdev/domus/pull/22#discussion_r4441091431",
"https://github.com/vypdev/domus/pull/22#discussion_r4441091443"
],
- "assessment": "Both are valid consumer boundary defects. Source corrected; fresh native verification required. Neither is dismissed or reported as a Copilot defect."
+ "assessment": "Both valid UTC findings confirmed fixed by native review at a6ba026; all5 product finding threads resolved,0 open. Overall native review remains partial due to the separately recorded human-context bound."
},
"implemented": [
"UTC UNTIL requires last-included resolved proof at the exact consumed nominal rank and at/before the inclusive cutoff. Closed progress additionally requires the first excluded resolved candidate, a conservative exhausted-period lower bound or natural calendar exhaustion.",
@@ -121,7 +121,13 @@
},
"images": {
"exactCurrentScriptOnHost": "passed; fixed/fair materialization, both owned runtimes, relative read-only planning, UTC inclusive/terminal evidence and missing-header/checkpoint refusal",
- "freshHosted": "Required for this correction after publication; local host script is not actual CPU-image proof.",
+ "freshHosted": {
+ "head": "a6ba0263551e2d19ba07574f46bdbd7c6b9b1532",
+ "runId": 41432261783,
+ "amd64Job": 125233464313,
+ "aarch64Job": 125233464190,
+ "result": "Both passed actual current-source CPU-image controls. Aggregate failed only on the independent browser CDP observation; its test-only correction requires fresh CI."
+ },
"previousB2": {
"runId": 41425773817,
"amd64": 125211894187,
@@ -170,5 +176,181 @@
"privateCampaign": "/private/tmp/domus-time-until-b2bcc69",
"consumers": "CurrentPR22 UTC boundary review and rollback: exact original b2 wheel, immutable original schema8 database/provenance, corrected logical fixture/erratum, original and current tests, coverage/source/SDK proofs and image-host/package checks. Compact completed producers after publication; keep authored original input and necessary evidence."
},
- "pending": "Fresh CI/native verification; relative rule/slot/progress persistence, atomic initial creation, durable source inbox/closure/invalidation, historical source reconciliation and mixed-rule global fairness/runtime composition; protected calendar/operations, reviewed schedule changes, household timing defaults, full retained52+37 cases and all-domain UI/HA/Stable-Edge acceptance."
+ "pending": "Fresh CI/native verification; relative rule/slot/progress persistence, atomic initial creation, durable source inbox/closure/invalidation, historical source reconciliation and mixed-rule global fairness/runtime composition; protected calendar/operations, reviewed schedule changes, household timing defaults, full retained52+37 cases and all-domain UI/HA/Stable-Edge acceptance.",
+ "nativeReviewA6": {
+ "schemaVersion": 1,
+ "reviewId": "vypdev:domus:pr-22:a6ba0263551e",
+ "repository": "vypdev/domus",
+ "repositoryId": 1437483050,
+ "triggerKind": "pull_request",
+ "pullRequestNumber": 22,
+ "headSha": "a6ba0263551e2d19ba07574f46bdbd7c6b9b1532",
+ "publicationMode": "publish",
+ "configuredEffort": "smart",
+ "agentProvider": "codex",
+ "agentModel": "gpt-6-luna",
+ "startedAt": "2026-11-02T10:52:51.489Z",
+ "elapsedMs": 192051,
+ "stagesMs": {
+ "context-preflight": 421,
+ "context": 1068,
+ "analysis": 178134,
+ "normalization": 1,
+ "freshness": 575,
+ "publication": 7349,
+ "post-publication-freshness": 402,
+ "projection": 4092
+ },
+ "promptCharacters": 869837,
+ "responseCharacters": 2605,
+ "estimatedInputTokens": 217460,
+ "estimatedOutputTokens": 652,
+ "changedFiles": 91,
+ "changedLines": 10161,
+ "rulesLoaded": 0,
+ "contextSelectionReason": "event",
+ "contextCandidateBucket": "1",
+ "contextCoverageStatus": "partial",
+ "contextCoverage": {
+ "selection": {
+ "status": "complete",
+ "pagesFetched": 1,
+ "itemsFetched": 1,
+ "itemsRetained": 1,
+ "omittedItems": 0,
+ "truncatedItems": 0,
+ "limitReached": false
+ },
+ "issue-comments": {
+ "status": "complete",
+ "pagesFetched": 1,
+ "itemsFetched": 6,
+ "itemsRetained": 6,
+ "omittedItems": 0,
+ "truncatedItems": 0,
+ "limitReached": false
+ },
+ "pull-request-comments": {
+ "status": "complete",
+ "pagesFetched": 1,
+ "itemsFetched": 8,
+ "itemsRetained": 8,
+ "omittedItems": 0,
+ "truncatedItems": 0,
+ "limitReached": false
+ },
+ "review-threads": {
+ "status": "complete",
+ "pagesFetched": 1,
+ "itemsFetched": 5,
+ "itemsRetained": 5,
+ "omittedItems": 0,
+ "truncatedItems": 0,
+ "limitReached": false
+ },
+ "diff": {
+ "status": "complete",
+ "pagesFetched": 1,
+ "itemsFetched": 91,
+ "itemsRetained": 91,
+ "omittedItems": 0,
+ "truncatedItems": 0,
+ "limitReached": false
+ },
+ "previous-findings": {
+ "status": "complete",
+ "pagesFetched": 1,
+ "itemsFetched": 2,
+ "itemsRetained": 2,
+ "omittedItems": 0,
+ "truncatedItems": 0,
+ "limitReached": false
+ },
+ "human-conversation": {
+ "status": "partial",
+ "pagesFetched": 1,
+ "itemsFetched": 5,
+ "itemsRetained": 3,
+ "omittedItems": 2,
+ "truncatedItems": 0,
+ "limitReached": true
+ },
+ "rules": {
+ "status": "complete",
+ "pagesFetched": 0,
+ "itemsFetched": 0,
+ "itemsRetained": 0,
+ "omittedItems": 0,
+ "truncatedItems": 0,
+ "limitReached": false
+ }
+ },
+ "contextLogicalProviderReads": 5,
+ "contextRawProviderRequests": 5,
+ "contextConcurrencyLimit": 2,
+ "analysisPartitions": 11,
+ "completedAnalysisPartitions": 11,
+ "analysisDiffFragments": 109,
+ "analysisAssignedFiles": 91,
+ "maximumAnalysisConcurrency": 2,
+ "candidateFindings": 0,
+ "publishedFindings": 0,
+ "overflowFindings": 0,
+ "resolvedFindings": 2,
+ "findingStates": {
+ "open": 0,
+ "reopened": 0,
+ "fixed": 5,
+ "obsolete": 0,
+ "dismissed": 0,
+ "verification-required": 0,
+ "unknown": 0
+ },
+ "outcome": "partial"
+ },
+ "browserObservationRepair": {
+ "base": "a6ba0263551e2d19ba07574f46bdbd7c6b9b1532",
+ "failedRun": 41432261783,
+ "failedJob": 125233464397,
+ "originalArtifact": 13135045868,
+ "originalArtifactSHA256": "37b7e5ce505c92a0b66e2059197bd270e0d07e6d20fdc60635ac003dde4f806d",
+ "observation": "48 browser journeys passed; the standalone same-account writer case received503, then Playwright Network.getResponseBody could not retrieve its body. The retained UI shows Request not accepted, preserved draft and rejected journal entry. Trace body content is unavailable; generic navigation guidance is not proof of actual unexpected navigation or an unlogged product stack.",
+ "change": "Contention tests buffer the actual upstream response before forwarding its exact status, headers and body to the browser. Route fetch permits0 retries and0 redirects. Browser status/Retry-After, actual storage_busy body, real independent SQLite contention, journal/draft identity, exact original replay/current authority, singleton and no-automatic-POST assertions remain.",
+ "sourceProof": {
+ "backend133ModulesUnchanged": true,
+ "inventorySHA256": "379103605c1e68b4ce45bb5f82a4ef4edbf6d7bc757261b4326706659daa2f11",
+ "frontendImplementationChanged": false,
+ "scope": "Only browser test observations, companion text and evidence change. Product-source coverage3881/100% and actual a6 images remain tied to the same application bytes; fresh current-head CI remains required."
+ },
+ "validation": {
+ "browserExecutions": 49,
+ "passed": 49,
+ "durationSeconds": 29.3,
+ "strictTypes": "runtime/catalog passed",
+ "ruff": "final check/format passed",
+ "originalPinnedSdd": {
+ "upstream": "3e7ceea08e6131360e026937d1c0cc1b120472e0",
+ "base": "0806a38f8a2de1f102a017bff25ad6856fb17bcd",
+ "selected": "specs/recurrence-storage-continuation.md",
+ "originalPolicyModulesVerified": 7,
+ "format": "PASS",
+ "consistency": "PASS",
+ "composition": "explicit contributor integration; original publication retained in Git",
+ "unchangedCatalog": "PASS",
+ "immutableOwnerDocumentsUsed": 18,
+ "retainedCompanionCases": 52,
+ "newCases": 4,
+ "rawCharacters": 37552,
+ "renderedCharacters": 37552,
+ "composedCharacters": 37552,
+ "applicableDraftMaximumCharacters": 70000,
+ "sha256": "233ad9bfca0af9975174dc4b058a6261a2c951aff9c516c570abe5fb21e2ec13",
+ "scope": "Review input only; no gate admission, branch publication, source implementation or runtime acceptance.",
+ "retainedOwningFilesUnchanged": true,
+ "workingDocumentValidated": true
+ }
+ },
+ "privateEvidence": "/private/tmp/domus-response-body-a6ba026",
+ "scope": "Test observation repair; no production source, timeout, Copilot resource, dependency, browser version or retry policy changed."
+ }
}
diff --git a/frontend/tests/e2e/entry.spec.ts b/frontend/tests/e2e/entry.spec.ts
index 1a148c5..a768e44 100644
--- a/frontend/tests/e2e/entry.spec.ts
+++ b/frontend/tests/e2e/entry.spec.ts
@@ -1,5 +1,5 @@
import {expect, test} from '@playwright/test';
-import type {APIRequestContext, BrowserContext, Page, Response} from '@playwright/test';
+import type {APIRequestContext, APIResponse, BrowserContext, Page, Response, Route} from '@playwright/test';
const prefix = '/api/hassio_ingress/fixture_domus_supervisor_1234567890';
const journalKey = 'domus.entry.journal.v1';
@@ -29,6 +29,14 @@ async function create(page: Page, name = 'Fictional household') {
}
const setupResponse = (page: Page, mode: Mode) => page.waitForResponse(response =>
response.url() === base(mode) + '/api/v1/household' && response.request().method() === 'POST');
+async function forwardSetup(route: Route, observed: Map) {
+ // Buffer the actual upstream response before forwarding it unchanged. CI could not read
+ // the Chromium CDP body even though the application displayed and retained the rejection.
+ const response = await route.fetch({maxRetries: 0, maxRedirects: 0});
+ const body = await response.body();
+ observed.set(route.request().headers()['idempotency-key'], response);
+ await route.fulfill({response, body});
+}
async function replaySetup(page: Page, response: Response) {
const original = response.request(), headers = await original.allHeaders();
// An explicit raw-API replay keeps every command assertion; no UI retry or new key is inferred.
@@ -312,6 +320,7 @@ for (const mode of modes) test.describe(mode, () => {
const gate = new Promise(resolve => {release = resolve;});
const ready = new Promise(resolve => {arrived = resolve;});
const keys: string[] = [];
+ const observed = new Map();
try {
if (mode === 'standalone' && actors === 'different_accounts') await request.post(origin(mode) + '/__fixture__/identity', {data: {subject: 'c'.repeat(32)}});
await open(peerPage, peer, mode, true, (actors === 'different_accounts' ? 'e' : 'd').repeat(128));
@@ -320,7 +329,7 @@ for (const mode of modes) test.describe(mode, () => {
if (route.request().method() !== 'POST') return route.continue();
keys.push(route.request().headers()['idempotency-key']);
if (++count === 2) arrived();
- await gate; await route.continue();
+ await gate; await forwardSetup(route, observed);
});
}
const responses = Promise.all([page, peerPage].map(candidate => setupResponse(candidate, mode)));
@@ -329,8 +338,10 @@ for (const mode of modes) test.describe(mode, () => {
const replies = await responses, statuses: number[] = [];
// An active mapped member gets a conflict; a new unlinked HA admin gains no implicit membership.
for (const [index, reply] of replies.entries()) {
+ const upstream = observed.get(reply.request().headers()['idempotency-key'])!;
+ expect(upstream.status()).toBe(reply.status());
if (reply.status() === 503) {
- expect((await reply.json()).code).toBe('storage_busy');
+ expect((await upstream.json()).code).toBe('storage_busy');
expect(reply.headers()['retry-after']).toBe('1');
// The bounded writer may time out under CI load. After the winner completes, the exact
// original request still needs current authority and cannot create a second household.
@@ -362,16 +373,21 @@ for (const mode of modes) test.describe(mode, () => {
for (const actors of ['same_account', 'different_accounts'] as const) test(`bounded writer rejection retains the original ${actors} request and rechecks authority after another setup`, async ({page, context, browser, request}) => {
await open(page, context, mode);
const peer = await browser.newContext(), peerPage = await peer.newPage();
+ const observed = new Map();
let posts = 0;
page.on('request', item => {if (item.url() === base(mode) + '/api/v1/household' && item.method() === 'POST') posts++;});
try {
if (mode === 'standalone' && actors === 'different_accounts') await request.post(origin(mode) + '/__fixture__/identity', {data: {subject: 'c'.repeat(32)}});
await open(peerPage, peer, mode, true, (actors === 'different_accounts' ? 'e' : 'd').repeat(128));
+ await page.route(base(mode) + '/api/v1/household', route =>
+ route.request().method() === 'POST' ? forwardSetup(route, observed) : route.continue());
expect((await request.post(origin(mode) + '/__fixture__/writer', {data: {action: 'acquire'}})).status()).toBe(200);
const pending = setupResponse(page, mode);
await create(page, 'Retained fictional draft');
const rejected = await pending;
- expect(rejected.status()).toBe(503); expect((await rejected.json()).code).toBe('storage_busy');
+ const upstream = observed.get(rejected.request().headers()['idempotency-key'])!;
+ expect(upstream.status()).toBe(rejected.status());
+ expect(rejected.status()).toBe(503); expect((await upstream.json()).code).toBe('storage_busy');
expect(rejected.headers()['retry-after']).toBe('1');
await expect(page.getByRole('heading', {name: 'Request not accepted', exact: true})).toBeVisible();
await expect(page.getByLabel('Household name', {exact: true})).toHaveValue('Retained fictional draft');
diff --git a/specs/recurrence-storage-continuation.md b/specs/recurrence-storage-continuation.md
index 373d935..22db430 100644
--- a/specs/recurrence-storage-continuation.md
+++ b/specs/recurrence-storage-continuation.md
@@ -272,6 +272,8 @@ The worker carries cancellation into every run and joins before runtime identity
## 19. Definition of Done
+Browser CI later recorded a503 rejection whose body was unavailable through Chromium CDP, while the actual application showed the rejected outcome and preserved its draft. Retain that trace; its generic navigation guidance does not establish an actual unexpected navigation. Contention tests may buffer the actual upstream HTTP response before forwarding the same bytes, status and headers to the browser, with redirects and network retries disabled. Preserve real independent SQLite writer contention, browser response checks, exact request metadata, current-authorized replay, draft/journal identity and no-duplicate assertions. A test observation repair changes neither product behavior nor storage timeouts, and adds no counted product journey.
+
UTC UNTIL continuation additionally retains the actual last included resolved slot and, when discovered, the first excluded candidate at the cursor. The last included instant must not exceed the inclusive cutoff and its nominal rank must equal the consumed count. Closed progress requires the actual excluded boundary, a conservative exhausted-period lower bound or natural calendar exhaustion. A legitimate unclosed cursor after the final included slot remains valid until a later bounded step checks the end. Unproved bootstrap values cannot claim a run or a completed range.
Every actual fixed progress advance, including empty scans and date/count rules, binds its time evidence to the exact original scoped command and operation. Slot and scan receipts retain the evidence core; a current or restored historical original must match before progress is saved. A separate deferred revision-bound evidence header and strict canonical inline document must agree on reads and checkpoint validation. Providers resolve outside the writer; domain records perform no timezone I/O. Restored absent effects remain absent without new identities or delivery.
From 8b0aafda1a4cd8d1d63bf782b04f2709fb7db46c Mon Sep 17 00:00:00 2001
From: Efra Espada
Date: Mon, 2 Nov 2026 12:18:31 +0100
Subject: [PATCH 14/16] fix: Verify historical slot generations against
original materialization proof
---
.../src/domus/domain/recurrence_records.py | 48 +++++-
.../domus/infrastructure/sqlite_recurrence.py | 7 +
.../sqlite_recurrence_integrity.py | 15 +-
docs/architecture-recurrence.md | 18 ++
docs/evidence/recurrence-slot-original.json | 130 ++++++++++++++
docs/evidence/recurrence-storage.json | 5 +-
specs/recurrence-storage-continuation.md | 4 +
tests/backend/test_fixed_materialization.py | 5 +
.../backend/test_recurrence_slot_original.py | 161 ++++++++++++++++++
tests/backend/test_sqlite_recurrence.py | 25 ++-
tests/containers/test_recurrence_image.py | 45 +++++
11 files changed, 444 insertions(+), 19 deletions(-)
create mode 100644 docs/evidence/recurrence-slot-original.json
create mode 100644 tests/backend/test_recurrence_slot_original.py
diff --git a/backend/src/domus/domain/recurrence_records.py b/backend/src/domus/domain/recurrence_records.py
index f68a66a..639a122 100644
--- a/backend/src/domus/domain/recurrence_records.py
+++ b/backend/src/domus/domain/recurrence_records.py
@@ -14,7 +14,13 @@
)
from domus.domain.recurrence_rules import FixedPattern, NominalDate
from domus.domain.recurrence_time import TimePolicy
-from domus.domain.storage import CommandKey, Installation, JsonDocument, validate_identifier
+from domus.domain.storage import (
+ CommandKey,
+ Installation,
+ JsonDocument,
+ Receipt,
+ validate_identifier,
+)
from domus.domain.task_values import validate_task_time
MAINTENANCE_ACTOR = "system:recurrence"
@@ -164,6 +170,46 @@ def consistent_with(self, schedule: FixedSchedule) -> bool:
)
)
+ def matches_receipt(self, receipt: Receipt | None) -> bool:
+ """Original generation and realized facts must survive later rule edits unchanged."""
+ if receipt is None or receipt.command != self.command or receipt.historical:
+ return False
+ result = receipt.result.decode()
+ core = result.get("cursor_evidence")
+ if not isinstance(core, dict):
+ return False
+ cursor = core.get("cursor")
+ if not isinstance(cursor, dict):
+ return False
+ actual = JsonDocument.from_value(
+ {
+ "slot_key": result.get("slot_key"),
+ "occurrence_id": result.get("occurrence_id"),
+ "schedule_id": result.get("schedule_id"),
+ "definition_id": result.get("definition_id"),
+ "binding": cursor.get("binding"),
+ "policy": core.get("policy"),
+ "planned": core.get("last"),
+ }
+ )
+ expected = JsonDocument.from_value(
+ {
+ "slot_key": self.key,
+ "occurrence_id": self.occurrence_id,
+ "schedule_id": self.schedule.schedule_id,
+ "definition_id": self.schedule.definition_id,
+ "binding": cursor_binding(self.schedule.pattern, self.schedule.policy),
+ "policy": {
+ "zone": self.schedule.policy.zone,
+ "data_identity": self.schedule.policy.data_identity,
+ "repeated_time": self.schedule.policy.repeated_time.value,
+ "revision": self.schedule.policy.revision,
+ },
+ "planned": self.planned.value(),
+ }
+ )
+ return actual == expected
+
@property
def key(self) -> str:
return JsonDocument.from_value(
diff --git a/backend/src/domus/infrastructure/sqlite_recurrence.py b/backend/src/domus/infrastructure/sqlite_recurrence.py
index beeb644..5de181f 100644
--- a/backend/src/domus/infrastructure/sqlite_recurrence.py
+++ b/backend/src/domus/infrastructure/sqlite_recurrence.py
@@ -227,6 +227,13 @@ def _slot(self, row: sqlite3.Row | None) -> ScheduledSlot | None:
value = _model(row, read_slot, slot_headers)
if value is not None and value.installation != self.installation:
raise StorageFailure("invalid_recurrence_record")
+ if value is not None:
+ try:
+ valid = value.matches_receipt(SQLiteReceipts(self.connection).get(value.command))
+ except DomusError:
+ valid = False
+ if not valid:
+ raise StorageFailure("invalid_recurrence_record")
return value
def _write(
diff --git a/backend/src/domus/infrastructure/sqlite_recurrence_integrity.py b/backend/src/domus/infrastructure/sqlite_recurrence_integrity.py
index 5e66fa7..298f1e0 100644
--- a/backend/src/domus/infrastructure/sqlite_recurrence_integrity.py
+++ b/backend/src/domus/infrastructure/sqlite_recurrence_integrity.py
@@ -5,7 +5,7 @@
from domus.domain.errors import StorageFailure
from domus.domain.recurrence_records import FixedSchedule, ScheduledSlot
-from domus.domain.storage import Installation, JsonDocument
+from domus.domain.storage import Installation
from domus.infrastructure.sqlite_recurrence import SQLiteRecurrences
@@ -24,16 +24,3 @@ def verify_recurrence_records(connection: sqlite3.Connection, installation: Inst
source = repository.schedule(value.schedule.household_id, value.schedule.schedule_id)
if source is None or not value.consistent_with(source):
raise StorageFailure("invalid_recurrence_record")
- receipt = connection.execute(
- "SELECT result FROM receipts WHERE household_id=? "
- "AND actor_id=? AND action=? AND command_key=?",
- (value.command.household_id, value.command.actor_id, value.command.action, value.key),
- ).fetchone()
- if receipt is None:
- raise StorageFailure("invalid_recurrence_record")
- result = JsonDocument(receipt[0]).decode()
- if (
- result.get("slot_key") != value.key
- or result.get("occurrence_id") != value.occurrence_id
- ):
- raise StorageFailure("invalid_recurrence_record")
diff --git a/docs/architecture-recurrence.md b/docs/architecture-recurrence.md
index 3dbae07..9bcbf04 100644
--- a/docs/architecture-recurrence.md
+++ b/docs/architecture-recurrence.md
@@ -322,3 +322,21 @@ inputs remain supported. The genuine prior b2bcc69 schema8 layout is a strict re
keeping its matching application/database pair permits development rollback without relabeling it.
See [UTC boundary evidence](evidence/recurrence-until-integrity.json) for original-source replay,
logical-dump correction, current verification and remaining full-feature acceptance.
+
+## Original generation facts in historical slots
+
+An older slot may remain valid after the current rule changes. That relationship cannot prove
+which pattern produced it: another pattern can admit the same nominal date and leave its key
+unchanged. The slot now checks its original receipt's pattern-and-policy cursor binding, explicit
+policy, complete planned/resolved facts and scoped resource identifiers. Canonical JSON comparison
+keeps boolean/integer aliases distinct. Current and retained-current originals remain valid;
+historical absent-effect proofs cannot claim a present slot.
+
+Point/page repository reads and checkpoint inspection share this pure check. Malformed originals
+receive a safe record failure without changing source bytes. Snapshot validation no longer uses a
+weaker duplicate identifier-only check. Actual earlier-generation positive controls and an unchanged
+genuine06 source database verify compatibility. Synthetic repository fixtures now carry explicitly
+complete synthetic source proof; they do not establish actual materializer acceptance.
+
+See [original slot evidence](evidence/recurrence-slot-original.json). This refinement changes no
+layout, dependency, timezone I/O, timeout, owner installation or retained original operation.
diff --git a/docs/evidence/recurrence-slot-original.json b/docs/evidence/recurrence-slot-original.json
new file mode 100644
index 0000000..99d97f3
--- /dev/null
+++ b/docs/evidence/recurrence-slot-original.json
@@ -0,0 +1,130 @@
+{
+ "status": "partial; original historical-slot generation integrity corrected; full retained recurrence execution remains in progress",
+ "recordedAtUTC": "2026-11-02T11:17:51.246515+00:00",
+ "issue": "https://github.com/vypdev/domus/issues/20",
+ "pullRequest": "https://github.com/vypdev/domus/pull/22",
+ "sourceBase": "06b3b9f38955a25be2511b68b2ea667a84a42d8f",
+ "backendSourceInventorySHA256": "27030e1068bd2d2b9901a6f5d25e240a8313e44d4e8cc8b7ef7dc63d3bfa8c6c",
+ "finding": {
+ "url": "https://github.com/vypdev/domus/pull/22#discussion_r4441611132",
+ "id": "sqlite_recurrence_integrity.py:24:historical-slot-source-unverified",
+ "assessment": "Valid consumer defect; source corrected, fresh native confirmation required. Not dismissed or reported as a Copilot defect."
+ },
+ "implemented": [
+ "A valid older slot snapshot must match its exact current original materialization receipt, beyond matching slot/occurrence IDs or permissive historical generation relationships.",
+ "The pure relation checks original pattern-and-policy cursor binding, explicit policy, complete nominal/resolved last-slot facts, scoped command and schedule/definition/occurrence/slot identity. Canonical JSON equality rejects boolean/integer aliases.",
+ "Independent point/page repository reads and checkpoint inspection share the stronger original proof. Malformed, foreign, missing or historical-absent originals cannot claim a present slot; source bytes are not repaired.",
+ "The snapshot verifier no longer duplicates the weaker identifier-only receipt check. Actual original slot receipts retain their existing contents.",
+ "The existing repository fixture now carries explicitly complete synthetic time evidence instead of bare resource IDs. Actual materializer and older-generation controls independently prove real behavior.",
+ "A semantic application port that returns a contradictory present slot alongside an absent-effect receipt remains rejected, separately from the concrete adapter rejection."
+ ],
+ "testsFirst": {
+ "before": "18 original negative failures and2 actual older-generation positive controls against unchanged original06 implementation; captured before the source correction.",
+ "originalBackendManifest": "379103605c1e68b4ce45bb5f82a4ef4edbf6d7bc757261b4326706659daa2f11",
+ "sourceIdentity": "The genuine pre-change06 capture verified every133 backend module against published Git before this correction. Current negative cases use actual command/materializer receipts, not synthetic repository evidence.",
+ "after": "All28 new regressions pass. Additional meaningful malformed/scope/JSON-alias/concrete-adapter and semantic-port defenses are included in the full campaign.",
+ "firstFull": "3908 tests passed at99.98% coverage; the stronger concrete adapter made one existing application-port defense branch unreachable through that adapter. The final semantic-port contradiction fixture preserves that independent defense instead of removing it."
+ },
+ "completeBackend": {
+ "executions": 3909,
+ "durationSeconds": 117.81,
+ "authoredModules": 133,
+ "coverageTotals": {
+ "covered_lines": 9551,
+ "num_statements": 9551,
+ "percent_covered": 100.0,
+ "percent_covered_display": "100.00",
+ "missing_lines": 0,
+ "excluded_lines": 274,
+ "percent_statements_covered": 100.0,
+ "percent_statements_covered_display": "100.00",
+ "num_branches": 2742,
+ "num_partial_branches": 0,
+ "covered_branches": 2742,
+ "missing_branches": 0,
+ "percent_branches_covered": 100.0,
+ "percent_branches_covered_display": "100.00"
+ },
+ "independentGlobalAndPerModuleAudit": "passed"
+ },
+ "checks": {
+ "strictTypes": "passed;133 modules",
+ "ruff": "check/format passed after the current image edit;338 files",
+ "architecture": "3 contracts kept,0 broken;177 files/1144 dependencies",
+ "originalPinnedSdd": {
+ "upstream": "3e7ceea08e6131360e026937d1c0cc1b120472e0",
+ "base": "0806a38f8a2de1f102a017bff25ad6856fb17bcd",
+ "selected": "specs/recurrence-storage-continuation.md",
+ "originalPolicyModulesVerified": 7,
+ "format": "PASS",
+ "consistency": "PASS",
+ "composition": "explicit contributor integration; original publication retained in Git",
+ "unchangedCatalog": "PASS",
+ "immutableOwnerDocumentsUsed": 18,
+ "retainedCompanionCases": 52,
+ "newCases": 4,
+ "rawCharacters": 38612,
+ "renderedCharacters": 38612,
+ "composedCharacters": 38612,
+ "applicableDraftMaximumCharacters": 70000,
+ "sha256": "f85a33a474e3db73932506dde262ea5c34ba67f631e859aa55f2d4ce8e02293b",
+ "scope": "Review input only; no gate admission, branch publication, source implementation or runtime acceptance.",
+ "retainedOwningFilesUnchanged": true,
+ "workingDocumentValidated": true
+ },
+ "freshPackageAndArchitecture": {
+ "executions": 52,
+ "durationSeconds": 6.9,
+ "result": "passed"
+ },
+ "exactCurrentImageScriptOnHost": "passed; adds a real valid older slot after an edit, then rejects a canonical changed historical pattern on direct read and checkpoint, with unchanged source bytes",
+ "freshHosted": "Required after publication for this new correction; host controls are not actual CPU-image proof."
+ },
+ "compatibility": {
+ "layoutChanged": false,
+ "recordEncodingChanged": false,
+ "providerIoAdded": false,
+ "dependenciesChanged": false,
+ "timeoutsChanged": false,
+ "copilotChanged": false,
+ "genuine06Control": {
+ "original06SourceInputUnchanged": true,
+ "slotsVerifiedAgainstOriginals": 2,
+ "databaseSHA256": "5d4c4e26e273e7f26e579e728db243c39e29b21dbef53378cdd2368643debe09",
+ "layoutChanged": false,
+ "source": "06b3b9f38955a25be2511b68b2ea667a84a42d8f"
+ },
+ "ownerHa": "No owner HA request, update, migration, release or deployment. Edge.5/schema7 remains unchanged."
+ },
+ "prior06Hosted": {
+ "runId": 41433389677,
+ "aggregateJob": 125239685933,
+ "backendJob": 125237234578,
+ "frontendJob": 125237235059,
+ "amd64Job": 125237235352,
+ "aarch64Job": 125237235192,
+ "result": "Every applicable productCI Check suite passed, including the repaired49 browser journeys and both real images. Native review separately published this valid historical-slot finding."
+ },
+ "native06": {
+ "runId": 41433389688,
+ "jobId": 125237236014,
+ "partitions": "11/11",
+ "files": 91,
+ "fragments": 110,
+ "candidateFindings": 1,
+ "openFindings": 1,
+ "fixedPriorFindings": 5,
+ "overall": "partial; human-conversation7 fetched/5 retained/2 omitted/0 truncated/limitReached. Issue6/6,PR10/10,threads5/5 and diff91/91 separately complete.",
+ "manualSupplement": "Two proposed-SDD inputs and five prior fix replies were manually inspected; all are agent-authored maintainer-account technical material, not new owner policy answers. No omitted-item identity or whole-prompt absence is inferred."
+ },
+ "copilotReports": [
+ "https://github.com/vypdev/copilot/issues/851#issuecomment-6565229213",
+ "https://github.com/vypdev/copilot/issues/843#issuecomment-6565011301"
+ ],
+ "progress06Reference": "14d74cb5-f4d2-4736-9b92-a2f870fcb033",
+ "rawEvidence": {
+ "privateCampaign": "/private/tmp/domus-slot-proof-06b3b9f",
+ "consumer": "CurrentPR22 original-slot review: original negative/positive output, exact source/full coverage, SDK/package/host image controls and immutable genuine06 input. Compact completed producers after publication while preserving authored source input and current rollback."
+ },
+ "pending": "Fresh native/CI verification and full relative persistence/creation/source inbox/closure/invalidation/restore/mixed fairness; protected calendar/operations, reviewed schedule changes, household defaults and full52+37 retained cases and all-domain UI/HA/Stable-Edge acceptance."
+}
diff --git a/docs/evidence/recurrence-storage.json b/docs/evidence/recurrence-storage.json
index b625caf..7dac945 100644
--- a/docs/evidence/recurrence-storage.json
+++ b/docs/evidence/recurrence-storage.json
@@ -85,7 +85,7 @@
"policyModulesByteVerified": 7,
"immutableBaseOwners": 18,
"retainedOwnersUnchanged": true,
- "currentCompanionSHA256": "233ad9bfca0af9975174dc4b058a6261a2c951aff9c516c570abe5fb21e2ec13",
+ "currentCompanionSHA256": "f85a33a474e3db73932506dde262ea5c34ba67f631e859aa55f2d4ce8e02293b",
"scope": "Explicit contributor integration; original first publication remains in Git. Format, reciprocal inventory/path consistency and current catalog pass."
},
"packageAndArchitectureTests": {
@@ -284,5 +284,6 @@
},
"laterMaterializationIncrement": "docs/evidence/recurrence-materialization.json; original storage-kernel results remain historical and scope-specific.",
"laterFairnessIncrement": "docs/evidence/recurrence-fairness.json; original storage-kernel acceptance scope remains unchanged.",
- "laterUntilIntegrityIncrement": "docs/evidence/recurrence-until-integrity.json; original storage-kernel results retain their historical source and scope."
+ "laterUntilIntegrityIncrement": "docs/evidence/recurrence-until-integrity.json; original storage-kernel results retain their historical source and scope.",
+ "laterSlotOriginalIncrement": "docs/evidence/recurrence-slot-original.json; prior kernel/source results remain historical."
}
diff --git a/specs/recurrence-storage-continuation.md b/specs/recurrence-storage-continuation.md
index 22db430..8ba7aa4 100644
--- a/specs/recurrence-storage-continuation.md
+++ b/specs/recurrence-storage-continuation.md
@@ -184,6 +184,7 @@ Use real independent SQLite connections and exact before/after version/row check
| tests/backend/test_recurrence_cursor_integrity.py | create |
| tests/backend/test_recurrence_cursor_evidence.py | create |
| tests/backend/test_recurrence_until_proof.py | create |
+| tests/backend/test_recurrence_slot_original.py | create |
| tests/backend/test_recurrence_slot_membership.py | create |
| tests/backend/test_sqlite_recurrence_records.py | create |
| tests/backend/test_sqlite_recurrence_integrity.py | create |
@@ -213,6 +214,7 @@ Use real independent SQLite connections and exact before/after version/row check
| docs/tasks/migration.md | update |
| docs/evidence/recurrence-execution.json | update |
| docs/evidence/recurrence-until-integrity.json | create |
+| docs/evidence/recurrence-slot-original.json | create |
Existing retained path declarations describe their original publication baseline; the table above identifies this continuation's actual new and existing storage paths at the immutable base. Declare any additional real implementation/test path before committing it. Update exact evidence and current acceptance limits without relabeling historical results.
@@ -272,6 +274,8 @@ The worker carries cancellation into every run and joins before runtime identity
## 19. Definition of Done
+Older slot snapshots remain permitted after a current rule edit, but that relationship alone cannot prove original generation facts. Compare each persisted slot with its exact current materialization receipt: scoped identity, slot/occurrence/rule/definition identifiers, original cursor pattern-and-policy binding, explicit policy snapshot and complete nominal/resolved last-slot facts. Compare canonical JSON documents so bool/integer aliases do not pass. Reuse this pure relation in independent repository point/page reads and checkpoint validation; malformed, foreign, historical-absent or missing originals fail safely. Keep real valid older-generation controls. Existing synthetic repository fixtures must carry explicit complete synthetic proof rather than bare identifiers, without claiming actual materializer acceptance. No extra case count, provider I/O, schema relabeling or alteration of retained original receipts is introduced.
+
Browser CI later recorded a503 rejection whose body was unavailable through Chromium CDP, while the actual application showed the rejected outcome and preserved its draft. Retain that trace; its generic navigation guidance does not establish an actual unexpected navigation. Contention tests may buffer the actual upstream HTTP response before forwarding the same bytes, status and headers to the browser, with redirects and network retries disabled. Preserve real independent SQLite writer contention, browser response checks, exact request metadata, current-authorized replay, draft/journal identity and no-duplicate assertions. A test observation repair changes neither product behavior nor storage timeouts, and adds no counted product journey.
UTC UNTIL continuation additionally retains the actual last included resolved slot and, when discovered, the first excluded candidate at the cursor. The last included instant must not exceed the inclusive cutoff and its nominal rank must equal the consumed count. Closed progress requires the actual excluded boundary, a conservative exhausted-period lower bound or natural calendar exhaustion. A legitimate unclosed cursor after the final included slot remains valid until a later bounded step checks the end. Unproved bootstrap values cannot claim a run or a completed range.
diff --git a/tests/backend/test_fixed_materialization.py b/tests/backend/test_fixed_materialization.py
index 163f169..1b08cd6 100644
--- a/tests/backend/test_fixed_materialization.py
+++ b/tests/backend/test_fixed_materialization.py
@@ -396,6 +396,8 @@ def test_contradictory_historical_receipt_projection_cannot_hide_a_current_effec
service, store, _, _, rule_id, before = configured(tmp_path)
first = service.step(rule_id, before)
+ with store.read() as unit:
+ saved = unit.recurrence.slot("home", first.receipt.command.key)
rewind(store, rule_id)
original = SQLiteReceipts.get
@@ -408,6 +410,9 @@ def inconsistent(self, command):
)
monkeypatch.setattr(SQLiteReceipts, "get", inconsistent)
+ # Also defend against an inconsistent semantic port, after the concrete adapter's
+ # stricter original-proof check would normally have rejected this projection.
+ monkeypatch.setattr(SQLiteRecurrences, "slot", lambda *_: saved)
with pytest.raises(StorageFailure, match="invalid_recurrence_effect"):
service.step(rule_id, before)
diff --git a/tests/backend/test_recurrence_slot_original.py b/tests/backend/test_recurrence_slot_original.py
new file mode 100644
index 0000000..f715e54
--- /dev/null
+++ b/tests/backend/test_recurrence_slot_original.py
@@ -0,0 +1,161 @@
+"""Historical slot generations must agree with their immutable original materialization proof."""
+
+from dataclasses import replace
+
+import pytest
+from test_fixed_materialization import configured
+from test_sqlite_snapshots import Clock
+
+from domus.domain.errors import DatabaseRejected, StorageFailure
+from domus.domain.recurrence_planning import FixedCursor, cursor_binding
+from domus.infrastructure.sqlite_recurrence_records import slot_document
+from domus.infrastructure.sqlite_snapshots import SQLiteSnapshots
+
+
+def historical(tmp_path, timed):
+ service, store, _, _, rid, before = configured(tmp_path, timed=timed, count=None)
+ result = service.step(rid, before)
+ with store.write() as unit:
+ original = unit.recurrence.slot("home", result.receipt.command.key)
+ progress = unit.recurrence.progress("home", rid)
+ current = replace(
+ original.schedule,
+ revision=2,
+ pattern_revision=2,
+ pattern=replace(original.schedule.pattern, interval=2),
+ )
+ unit.recurrence.save_schedule(current, 1)
+ unit.recurrence.save_progress(
+ replace(
+ progress,
+ revision=3,
+ pattern_revision=2,
+ cursor=FixedCursor(cursor_binding(current.pattern, current.policy)),
+ evidence=None,
+ last_run_at=None,
+ range_complete=False,
+ ),
+ 2,
+ )
+ return store, original
+
+
+@pytest.mark.parametrize("timed", [False, True])
+def test_actual_older_generation_remains_readable_and_checkpointable_after_a_rule_edit(
+ tmp_path, timed
+):
+ store, original = historical(tmp_path, timed)
+ with store.read() as unit:
+ assert unit.recurrence.slot("home", original.key) == original
+ assert unit.recurrence.slots("home", original.schedule.schedule_id) == (original,)
+ assert SQLiteSnapshots(store, tmp_path / "good", store.installation, Clock()).create("good")
+
+
+@pytest.mark.parametrize("timed", [False, True])
+@pytest.mark.parametrize("change", ["pattern", "policy", "missing-core"])
+@pytest.mark.parametrize("surface", ["read", "page", "snapshot"])
+def test_older_generation_cannot_substitute_other_valid_pattern_or_policy(
+ tmp_path, timed, change, surface
+):
+ from domus.domain.storage import JsonDocument
+
+ store, original = historical(tmp_path, timed)
+ if change == "pattern":
+ altered = replace(
+ original,
+ schedule=replace(
+ original.schedule, pattern=replace(original.schedule.pattern, interval=3)
+ ),
+ )
+ elif change == "policy":
+ policy = replace(original.schedule.policy, revision=2)
+ planned = original.planned
+ if planned.resolved is not None:
+ planned = replace(planned, resolved=replace(planned.resolved, policy=policy))
+ altered = replace(
+ original, schedule=replace(original.schedule, policy=policy), planned=planned
+ )
+ else:
+ altered = original
+ assert altered.key == original.key
+ with store._connection() as connection:
+ connection.execute(
+ "UPDATE recurrence_slots SET document=?", (slot_document(altered).encoded,)
+ )
+ if change == "missing-core":
+ with store.read() as unit:
+ receipt = unit.receipts.get(original.command)
+ body = receipt.result.decode()
+ del body["cursor_evidence"]
+ connection.execute(
+ "UPDATE receipts SET result=? WHERE operation_id=?",
+ (JsonDocument.from_value(body).encoded, receipt.operation_id),
+ )
+ before = store.path.read_bytes()
+ if surface == "snapshot":
+ with pytest.raises(DatabaseRejected, match="invalid_snapshot_records"):
+ SQLiteSnapshots(store, tmp_path / "bad", store.installation, Clock()).create("bad")
+ else:
+ with store.read() as unit:
+ with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
+ if surface == "read":
+ unit.recurrence.slot("home", original.key)
+ else:
+ unit.recurrence.slots("home", original.schedule.schedule_id)
+ assert store.path.read_bytes() == before
+
+
+@pytest.mark.parametrize(
+ "change", ["command", "historical", "core-type", "cursor-type", "policy-bool", "planned"]
+)
+def test_original_proof_does_not_accept_foreign_scope_or_json_value_aliases(tmp_path, change):
+ from domus.domain.storage import JsonDocument, RestorationProvenance
+
+ store, original = historical(tmp_path, True)
+ with store.read() as unit:
+ receipt = unit.receipts.get(original.command)
+ assert original.matches_receipt(receipt)
+ if change == "command":
+ receipt = replace(receipt, command=replace(receipt.command, key="other"))
+ elif change == "historical":
+ receipt = replace(receipt, restoration=RestorationProvenance("restored", False))
+ else:
+ data = receipt.result.decode()
+ if change == "core-type":
+ data["cursor_evidence"] = []
+ elif change == "cursor-type":
+ data["cursor_evidence"]["cursor"] = None
+ elif change == "policy-bool":
+ data["cursor_evidence"]["policy"]["revision"] = True
+ else:
+ data["cursor_evidence"]["last"]["resolved"]["zone_digest"] = "b" * 64
+ receipt = replace(receipt, result=JsonDocument.from_value(data))
+ assert not original.matches_receipt(receipt)
+
+
+def test_bad_original_json_fails_as_a_safe_slot_record_error(tmp_path):
+ store, original = historical(tmp_path, False)
+ with store._connection() as connection:
+ connection.execute(
+ "UPDATE receipts SET result='invalid JSON' WHERE command_key=?", (original.key,)
+ )
+ with store.read() as unit:
+ with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
+ unit.recurrence.slot("home", original.key)
+
+
+def test_concrete_slot_adapter_refuses_a_contradictory_historical_projection(tmp_path, monkeypatch):
+ from domus.domain.storage import RestorationProvenance
+ from domus.infrastructure.sqlite_receipts import SQLiteReceipts
+
+ store, original = historical(tmp_path, False)
+ get = SQLiteReceipts.get
+
+ def absent(self, command):
+ receipt = get(self, command)
+ return replace(receipt, restoration=RestorationProvenance("restored", False))
+
+ monkeypatch.setattr(SQLiteReceipts, "get", absent)
+ with store.read() as unit:
+ with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
+ unit.recurrence.slot("home", original.key)
diff --git a/tests/backend/test_sqlite_recurrence.py b/tests/backend/test_sqlite_recurrence.py
index 36f1754..b756eb5 100644
--- a/tests/backend/test_sqlite_recurrence.py
+++ b/tests/backend/test_sqlite_recurrence.py
@@ -10,7 +10,12 @@
from domus.application.task_commands import CreateTask
from domus.domain.errors import Conflict, InvalidInput, StorageFailure
-from domus.domain.recurrence_planning import FixedCursor, PlannedFixedSlot, cursor_binding
+from domus.domain.recurrence_planning import (
+ CursorTimeEvidence,
+ FixedCursor,
+ PlannedFixedSlot,
+ cursor_binding,
+)
from domus.domain.recurrence_records import (
FixedProgress,
FixedSchedule,
@@ -57,7 +62,23 @@ def save_slot(unit, value):
"""Repository fixture records full synthetic technical evidence, not human authorization."""
unit.recurrence.add_slot(value)
command = value.command
- result = JsonDocument.from_value({"slot_key": value.key, "occurrence_id": value.occurrence_id})
+ core = CursorTimeEvidence(
+ FixedCursor(
+ cursor_binding(value.schedule.pattern, value.schedule.policy), offset=1, nominal_count=1
+ ),
+ value.schedule.policy,
+ False,
+ value.planned,
+ )
+ result = JsonDocument.from_value(
+ {
+ "slot_key": value.key,
+ "occurrence_id": value.occurrence_id,
+ "schedule_id": value.schedule.schedule_id,
+ "definition_id": value.schedule.definition_id,
+ "cursor_evidence": core.document().decode(),
+ }
+ )
unit.receipts.add(
Receipt(command, JsonDocument.from_value({}).fingerprint, "slot-operation", result, 100)
)
diff --git a/tests/containers/test_recurrence_image.py b/tests/containers/test_recurrence_image.py
index 3ebfcaa..c5a3f86 100644
--- a/tests/containers/test_recurrence_image.py
+++ b/tests/containers/test_recurrence_image.py
@@ -37,6 +37,7 @@ def test_current_image_executes_recurrence_creation_planning_and_closed_record_c
from domus.domain.relative_recurrence import RelativeSchedule
from domus.domain.identity import HumanIdentity
from domus.domain.nominal_recurrence import CalendarNominalExpansion
+from domus.domain.recurrence_planning import FixedCursor, cursor_binding
from domus.domain.recurrence_records import FixedProgress, FixedSchedule, ScheduledSlot
from domus.domain.recurrence_rule_text import parse_fixed_pattern
from domus.domain.recurrence_time import NominalTime, TimePolicy, TimeResolution, RelativeInterval
@@ -257,6 +258,50 @@ async def serve_once(runtime):
else:
raise AssertionError('Checkpoint accepted unproved UTC continuation')
assert store.path.read_bytes() == source_bytes
+with TemporaryDirectory(prefix='domus-current-history-') as directory:
+ store = SQLiteDatabase(Path(directory)/'domus.sqlite3', installation)
+ store.initialize()
+ HouseholdCommands(store, human, UnusedDirectory(), TimeZoneCatalog(), clock, identifiers).setup(
+ 'setup', SetupHousehold('Fictional original generation', 'UTC'))
+ creation = RecurrenceCommands(store, human, clock, identifiers,
+ FixedRecurrencePlanner(CalendarNominalExpansion(), times))
+ rid = creation.create_fixed('history-create', request).result.decode()['schedule_id']
+ maintenance = FixedMaintenance(store, MaintenanceScope(installation, 'home'),
+ creation.planner, clock, identifiers)
+ result = maintenance.step(rid, horizon)
+ with store.write() as unit:
+ original = unit.recurrence.slot('home', result.receipt.command.key)
+ progress = unit.recurrence.progress('home', rid)
+ current = replace(original.schedule, revision=2, pattern_revision=2,
+ pattern=replace(original.schedule.pattern, interval=2))
+ unit.recurrence.save_schedule(current, 1)
+ unit.recurrence.save_progress(replace(progress, revision=3, pattern_revision=2,
+ cursor=FixedCursor(cursor_binding(current.pattern, current.policy)),
+ evidence=None, last_run_at=None, range_complete=False), 2)
+ with store.read() as unit:
+ assert unit.recurrence.slot('home', original.key) == original
+ assert SQLiteSnapshots(store, Path(directory)/'good', installation, clock).create('good')
+ altered = replace(original, schedule=replace(original.schedule,
+ pattern=replace(original.schedule.pattern, interval=3)))
+ assert altered.key == original.key
+ with store._connection() as connection:
+ connection.execute('UPDATE recurrence_slots SET document=?',
+ (slot_document(altered).encoded,))
+ source_bytes = store.path.read_bytes()
+ try:
+ with store.read() as unit:
+ unit.recurrence.slot('home', original.key)
+ except StorageFailure:
+ pass
+ else:
+ raise AssertionError('Historical pattern contradicted its original generation binding')
+ try:
+ SQLiteSnapshots(store, Path(directory)/'bad', installation, clock).create('bad')
+ except DatabaseRejected as error:
+ assert error.code == 'invalid_snapshot_records'
+ else:
+ raise AssertionError('Checkpoint accepted altered original generation facts')
+ assert store.path.read_bytes() == source_bytes
print('Current-source recurrence, both owned runtimes and pinned-time codecs passed.')
""",
)
From 19ff35cb2cb2ceb4a0397901341f1089001cc619 Mon Sep 17 00:00:00 2001
From: Efra Espada
Date: Mon, 2 Nov 2026 13:12:23 +0100
Subject: [PATCH 15/16] feat: Persist original-bound completion-relative
recurrence records
---
.../src/domus/application/recurrence_ports.py | 19 +
.../src/domus/domain/relative_recurrence.py | 143 ++++++-
.../domus/infrastructure/sqlite_recurrence.py | 82 +---
.../sqlite_recurrence_integrity.py | 21 +-
.../infrastructure/sqlite_recurrence_rows.py | 81 ++++
.../sqlite_recurrence_schema.py | 49 ++-
.../sqlite_relative_recurrence.py | 340 +++++++++++++++++
.../sqlite_relative_recurrence_records.py | 233 ++++++++++++
docs/architecture-recurrence.md | 36 ++
.../evidence/recurrence-relative-storage.json | 152 ++++++++
docs/evidence/recurrence-storage.json | 9 +-
docs/tasks/migration.md | 15 +
specs/recurrence-storage-continuation.md | 17 +
.../test_relative_recurrence_evidence.py | 137 +++++++
.../test_relative_recurrence_records.py | 2 +-
.../test_sqlite_recurrence_integrity.py | 7 +-
.../backend/test_sqlite_relative_integrity.py | 351 ++++++++++++++++++
.../test_sqlite_relative_recurrence.py | 348 +++++++++++++++++
...test_sqlite_relative_recurrence_records.py | 117 ++++++
tests/backend/test_sqlite_task_reviews.py | 1 +
tests/containers/test_recurrence_image.py | 43 ++-
.../sqlite/unreleased-06b3b9f-schema8.sql | 74 ++++
22 files changed, 2188 insertions(+), 89 deletions(-)
create mode 100644 backend/src/domus/infrastructure/sqlite_recurrence_rows.py
create mode 100644 backend/src/domus/infrastructure/sqlite_relative_recurrence.py
create mode 100644 backend/src/domus/infrastructure/sqlite_relative_recurrence_records.py
create mode 100644 docs/evidence/recurrence-relative-storage.json
create mode 100644 tests/backend/test_relative_recurrence_evidence.py
create mode 100644 tests/backend/test_sqlite_relative_integrity.py
create mode 100644 tests/backend/test_sqlite_relative_recurrence.py
create mode 100644 tests/backend/test_sqlite_relative_recurrence_records.py
create mode 100644 tests/fixtures/sqlite/unreleased-06b3b9f-schema8.sql
diff --git a/backend/src/domus/application/recurrence_ports.py b/backend/src/domus/application/recurrence_ports.py
index 06d48cf..cc0210f 100644
--- a/backend/src/domus/application/recurrence_ports.py
+++ b/backend/src/domus/application/recurrence_ports.py
@@ -17,6 +17,7 @@
ResolvedTime,
TimePolicy,
)
+from domus.domain.relative_recurrence import RelativeProgress, RelativeSchedule, RelativeSlot
from domus.domain.storage import Installation
@@ -46,6 +47,24 @@ def installation(self) -> Installation: ...
def frontier(self, household_id: str) -> RecurrenceFrontier | None: ...
def save_frontier(self, value: RecurrenceFrontier, expected_revision: int) -> None: ...
+ def relative_schedule(self, household_id: str, schedule_id: str) -> RelativeSchedule | None: ...
+ def relative_progress(self, household_id: str, schedule_id: str) -> RelativeProgress | None: ...
+ def relative_slot(self, household_id: str, slot_key: str) -> RelativeSlot | None: ...
+ def save_relative_schedule(self, value: RelativeSchedule, expected_revision: int) -> None: ...
+ def save_relative_progress(self, value: RelativeProgress, expected_revision: int) -> None: ...
+ def add_relative_slot(self, value: RelativeSlot) -> None: ...
+ def relative_schedules(
+ self,
+ household_id: str,
+ *,
+ limit: int = 25,
+ after: str | None = None,
+ state: ScheduleState | None = None,
+ ) -> tuple[RelativeSchedule, ...]: ...
+ def relative_slots(
+ self, household_id: str, schedule_id: str, *, limit: int = 25, after: str | None = None
+ ) -> tuple[RelativeSlot, ...]: ...
+
def schedule(self, household_id: str, schedule_id: str) -> FixedSchedule | None: ...
def progress(self, household_id: str, schedule_id: str) -> FixedProgress | None: ...
def slot(self, household_id: str, slot_key: str) -> ScheduledSlot | None: ...
diff --git a/backend/src/domus/domain/relative_recurrence.py b/backend/src/domus/domain/relative_recurrence.py
index 0b56050..98894ee 100644
--- a/backend/src/domus/domain/relative_recurrence.py
+++ b/backend/src/domus/domain/relative_recurrence.py
@@ -1,6 +1,8 @@
"""Completion-relative rule, progress and original-cycle facts contain no authority grants."""
-from dataclasses import dataclass
+import json
+import re
+from dataclasses import asdict, dataclass
from datetime import timedelta
from enum import StrEnum
@@ -10,7 +12,13 @@
from domus.domain.recurrence_records import MAINTENANCE_ACTOR, ScheduleState
from domus.domain.recurrence_rules import NominalDate
from domus.domain.recurrence_time import LocalInstant, NominalTime, RelativeInterval, TimePolicy
-from domus.domain.storage import CommandKey, Installation, JsonDocument, validate_identifier
+from domus.domain.storage import (
+ CommandKey,
+ Installation,
+ JsonDocument,
+ Receipt,
+ validate_identifier,
+)
from domus.domain.task_values import VerificationState, validate_task_time
from domus.domain.tasks import TaskCycle
@@ -20,6 +28,19 @@ class SuccessorVisibility(StrEnum):
AT_VERIFICATION = "at_verification"
+@dataclass(frozen=True, slots=True)
+class RelativeOperation:
+ """A reference to original proof; possession grants no execution capability."""
+
+ command: CommandKey
+ operation_id: str
+
+ def __post_init__(self) -> None:
+ validate_identifier(self.operation_id)
+ if not isinstance(self.command, CommandKey):
+ raise InvalidInput("invalid_relative_operation")
+
+
@dataclass(frozen=True, slots=True)
class RelativeSchedule:
schedule_id: str
@@ -59,6 +80,28 @@ def same_timing(self, other: RelativeSchedule) -> bool:
other.visibility,
)
+ def generation_document(self) -> JsonDocument:
+ return JsonDocument(
+ json.dumps(
+ {
+ "version": 1,
+ "schedule_id": self.schedule_id,
+ "household_id": self.household_id,
+ "definition_id": self.definition_id,
+ "created_at": self.created_at,
+ "pattern_revision": self.pattern_revision,
+ "initial": self.initial.value(),
+ "interval_days": self.interval.days,
+ "policy": asdict(self.policy),
+ "visibility": self.visibility.value,
+ }
+ )
+ )
+
+ @property
+ def binding(self) -> str:
+ return self.generation_document().fingerprint
+
def allows_successor(self, cycle: TaskCycle) -> bool:
if not isinstance(cycle, TaskCycle) or cycle.household_id != self.household_id:
raise InvalidInput("invalid_relative_source")
@@ -78,11 +121,13 @@ class RelativeProgress:
schedule_id: str
household_id: str
pattern_revision: int
+ binding: str
current_occurrence_id: str | None = None
current_slot_key: str | None = None
source_after: int = 0
last_run_at: int | None = None
revision: int = 1
+ evidence: RelativeOperation | None = None
def __post_init__(self) -> None:
validate_identifier(self.schedule_id)
@@ -96,9 +141,12 @@ def __post_init__(self) -> None:
if self.last_run_at is not None:
validate_task_time(self.last_run_at)
if (
- (self.current_occurrence_id is None) != (self.current_slot_key is None)
+ not isinstance(self.binding, str)
+ or re.fullmatch(r"[a-f0-9]{64}", self.binding) is None
+ or (self.current_occurrence_id is None) != (self.current_slot_key is None)
or type(self.source_after) is not int
or not 0 <= self.source_after <= 2**63 - 1
+ or (self.evidence is not None and not isinstance(self.evidence, RelativeOperation))
):
raise InvalidInput("invalid_relative_progress")
@@ -107,8 +155,48 @@ def matches_schedule(self, schedule: RelativeSchedule) -> bool:
self.schedule_id == schedule.schedule_id
and self.household_id == schedule.household_id
and self.pattern_revision == schedule.pattern_revision
+ and self.binding == schedule.binding
+ and (self.evidence is None or self.evidence.command.household_id == self.household_id)
)
+ @property
+ def pristine(self) -> bool:
+ return (
+ self.current_occurrence_id is None
+ and self.source_after == 0
+ and self.last_run_at is None
+ and self.evidence is None
+ )
+
+ def state_document(self) -> JsonDocument:
+ """Business continuation excludes observational time and mutable CAS revision."""
+ return JsonDocument.from_value(
+ {
+ "version": 1,
+ "schedule_id": self.schedule_id,
+ "household_id": self.household_id,
+ "pattern_revision": self.pattern_revision,
+ "binding": self.binding,
+ "current_occurrence_id": self.current_occurrence_id,
+ "current_slot_key": self.current_slot_key,
+ "source_after": self.source_after,
+ }
+ )
+
+ def matches_receipt(self, receipt: Receipt | None) -> bool:
+ evidence = self.evidence
+ if evidence is None:
+ return self.pristine
+ if (
+ receipt is None
+ or receipt.command != evidence.command
+ or receipt.operation_id != evidence.operation_id
+ ):
+ return False
+ return JsonDocument.from_value(
+ {"state": receipt.result.decode().get("relative_progress")}
+ ) == JsonDocument.from_value({"state": self.state_document().decode()})
+
@dataclass(frozen=True, slots=True)
class RelativeSource:
@@ -199,6 +287,55 @@ def consistent_with(self, current: RelativeSchedule) -> bool:
)
)
+ @property
+ def evidence_fingerprint(self) -> str:
+ schedule = self.schedule
+ source = self.source
+ value = {
+ "version": 1,
+ "installation": asdict(self.installation),
+ "generation": schedule.generation_document().decode(),
+ "planned": self.planned.value(),
+ "occurrence_id": self.occurrence_id,
+ "created_at": self.created_at,
+ "source": None
+ if source is None
+ else {
+ "cycle": asdict(source.cycle),
+ "performed_local": {
+ "instant_ms": source.performed_local.instant_ms,
+ "local": source.performed_local.local.isoformat(),
+ "offset_seconds": source.performed_local.offset_seconds,
+ "policy": asdict(source.performed_local.policy),
+ "zone_digest": source.performed_local.zone_digest,
+ },
+ },
+ }
+ return JsonDocument(json.dumps(value)).fingerprint
+
+ def matches_receipt(self, receipt: Receipt | None) -> bool:
+ if receipt is None or receipt.command != self.command or receipt.historical:
+ return False
+ result = receipt.result.decode()
+ keys = (
+ "slot_key",
+ "occurrence_id",
+ "schedule_id",
+ "definition_id",
+ "relative_slot_fingerprint",
+ )
+ return JsonDocument.from_value(
+ {key: result.get(key) for key in keys}
+ ) == JsonDocument.from_value(
+ {
+ "slot_key": self.key,
+ "occurrence_id": self.occurrence_id,
+ "schedule_id": self.schedule.schedule_id,
+ "definition_id": self.schedule.definition_id,
+ "relative_slot_fingerprint": self.evidence_fingerprint,
+ }
+ )
+
@property
def key(self) -> str:
source = (
diff --git a/backend/src/domus/infrastructure/sqlite_recurrence.py b/backend/src/domus/infrastructure/sqlite_recurrence.py
index 5de181f..85f1d29 100644
--- a/backend/src/domus/infrastructure/sqlite_recurrence.py
+++ b/backend/src/domus/infrastructure/sqlite_recurrence.py
@@ -1,7 +1,6 @@
"""Scoped recurrence repositories never commit independently or infer caller authority."""
import sqlite3
-from collections.abc import Callable
from dataclasses import dataclass
from typing import cast
@@ -26,6 +25,12 @@
schedule_document,
slot_document,
)
+from domus.infrastructure.sqlite_recurrence_rows import (
+ _expected,
+ _model,
+ _page,
+)
+from domus.infrastructure.sqlite_relative_recurrence import SQLiteRelativeRecurrences
def frontier_headers(value: RecurrenceFrontier) -> dict[str, object]:
@@ -45,6 +50,7 @@ def schedule_headers(value: FixedSchedule) -> dict[str, object]:
"revision": value.revision,
"pattern_revision": value.pattern_revision,
"state": value.state.value,
+ "timing_kind": "fixed_calendar",
}
@@ -54,6 +60,7 @@ def progress_headers(value: FixedProgress) -> dict[str, object]:
"household_id": value.household_id,
"pattern_revision": value.pattern_revision,
"revision": value.revision,
+ "timing_kind": "fixed_calendar",
}
@@ -101,44 +108,12 @@ def slot_headers(value: ScheduledSlot) -> dict[str, object]:
"created_at": value.created_at,
"receipt_actor": value.command.actor_id,
"receipt_action": value.command.action,
+ "timing_kind": "fixed_calendar",
}
-def _model[T](
- row: sqlite3.Row | None, decode: Callable[[str], T], headers: Callable[[T], dict[str, object]]
-) -> T | None:
- if row is None:
- return None
- value = decode(row["document"])
- if any(
- type(row[key]) is not type(expected) or row[key] != expected
- for key, expected in headers(value).items()
- ):
- raise StorageFailure("invalid_recurrence_record")
- return value
-
-
-def _expected(value: int) -> None:
- if type(value) is not int or not 0 <= value <= 2**63 - 1:
- raise InvalidInput("invalid_revision")
-
-
-def _page(
- household_id: str, limit: int, after: str | None, key: str
-) -> tuple[list[str], list[object]]:
- validate_identifier(household_id)
- if type(limit) is not int or not 1 <= limit <= 101:
- raise InvalidInput("invalid_page_size")
- where, parameters = ["household_id=?"], [household_id]
- if after is not None:
- validate_identifier(after)
- where.append(key + ">?")
- parameters.append(after)
- return where, list(parameters)
-
-
@dataclass
-class SQLiteRecurrences:
+class SQLiteRecurrences(SQLiteRelativeRecurrences):
connection: sqlite3.Connection
installation: Installation
@@ -169,7 +144,8 @@ def schedule(self, household_id: str, schedule_id: str) -> FixedSchedule | None:
validate_identifier(schedule_id)
return _model(
self.connection.execute(
- "SELECT * FROM recurrence_rules WHERE household_id=? AND schedule_id=?",
+ "SELECT * FROM recurrence_rules WHERE household_id=? AND schedule_id=? "
+ "AND timing_kind='fixed_calendar'",
(household_id, schedule_id),
).fetchone(),
read_schedule,
@@ -181,7 +157,8 @@ def progress(self, household_id: str, schedule_id: str) -> FixedProgress | None:
validate_identifier(schedule_id)
value = _model(
self.connection.execute(
- "SELECT * FROM recurrence_progress WHERE household_id=? AND schedule_id=?",
+ "SELECT * FROM recurrence_progress WHERE household_id=? AND schedule_id=? "
+ "AND timing_kind='fixed_calendar'",
(household_id, schedule_id),
).fetchone(),
read_progress,
@@ -236,36 +213,6 @@ def _slot(self, row: sqlite3.Row | None) -> ScheduledSlot | None:
raise StorageFailure("invalid_recurrence_record")
return value
- def _write(
- self,
- table: str,
- headers: dict[str, object],
- document: str,
- expected: int,
- *,
- identity: str = "schedule_id",
- ) -> None:
- values = {**headers, "document": document}
- keys = ("household_id",) if identity == "household_id" else ("household_id", identity)
- if expected == 0:
- try:
- self.connection.execute(
- f"INSERT INTO {table}({','.join(values)}) "
- f"VALUES ({','.join('?' for _ in values)})",
- tuple(values.values()),
- )
- except sqlite3.IntegrityError:
- raise Conflict("recurrence_record_conflict") from None
- elif (
- self.connection.execute(
- f"UPDATE {table} SET {','.join(key + '=?' for key in values)} "
- f"WHERE {' AND '.join(key + '=?' for key in keys)} AND revision=?",
- (*values.values(), *(headers[key] for key in keys), expected),
- ).rowcount
- != 1
- ):
- raise Conflict("revision_conflict")
-
def save_schedule(self, value: FixedSchedule, expected_revision: int) -> None:
_expected(expected_revision)
if value.revision != expected_revision + 1:
@@ -348,6 +295,7 @@ def schedules(
state: ScheduleState | None = None,
) -> tuple[FixedSchedule, ...]:
where, parameters = _page(household_id, limit, after, "schedule_id")
+ where.append("timing_kind='fixed_calendar'")
if state is not None:
if not isinstance(state, ScheduleState):
raise InvalidInput("invalid_recurrence_state")
diff --git a/backend/src/domus/infrastructure/sqlite_recurrence_integrity.py b/backend/src/domus/infrastructure/sqlite_recurrence_integrity.py
index 298f1e0..37f196c 100644
--- a/backend/src/domus/infrastructure/sqlite_recurrence_integrity.py
+++ b/backend/src/domus/infrastructure/sqlite_recurrence_integrity.py
@@ -5,6 +5,7 @@
from domus.domain.errors import StorageFailure
from domus.domain.recurrence_records import FixedSchedule, ScheduledSlot
+from domus.domain.relative_recurrence import RelativeSchedule
from domus.domain.storage import Installation
from domus.infrastructure.sqlite_recurrence import SQLiteRecurrences
@@ -13,12 +14,24 @@ def verify_recurrence_records(connection: sqlite3.Connection, installation: Inst
repository = SQLiteRecurrences(connection, installation)
for row in connection.execute("SELECT household_id FROM recurrence_frontiers"):
repository.frontier(row[0])
- for row in connection.execute("SELECT household_id,schedule_id FROM recurrence_rules"):
+ for row in connection.execute(
+ "SELECT household_id,schedule_id,timing_kind FROM recurrence_rules"
+ ):
# These keys came from the same read cursor; record decoding still checks every header.
- rule = cast(FixedSchedule, repository.schedule(row[0], row[1]))
- progress = repository.progress(row[0], row[1])
- if progress is None or not progress.matches_schedule(rule):
+ if row[2] == "fixed_calendar":
+ rule = cast(FixedSchedule, repository.schedule(row[0], row[1]))
+ progress = repository.progress(row[0], row[1])
+ valid = progress is not None and progress.matches_schedule(rule)
+ elif row[2] == "completion_relative":
+ relative = cast(RelativeSchedule, repository.relative_schedule(row[0], row[1]))
+ continuation = repository.relative_progress(row[0], row[1])
+ valid = continuation is not None and continuation.matches_schedule(relative)
+ else:
+ valid = False
+ if not valid:
raise StorageFailure("invalid_recurrence_record")
+ for row in connection.execute("SELECT household_id,slot_key FROM recurrence_relative_slots"):
+ repository.relative_slot(row[0], row[1])
for row in connection.execute("SELECT household_id,slot_key FROM recurrence_slots"):
value = cast(ScheduledSlot, repository.slot(row[0], row[1]))
source = repository.schedule(value.schedule.household_id, value.schedule.schedule_id)
diff --git a/backend/src/domus/infrastructure/sqlite_recurrence_rows.py b/backend/src/domus/infrastructure/sqlite_recurrence_rows.py
new file mode 100644
index 0000000..f0cd0e9
--- /dev/null
+++ b/backend/src/domus/infrastructure/sqlite_recurrence_rows.py
@@ -0,0 +1,81 @@
+"""Shared bounded row/CAS mechanics; record kind remains part of every typed write."""
+
+import sqlite3
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from domus.domain.errors import Conflict, InvalidInput, StorageFailure
+from domus.domain.storage import Installation, validate_identifier
+
+
+def _model[T](
+ row: sqlite3.Row | None, decode: Callable[[str], T], headers: Callable[[T], dict[str, object]]
+) -> T | None:
+ if row is None:
+ return None
+ value = decode(row["document"])
+ if any(
+ type(row[key]) is not type(expected) or row[key] != expected
+ for key, expected in headers(value).items()
+ ):
+ raise StorageFailure("invalid_recurrence_record")
+ return value
+
+
+def _expected(value: int) -> None:
+ if type(value) is not int or not 0 <= value <= 2**63 - 1:
+ raise InvalidInput("invalid_revision")
+
+
+def _page(
+ household_id: str, limit: int, after: str | None, key: str
+) -> tuple[list[str], list[object]]:
+ validate_identifier(household_id)
+ if type(limit) is not int or not 1 <= limit <= 101:
+ raise InvalidInput("invalid_page_size")
+ where, parameters = ["household_id=?"], [household_id]
+ if after is not None:
+ validate_identifier(after)
+ where.append(key + ">?")
+ parameters.append(after)
+ return where, list(parameters)
+
+
+@dataclass
+class SQLiteRecurrenceRows:
+ connection: sqlite3.Connection
+ installation: Installation
+
+ def _write(
+ self,
+ table: str,
+ headers: dict[str, object],
+ document: str,
+ expected: int,
+ *,
+ identity: str = "schedule_id",
+ ) -> None:
+ values = {**headers, "document": document}
+ keys: tuple[str, ...] = (
+ ("household_id",) if identity == "household_id" else ("household_id", identity)
+ )
+ if "timing_kind" in headers:
+ keys += ("timing_kind",)
+ try:
+ if expected == 0:
+ self.connection.execute(
+ f"INSERT INTO {table}({','.join(values)}) "
+ f"VALUES ({','.join('?' for _ in values)})",
+ tuple(values.values()),
+ )
+ elif (
+ self.connection.execute(
+ f"UPDATE {table} SET {','.join(key + '=?' for key in values)} "
+ f"WHERE {' AND '.join(key + '=?' for key in keys)} AND revision=?",
+ (*values.values(), *(headers[key] for key in keys), expected),
+ ).rowcount
+ != 1
+ ):
+ raise Conflict("revision_conflict")
+ except sqlite3.IntegrityError:
+ raise Conflict("recurrence_record_conflict") from None
diff --git a/backend/src/domus/infrastructure/sqlite_recurrence_schema.py b/backend/src/domus/infrastructure/sqlite_recurrence_schema.py
index f9a8638..1b8acc5 100644
--- a/backend/src/domus/infrastructure/sqlite_recurrence_schema.py
+++ b/backend/src/domus/infrastructure/sqlite_recurrence_schema.py
@@ -6,6 +6,7 @@
"recurrence_slots",
"recurrence_frontiers",
"recurrence_progress_evidence",
+ "recurrence_relative_slots",
}
RECURRENCE_STATEMENTS = (
"CREATE TABLE recurrence_frontiers(household_id TEXT PRIMARY KEY NOT NULL "
@@ -15,29 +16,37 @@
"document TEXT NOT NULL)",
"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 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')), document TEXT NOT NULL, "
+ "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), "
+ "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) "
- "REFERENCES recurrence_progress(household_id,schedule_id,pattern_revision) "
+ "FOREIGN KEY(household_id,schedule_id,pattern_revision,timing_kind) "
+ "REFERENCES recurrence_progress(household_id,schedule_id,pattern_revision,timing_kind) "
"DEFERRABLE INITIALLY DEFERRED)",
"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), "
- "document TEXT NOT NULL, UNIQUE(household_id,schedule_id,pattern_revision), "
- "FOREIGN KEY(household_id,schedule_id,pattern_revision) "
- "REFERENCES recurrence_rules(household_id,schedule_id,pattern_revision) "
+ "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)",
"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 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), "
@@ -56,17 +65,39 @@
"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) "
- "REFERENCES recurrence_rules(household_id,schedule_id,definition_id), "
+ "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)",
+ "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 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)",
)
diff --git a/backend/src/domus/infrastructure/sqlite_relative_recurrence.py b/backend/src/domus/infrastructure/sqlite_relative_recurrence.py
new file mode 100644
index 0000000..b8a531e
--- /dev/null
+++ b/backend/src/domus/infrastructure/sqlite_relative_recurrence.py
@@ -0,0 +1,340 @@
+"""Relative records share rule identity and CAS while preserving current and absent originals."""
+
+import sqlite3
+from dataclasses import dataclass
+from typing import cast
+
+from domus.domain.errors import Conflict, DomusError, InvalidInput, StorageFailure
+from domus.domain.recurrence_records import MAINTENANCE_ACTOR, ScheduleState
+from domus.domain.relative_recurrence import RelativeProgress, RelativeSchedule, RelativeSlot
+from domus.domain.storage import CommandKey, JsonDocument, validate_identifier
+from domus.infrastructure.sqlite_receipts import SQLiteReceipts
+from domus.infrastructure.sqlite_recurrence_rows import (
+ SQLiteRecurrenceRows,
+ _expected,
+ _model,
+ _page,
+)
+from domus.infrastructure.sqlite_relative_recurrence_records import (
+ read_relative_progress,
+ read_relative_schedule,
+ read_relative_slot,
+ relative_progress_document,
+ relative_schedule_document,
+ relative_slot_document,
+)
+from domus.infrastructure.sqlite_tasks import SQLiteTasks
+
+
+def _rule_headers(value: RelativeSchedule) -> dict[str, object]:
+ return {
+ "schedule_id": value.schedule_id,
+ "household_id": value.household_id,
+ "definition_id": value.definition_id,
+ "revision": value.revision,
+ "pattern_revision": value.pattern_revision,
+ "state": value.state.value,
+ "timing_kind": "completion_relative",
+ }
+
+
+def _progress_headers(value: RelativeProgress) -> dict[str, object]:
+ return {
+ "schedule_id": value.schedule_id,
+ "household_id": value.household_id,
+ "revision": value.revision,
+ "pattern_revision": value.pattern_revision,
+ "timing_kind": "completion_relative",
+ }
+
+
+def _proof_headers(value: RelativeProgress) -> dict[str, object]:
+ assert value.evidence is not None
+ return {
+ "schedule_id": value.schedule_id,
+ "household_id": value.household_id,
+ "progress_revision": value.revision,
+ "operation_id": value.evidence.operation_id,
+ "evidence_fingerprint": relative_progress_document(value).fingerprint,
+ }
+
+
+def _slot_headers(value: RelativeSlot) -> dict[str, object]:
+ source = value.source
+ return {
+ "household_id": value.schedule.household_id,
+ "slot_key": value.key,
+ "schedule_id": value.schedule.schedule_id,
+ "definition_id": value.schedule.definition_id,
+ "pattern_revision": value.schedule.pattern_revision,
+ "occurrence_id": value.occurrence_id,
+ "source_occurrence_id": None if source is None else source.cycle.occurrence_id,
+ "source_cycle_id": None if source is None else source.cycle.cycle_id,
+ "created_at": value.created_at,
+ "receipt_actor": value.command.actor_id,
+ "receipt_action": value.command.action,
+ "timing_kind": "completion_relative",
+ }
+
+
+@dataclass
+class SQLiteRelativeRecurrences(SQLiteRecurrenceRows):
+ def relative_schedule(self, household_id: str, schedule_id: str) -> RelativeSchedule | None:
+ validate_identifier(household_id)
+ validate_identifier(schedule_id)
+ return _model(
+ self.connection.execute(
+ "SELECT * FROM recurrence_rules WHERE household_id=? AND schedule_id=? "
+ "AND timing_kind='completion_relative'",
+ (household_id, schedule_id),
+ ).fetchone(),
+ read_relative_schedule,
+ _rule_headers,
+ )
+
+ def save_relative_schedule(self, value: RelativeSchedule, expected_revision: int) -> None:
+ _expected(expected_revision)
+ if value.revision != expected_revision + 1:
+ raise Conflict("revision_conflict")
+ previous = self.relative_schedule(value.household_id, value.schedule_id)
+ if previous is not None and (
+ value.definition_id != previous.definition_id
+ or value.created_at != previous.created_at
+ or value.pattern_revision
+ != previous.pattern_revision + int(not previous.same_timing(value))
+ ):
+ raise Conflict("recurrence_identity_conflict")
+ self._write(
+ "recurrence_rules",
+ _rule_headers(value),
+ relative_schedule_document(value).encoded,
+ expected_revision,
+ )
+
+ def _relative_work(self, value: RelativeSlot) -> bool:
+ tasks = SQLiteTasks(self.connection)
+ work = tasks.occurrence(value.schedule.household_id, value.occurrence_id)
+ if (
+ work is None
+ or work.definition_snapshot.definition_id != value.schedule.definition_id
+ or work.created_at != value.created_at
+ ):
+ return False
+ if value.source is None:
+ return True
+ source = value.source
+ parent = tasks.occurrence(value.schedule.household_id, source.cycle.occurrence_id)
+ cycle = tasks.cycle(value.schedule.household_id, source.cycle.cycle_id)
+ return (
+ parent is not None
+ and parent.definition_snapshot.definition_id == value.schedule.definition_id
+ and cycle is not None
+ and cycle.completion_snapshot() == source.cycle
+ )
+
+ def _relative_slot(self, row: sqlite3.Row | None) -> RelativeSlot | None:
+ value = _model(row, read_relative_slot, _slot_headers)
+ if value is None:
+ return None
+ current = self.relative_schedule(value.schedule.household_id, value.schedule.schedule_id)
+ try:
+ valid = (
+ value.installation == self.installation
+ and current is not None
+ and value.consistent_with(current)
+ and self._relative_work(value)
+ and value.matches_receipt(SQLiteReceipts(self.connection).get(value.command))
+ )
+ except DomusError:
+ valid = False
+ if not valid:
+ raise StorageFailure("invalid_recurrence_record")
+ return value
+
+ def relative_slot(self, household_id: str, slot_key: str) -> RelativeSlot | None:
+ validate_identifier(household_id)
+ validate_identifier(slot_key)
+ return self._relative_slot(
+ self.connection.execute(
+ "SELECT * FROM recurrence_relative_slots WHERE household_id=? AND slot_key=?",
+ (household_id, slot_key),
+ ).fetchone()
+ )
+
+ def add_relative_slot(self, value: RelativeSlot) -> None:
+ if value.installation != self.installation:
+ raise Conflict("recurrence_scope_mismatch")
+ current = self.relative_schedule(value.schedule.household_id, value.schedule.schedule_id)
+ if (
+ current is None
+ or value.schedule.pattern_revision != current.pattern_revision
+ or not value.consistent_with(current)
+ or not self._relative_work(value)
+ ):
+ raise Conflict("recurrence_source_conflict")
+ fields = {**_slot_headers(value), "document": relative_slot_document(value).encoded}
+ try:
+ self.connection.execute(
+ f"INSERT INTO recurrence_relative_slots({','.join(fields)}) "
+ f"VALUES ({','.join('?' for _ in fields)})",
+ tuple(fields.values()),
+ )
+ except sqlite3.IntegrityError:
+ raise Conflict("recurrence_slot_conflict") from None
+
+ def _relative_continuation(self, value: RelativeProgress, rule: RelativeSchedule) -> bool:
+ if not value.matches_schedule(rule):
+ return False
+ evidence = value.evidence
+ receipt = (
+ None if evidence is None else SQLiteReceipts(self.connection).get(evidence.command)
+ )
+ if not value.matches_receipt(receipt):
+ return False
+ if value.current_occurrence_id is None:
+ return True
+ key = cast(str, value.current_slot_key)
+ saved = self.relative_slot(value.household_id, key)
+ if saved is not None:
+ return (
+ saved.occurrence_id == value.current_occurrence_id
+ and saved.schedule.schedule_id == rule.schedule_id
+ )
+ original = SQLiteReceipts(self.connection).get(
+ CommandKey(value.household_id, MAINTENANCE_ACTOR, "recurrence.materialize", key)
+ )
+ # An adopted task may predate the restored-away mapping. Its presence cannot
+ # authorize recreating that already processed mapping or erase the manual work.
+ work = SQLiteTasks(self.connection).occurrence(
+ value.household_id, value.current_occurrence_id
+ )
+ return (
+ original is not None
+ and original.historical
+ and (work is None or work.definition_snapshot.definition_id == rule.definition_id)
+ and JsonDocument.from_value(
+ {
+ name: original.result.decode().get(name)
+ for name in ("slot_key", "occurrence_id", "schedule_id", "definition_id")
+ }
+ )
+ == JsonDocument.from_value(
+ {
+ "slot_key": key,
+ "occurrence_id": value.current_occurrence_id,
+ "schedule_id": rule.schedule_id,
+ "definition_id": rule.definition_id,
+ }
+ )
+ )
+
+ def relative_progress(self, household_id: str, schedule_id: str) -> RelativeProgress | None:
+ validate_identifier(household_id)
+ validate_identifier(schedule_id)
+ value = _model(
+ self.connection.execute(
+ "SELECT * FROM recurrence_progress WHERE household_id=? AND schedule_id=? "
+ "AND timing_kind='completion_relative'",
+ (household_id, schedule_id),
+ ).fetchone(),
+ read_relative_progress,
+ _progress_headers,
+ )
+ if value is None:
+ return None
+ rule = self.relative_schedule(household_id, schedule_id)
+ row = self.connection.execute(
+ "SELECT * FROM recurrence_progress_evidence WHERE household_id=? AND schedule_id=?",
+ (household_id, schedule_id),
+ ).fetchone()
+ headers_match = (
+ row is None
+ if value.evidence is None
+ else row is not None
+ and all(
+ type(row[key]) is type(expected) and row[key] == expected
+ for key, expected in _proof_headers(value).items()
+ )
+ )
+ try:
+ valid = rule is not None and headers_match and self._relative_continuation(value, rule)
+ except DomusError:
+ valid = False
+ if not valid:
+ raise StorageFailure("invalid_recurrence_record")
+ return value
+
+ def save_relative_progress(self, value: RelativeProgress, expected_revision: int) -> None:
+ _expected(expected_revision)
+ if value.revision != expected_revision + 1:
+ raise Conflict("revision_conflict")
+ rule = self.relative_schedule(value.household_id, value.schedule_id)
+ try:
+ valid = rule is not None and self._relative_continuation(value, rule)
+ except DomusError:
+ raise StorageFailure("invalid_recurrence_record") from None
+ if not valid:
+ raise Conflict("relative_progress_mismatch")
+ self._write(
+ "recurrence_progress",
+ _progress_headers(value),
+ relative_progress_document(value).encoded,
+ expected_revision,
+ )
+ if value.evidence is None:
+ self.connection.execute(
+ "DELETE FROM recurrence_progress_evidence WHERE household_id=? AND schedule_id=?",
+ (value.household_id, value.schedule_id),
+ )
+ else:
+ headers = _proof_headers(value)
+ self.connection.execute(
+ f"INSERT INTO recurrence_progress_evidence({','.join(headers)}) "
+ f"VALUES ({','.join('?' for _ in headers)}) "
+ "ON CONFLICT(household_id,schedule_id) DO UPDATE SET "
+ "progress_revision=excluded.progress_revision, operation_id=excluded.operation_id, "
+ "evidence_fingerprint=excluded.evidence_fingerprint",
+ tuple(headers.values()),
+ )
+
+ def relative_schedules(
+ self,
+ household_id: str,
+ *,
+ limit: int = 25,
+ after: str | None = None,
+ state: ScheduleState | None = None,
+ ) -> tuple[RelativeSchedule, ...]:
+ where, parameters = _page(household_id, limit, after, "schedule_id")
+ where.append("timing_kind='completion_relative'")
+ if state is not None:
+ if not isinstance(state, ScheduleState):
+ raise InvalidInput("invalid_recurrence_state")
+ where.append("state=?")
+ parameters.append(state.value)
+ return tuple(
+ cast(RelativeSchedule, _model(row, read_relative_schedule, _rule_headers))
+ for row in self.connection.execute(
+ "SELECT * FROM recurrence_rules WHERE "
+ + " AND ".join(where)
+ + " ORDER BY schedule_id LIMIT ?",
+ (*parameters, limit),
+ )
+ )
+
+ def relative_slots(
+ self, household_id: str, schedule_id: str, *, limit: int = 25, after: str | None = None
+ ) -> tuple[RelativeSlot, ...]:
+ where, parameters = _page(household_id, limit, after, "slot_key")
+ validate_identifier(schedule_id)
+ where.append("schedule_id=?")
+ parameters.append(schedule_id)
+ return tuple(
+ cast(RelativeSlot, self._relative_slot(row))
+ for row in self.connection.execute(
+ "SELECT * FROM recurrence_relative_slots WHERE "
+ + " AND ".join(where)
+ + " ORDER BY slot_key LIMIT ?",
+ (*parameters, limit),
+ )
+ )
diff --git a/backend/src/domus/infrastructure/sqlite_relative_recurrence_records.py b/backend/src/domus/infrastructure/sqlite_relative_recurrence_records.py
new file mode 100644
index 0000000..506479d
--- /dev/null
+++ b/backend/src/domus/infrastructure/sqlite_relative_recurrence_records.py
@@ -0,0 +1,233 @@
+"""Closed canonical relative records retain original cycle and actual local-time facts."""
+
+from datetime import datetime
+from typing import cast
+
+from domus.domain.recurrence_records import ScheduleState
+from domus.domain.recurrence_time import LocalInstant, RelativeInterval
+from domus.domain.relative_recurrence import (
+ RelativeOperation,
+ RelativeProgress,
+ RelativeSchedule,
+ RelativeSlot,
+ RelativeSource,
+ SuccessorVisibility,
+)
+from domus.domain.storage import CommandKey, Installation, JsonDocument, JsonObject, JsonValue
+from domus.infrastructure.sqlite_recurrence_records import (
+ _local,
+ _object,
+ _planned,
+ _policy,
+ _policy_value,
+ _read,
+ _record,
+)
+from domus.infrastructure.sqlite_task_records import cycle_document, read_cycle
+
+
+def _schedule_value(value: RelativeSchedule) -> JsonObject:
+ return {
+ "version": 1,
+ "schedule_id": value.schedule_id,
+ "household_id": value.household_id,
+ "definition_id": value.definition_id,
+ "initial": value.initial.value(),
+ "interval_days": value.interval.days,
+ "policy": _policy_value(value.policy),
+ "created_at": value.created_at,
+ "visibility": value.visibility.value,
+ "state": value.state.value,
+ "revision": value.revision,
+ "pattern_revision": value.pattern_revision,
+ }
+
+
+def _schedule(value: JsonValue) -> RelativeSchedule:
+ row = _record(
+ value,
+ (
+ "schedule_id",
+ "household_id",
+ "definition_id",
+ "initial",
+ "interval_days",
+ "policy",
+ "created_at",
+ "visibility",
+ "state",
+ "revision",
+ "pattern_revision",
+ ),
+ )
+ return RelativeSchedule(
+ cast(str, row["schedule_id"]),
+ cast(str, row["household_id"]),
+ cast(str, row["definition_id"]),
+ _planned(row["initial"]),
+ RelativeInterval(cast(int, row["interval_days"])),
+ _policy(row["policy"]),
+ cast(int, row["created_at"]),
+ SuccessorVisibility(cast(str, row["visibility"])),
+ ScheduleState(cast(str, row["state"])),
+ cast(int, row["revision"]),
+ cast(int, row["pattern_revision"]),
+ )
+
+
+def relative_schedule_document(value: RelativeSchedule) -> JsonDocument:
+ return JsonDocument.from_value(_schedule_value(value))
+
+
+def relative_progress_document(value: RelativeProgress) -> JsonDocument:
+ return JsonDocument.from_value(
+ {
+ "version": 1,
+ "schedule_id": value.schedule_id,
+ "household_id": value.household_id,
+ "pattern_revision": value.pattern_revision,
+ "binding": value.binding,
+ "current_occurrence_id": value.current_occurrence_id,
+ "current_slot_key": value.current_slot_key,
+ "source_after": value.source_after,
+ "last_run_at": value.last_run_at,
+ "revision": value.revision,
+ "evidence": None
+ if value.evidence is None
+ else {
+ "command": {
+ "household_id": value.evidence.command.household_id,
+ "actor_id": value.evidence.command.actor_id,
+ "action": value.evidence.command.action,
+ "key": value.evidence.command.key,
+ },
+ "operation_id": value.evidence.operation_id,
+ },
+ }
+ )
+
+
+def _progress(value: JsonValue) -> RelativeProgress:
+ row = _record(
+ value,
+ (
+ "schedule_id",
+ "household_id",
+ "pattern_revision",
+ "binding",
+ "current_occurrence_id",
+ "current_slot_key",
+ "source_after",
+ "last_run_at",
+ "revision",
+ "evidence",
+ ),
+ )
+ return RelativeProgress(
+ cast(str, row["schedule_id"]),
+ cast(str, row["household_id"]),
+ cast(int, row["pattern_revision"]),
+ cast(str, row["binding"]),
+ cast(str | None, row["current_occurrence_id"]),
+ cast(str | None, row["current_slot_key"]),
+ cast(int, row["source_after"]),
+ cast(int | None, row["last_run_at"]),
+ cast(int, row["revision"]),
+ None if row["evidence"] is None else _operation(row["evidence"]),
+ )
+
+
+def _operation(value: JsonValue) -> RelativeOperation:
+ row = _object(value, ("command", "operation_id"))
+ command = _object(row["command"], ("household_id", "actor_id", "action", "key"))
+ return RelativeOperation(
+ CommandKey(
+ cast(str, command["household_id"]),
+ cast(str, command["actor_id"]),
+ cast(str, command["action"]),
+ cast(str, command["key"]),
+ ),
+ cast(str, row["operation_id"]),
+ )
+
+
+def _source_value(value: RelativeSource) -> JsonObject:
+ local = value.performed_local
+ return {
+ "cycle": cycle_document(value.cycle).decode(),
+ "performed_local": {
+ "instant_ms": local.instant_ms,
+ "local": local.local.isoformat(),
+ "offset_seconds": local.offset_seconds,
+ "policy": _policy_value(local.policy),
+ "zone_digest": local.zone_digest,
+ },
+ }
+
+
+def _source(value: JsonValue) -> RelativeSource:
+ row = _object(value, ("cycle", "performed_local"))
+ local = _object(
+ row["performed_local"],
+ (
+ "instant_ms",
+ "local",
+ "offset_seconds",
+ "policy",
+ "zone_digest",
+ ),
+ )
+ return RelativeSource(
+ read_cycle(JsonDocument.from_value(row["cycle"]).encoded),
+ LocalInstant(
+ cast(int, local["instant_ms"]),
+ cast(datetime, _local(local["local"], timed=True)),
+ cast(int, local["offset_seconds"]),
+ _policy(local["policy"]),
+ cast(str, local["zone_digest"]),
+ ),
+ )
+
+
+def relative_slot_document(value: RelativeSlot) -> JsonDocument:
+ return JsonDocument.from_value(
+ {
+ "version": 1,
+ "installation": {
+ "instance_id": value.installation.instance_id,
+ "channel": value.installation.channel,
+ },
+ "schedule": _schedule_value(value.schedule),
+ "planned": value.planned.value(),
+ "occurrence_id": value.occurrence_id,
+ "created_at": value.created_at,
+ "source": None if value.source is None else _source_value(value.source),
+ }
+ )
+
+
+def _slot(value: JsonValue) -> RelativeSlot:
+ row = _record(
+ value, ("installation", "schedule", "planned", "occurrence_id", "created_at", "source")
+ )
+ installation = _object(row["installation"], ("instance_id", "channel"))
+ return RelativeSlot(
+ Installation(cast(str, installation["instance_id"]), cast(str, installation["channel"])),
+ _schedule(row["schedule"]),
+ _planned(row["planned"]),
+ cast(str, row["occurrence_id"]),
+ cast(int, row["created_at"]),
+ None if row["source"] is None else _source(row["source"]),
+ )
+
+
+def read_relative_schedule(document: str) -> RelativeSchedule:
+ return _read(document, _schedule)
+
+
+def read_relative_progress(document: str) -> RelativeProgress:
+ return _read(document, _progress)
+
+
+def read_relative_slot(document: str) -> RelativeSlot:
+ return _read(document, _slot)
diff --git a/docs/architecture-recurrence.md b/docs/architecture-recurrence.md
index 9bcbf04..3cd6112 100644
--- a/docs/architecture-recurrence.md
+++ b/docs/architecture-recurrence.md
@@ -340,3 +340,39 @@ complete synthetic source proof; they do not establish actual materializer accep
See [original slot evidence](evidence/recurrence-slot-original.json). This refinement changes no
layout, dependency, timezone I/O, timeout, owner installation or retained original operation.
+
+## Shared rule identity and relative persistence
+
+Fixed and completion-relative rules now share globally unique rule/progress rows with an explicit
+timing kind. Deferred generation relationships and compare-and-swap predicates include that kind.
+One nonretired rule owns a definition's current timing; retired history remains. This preserves
+the definition-based selection budget and prevents a typed writer from overwriting the other kind.
+Shared row mechanics stay in infrastructure, behind the existing semantic recurrence port.
+
+Relative progress binds the complete immutable generation, including initial due fact, interval,
+visibility and policy. Even an unconsumed cursor cannot silently accept different valid timing.
+Every actual progress claim also requires an original scoped command/operation whose canonical
+result matches the business continuation. Its revision-bound proof header covers the stored record.
+CAS revision and observed last-run time are separate from business cursor identity. Unknown versions,
+fields, alternate canonical text and boolean/integer aliases are refused.
+
+A relative slot preserves complete original generation, planned/resolved and normalized source facts
+in its evidence fingerprint. Reads verify its actual task and source-cycle relationship, including
+the original completion after later approval or closure. That is retained factual history; it does
+not establish source eligibility or perform successor invalidation. Constructors and saved records
+grant no maintenance capability. Corrupt originals on reads or writes are storage failures.
+
+Actual offline restore tests keep a generated successor absent when its original task and mapping
+were rolled away. They separately preserve an adopted manual task that still exists while its later
+mapping is absent. Both keep original proof without new task IDs, receipts or event delivery. A
+historical pointer cannot authorize automatic recreation of that processed mapping.
+
+The schema8 layout is unreleased. Supported released1..7 fixtures remain unchanged; the genuine
+[prior06 layout](../tests/fixtures/sqlite/unreleased-06b3b9f-schema8.sql) is refused unchanged rather
+than relabeled or silently upgraded. No owner HA installation changes. See
+[relative storage evidence](evidence/recurrence-relative-storage.json) for exact measured scope.
+
+Authorized relative creation, actual initial/successor materialization, source inbox/closure and
+eligibility, restore-source reconciliation, successor invalidation, mixed-rule execution and runtime
+wiring remain required. Synthetic repository receipts and image codec/storage controls do not
+establish those consumer journeys or the remaining calendar/UI/HA/all-domain release acceptance.
diff --git a/docs/evidence/recurrence-relative-storage.json b/docs/evidence/recurrence-relative-storage.json
new file mode 100644
index 0000000..d6a8949
--- /dev/null
+++ b/docs/evidence/recurrence-relative-storage.json
@@ -0,0 +1,152 @@
+{
+ "status": "partial; durable relative storage implemented, full retained issue20 execution remains in progress",
+ "recordedAtUTC": "2026-11-02T12:11:55.026405+00:00",
+ "issue": "https://github.com/vypdev/domus/issues/20",
+ "pullRequest": "https://github.com/vypdev/domus/pull/22",
+ "sourceBase": "8b0aafda1a4cd8d1d63bf782b04f2709fb7db46c",
+ "backendSourceInventorySHA256": "43e40358642c0f0f48282eb4a38d9b65f666ef95e29cd85794b750f231784921",
+ "implemented": [
+ "Closed canonical version1 relative schedule/progress/slot codecs preserve original cycle and actual local-time facts, including an epoch instant whose local date is1969.",
+ "Fixed and relative rules share global identities, closed timing kinds, generation relationships and kind-aware CAS. One nonretired timing owner per definition preserves definition-based execution selection; retired history survives.",
+ "Every relative continuation binds the complete immutable timing generation. Nonbootstrap state additionally matches an exact scoped original command/operation result and revision-bound record proof.",
+ "Present slots match their original full timing/source fingerprint, actual target creation and normalized original source completion, even after later approval or closure. This proves stored facts without granting source eligibility.",
+ "Real offline restore preserves both a restored-away generated task and an absent mapping of still-present adopted manual work as historically processed; no automatic recreation, new original, identity or event delivery occurs.",
+ "Independent SQLite writers have one atomic rule/slot winner. Deferred original evidence, generation pairs, duplicate identities and late failures preserve atomic rollback.",
+ "Shared snapshot validation checks both typed timing kinds and strict originals. Malformed stored originals on writes are storage failures without partial state."
+ ],
+ "testsFirst": {
+ "originalPublishedBase": "Four initial repository tests failed against8b: three missing relative APIs and one accepted prior development layout. A separate codec draft first failed collection because its implementation module did not exist.",
+ "prototypeBinding": "Twelve meaningful value/read/snapshot failures exposed valid-field rebinding in the unpublished initial storage prototype. These are not attributed to nonexistent relative persistence in published8b.",
+ "prototypeRestore": "An actual adopted-manual restore first failed because the provisional missing-mapping check required the task to be absent. The corrected relation preserves existing manual work and historical mapping absence.",
+ "prototypeErrorClassification": "A malformed stored original first raised InvalidInput during progress write; the correction reports StorageFailure and preserves source bytes.",
+ "earlierFull": "First3924-test campaign passed at99.53% coverage. Later3986 passed100%; the final3987 campaign includes the separate malformed-original write regression.",
+ "scope": "78 added backend executions relative to3909 baseline; parameterizations and synthetic repository proofs are not independent product acceptance journeys."
+ },
+ "completeBackend": {
+ "executions": 3987,
+ "durationSeconds": 104.04,
+ "authoredModules": 136,
+ "coverageTotals": {
+ "covered_lines": 9790,
+ "num_statements": 9790,
+ "percent_covered": 100.0,
+ "percent_covered_display": "100.00",
+ "missing_lines": 0,
+ "excluded_lines": 292,
+ "percent_statements_covered": 100.0,
+ "percent_statements_covered_display": "100.00",
+ "num_branches": 2796,
+ "num_partial_branches": 0,
+ "covered_branches": 2796,
+ "missing_branches": 0,
+ "percent_branches_covered": 100.0,
+ "percent_branches_covered_display": "100.00"
+ },
+ "independentGlobalAndPerModuleAudit": "passed on the final report and current module inventory"
+ },
+ "checks": {
+ "strictTypes": "passed;136 modules",
+ "ruff": "check/format passed;345 files",
+ "architecture": {
+ "contractsKept": 3,
+ "contractsBroken": 0,
+ "filesAnalyzed": 180,
+ "dependencies": 1173
+ },
+ "originalPinnedSdd": {
+ "upstream": "3e7ceea08e6131360e026937d1c0cc1b120472e0",
+ "base": "0806a38f8a2de1f102a017bff25ad6856fb17bcd",
+ "selected": "specs/recurrence-storage-continuation.md",
+ "originalPolicyModulesVerified": 7,
+ "format": "PASS",
+ "consistency": "PASS",
+ "composition": "explicit contributor integration; original publication retained in Git",
+ "unchangedCatalog": "PASS",
+ "immutableOwnerDocumentsUsed": 18,
+ "retainedCompanionCases": 52,
+ "newCases": 4,
+ "rawCharacters": 41753,
+ "renderedCharacters": 41753,
+ "composedCharacters": 41753,
+ "applicableDraftMaximumCharacters": 70000,
+ "sha256": "a317ee4f8b3cc4517098f244966bd04a127f91bc7a70ca6c67dc5723cef57c04",
+ "scope": "Review input only; no gate admission, branch publication, source implementation or runtime acceptance.",
+ "retainedOwningFilesUnchanged": true,
+ "workingDocumentValidated": true
+ },
+ "freshPackageAndArchitecture": {
+ "executions": 52,
+ "durationSeconds": 6.77,
+ "result": "passed"
+ },
+ "exactWheel": {
+ "modules": 136,
+ "wheelSHA256": "208cd14903d6685f4ba863585f53098c35d498087740bbe848aeed6e13a26b77",
+ "scope": "Exact current module bytes match the freshly built wheel; no CPU execution or HA claim."
+ },
+ "currentImageScriptHost": "passed; existing fixed/time/original controls plus an isolated synthetic relative storage/codec proof and full checkpoint. No actual relative consumer is implied.",
+ "freshHosted": "Required after publication. Local host controls are distinct from actual amd64/aarch64 execution; no current hosted success is claimed yet."
+ },
+ "compatibility": {
+ "schema": 8,
+ "status": "unreleased layout refinement; six recurrence tables and typed shared ownership",
+ "supportedReleasedSources": [
+ 1,
+ 2,
+ 3,
+ 4,
+ 5,
+ 6,
+ 7
+ ],
+ "genuinePriorSource": "06b3b9f38955a25be2511b68b2ea667a84a42d8f",
+ "originalBackendModulesByteVerified": 133,
+ "completeLogicalFixtureSHA256": "aed613d7a6c2718827c05d44cd3fbbdb9d996fce44097f893b5c9bfd4dc28565",
+ "rawDatabaseSHA256": "5d4c4e26e273e7f26e579e728db243c39e29b21dbef53378cdd2368643debe09",
+ "priorLayout": "Exact captured header pragmas and actual UTC-UNTIL slots retained. Current normal reads/writes and initialization refuse its five-table development layout unchanged. No relabeling or released8 upgrade claim.",
+ "dependenciesChanged": false,
+ "providerIoAdded": false,
+ "timeoutsChanged": false,
+ "copilotChanged": false,
+ "ownerHa": "No HA request, update, migration, release or deployment. Installed Edge.5/schema7 and retained rollback remain unchanged."
+ },
+ "retainedScope": {
+ "companionCases": 52,
+ "originalOwnerCases": 37,
+ "additionalStorageCases": 4,
+ "newCountedProductJourneys": 0,
+ "owningSddsUnchanged": true
+ },
+ "priorPublishedReview": {
+ "head": "8b0aafda1a4cd8d1d63bf782b04f2709fb7db46c",
+ "productRun": 41435797354,
+ "aggregateJob": 125247642850,
+ "productResult": "all applicable suites passed including both actual CPU images",
+ "nativeRun": 41435797183,
+ "nativeJob": 125245131294,
+ "partitions": "12/12",
+ "files": 93,
+ "fragments": 112,
+ "openFindings": 0,
+ "fixedFindings": 6,
+ "result": "partial/neutral; human conversation7 fetched/5 retained/2 omitted/0 truncated/limitReached. Separate issue6/6,PR11/11,threads6/6,diff93/93 complete.",
+ "manualSupplement": "Eight known agent-authored maintainer-account technical inputs inspected; no new owner answers, omitted-item identity or complete-review claim inferred.",
+ "progressReference": "bb80b30f-fb77-4df7-bd60-e5ad40b2d7e2"
+ },
+ "copilotReports": [
+ "https://github.com/vypdev/copilot/issues/851#issuecomment-6565229213",
+ "https://github.com/vypdev/copilot/issues/843#issuecomment-6565011301"
+ ],
+ "rawEvidence": {
+ "privateCampaign": "/private/tmp/domus-relative-storage-06b3b9f",
+ "consumer": "CurrentPR22 storage review: actual original failures, final module/coverage/package proof, strict SDK evidence and genuine original06 input. Compact terminal producers after publication; preserve authored input and active next-consumer plan separately."
+ },
+ "pending": [
+ "Fresh current-source product CI, actual CPU images and native review after publication.",
+ "Current-authorized relative creation/attachment and actual initial/successor materialization.",
+ "Bounded current/historical source observations, inbox, closure dominance, eligibility, invalidation and restore-source reconciliation.",
+ "One global mixed-rule definition/slot budget and durable frontier; both owned runtime compositions.",
+ "Protected calendar/operation APIs, reviewed schedules/exceptions/classification and configurable household timing defaults.",
+ "Complete retained52+37 cases and full UI/HA/Stable-Edge/all-domain release acceptance. KeepPR22 open until complete retained issue20 scope."
+ ]
+}
diff --git a/docs/evidence/recurrence-storage.json b/docs/evidence/recurrence-storage.json
index 7dac945..bde89b9 100644
--- a/docs/evidence/recurrence-storage.json
+++ b/docs/evidence/recurrence-storage.json
@@ -85,7 +85,7 @@
"policyModulesByteVerified": 7,
"immutableBaseOwners": 18,
"retainedOwnersUnchanged": true,
- "currentCompanionSHA256": "f85a33a474e3db73932506dde262ea5c34ba67f631e859aa55f2d4ce8e02293b",
+ "currentCompanionSHA256": "a317ee4f8b3cc4517098f244966bd04a127f91bc7a70ca6c67dc5723cef57c04",
"scope": "Explicit contributor integration; original first publication remains in Git. Format, reciprocal inventory/path consistency and current catalog pass."
},
"packageAndArchitectureTests": {
@@ -285,5 +285,10 @@
"laterMaterializationIncrement": "docs/evidence/recurrence-materialization.json; original storage-kernel results remain historical and scope-specific.",
"laterFairnessIncrement": "docs/evidence/recurrence-fairness.json; original storage-kernel acceptance scope remains unchanged.",
"laterUntilIntegrityIncrement": "docs/evidence/recurrence-until-integrity.json; original storage-kernel results retain their historical source and scope.",
- "laterSlotOriginalIncrement": "docs/evidence/recurrence-slot-original.json; prior kernel/source results remain historical."
+ "laterSlotOriginalIncrement": "docs/evidence/recurrence-slot-original.json; prior kernel/source results remain historical.",
+ "relativeStorageContinuation": {
+ "evidence": "recurrence-relative-storage.json",
+ "backendSourceInventorySHA256": "43e40358642c0f0f48282eb4a38d9b65f666ef95e29cd85794b750f231784921",
+ "scope": "Historical initial storage measurements above remain unchanged. See this later increment for typed shared identity and relative persistence; full consumer and product acceptance remain pending."
+ }
}
diff --git a/docs/tasks/migration.md b/docs/tasks/migration.md
index b3881e3..f8fcf61 100644
--- a/docs/tasks/migration.md
+++ b/docs/tasks/migration.md
@@ -98,6 +98,21 @@ The original database remains byte-identical, with SHA-256
initialization refuse its previous development layout unchanged. There is no released8 upgrade
claim, version-number downgrade or alteration to any released1..7 input.
+Relative storage subsequently refines the unreleased schema8 layout with a closed timing kind on
+the shared rule/progress rows, generation relationships that include it, one nonretired timing owner
+per definition, and recurrence_relative_slots. Fixed and relative typed writers cannot overwrite
+each other's identities. Source-cycle references and original proof retain scoped facts at commit.
+
+The genuine [06b3b9f input](../../tests/fixtures/sqlite/unreleased-06b3b9f-schema8.sql) was captured
+before this change with two actual UTC-UNTIL slots and revision-bound original time evidence. All133
+original backend module bytes were checked against source06b3b9f38955a25be2511b68b2ea667a84a42d8f.
+The complete logical input SHA-256 is aed613d7a6c2718827c05d44cd3fbbdb9d996fce44097f893b5c9bfd4dc28565;
+its unchanged raw database SHA-256 is 5d4c4e26e273e7f26e579e728db243c39e29b21dbef53378cdd2368643debe09.
+It restores the exact captured application/schema header pragmas omitted by SQLite iterdump. Current
+normal reads/writes and initialization refuse its five-table development layout without changing it.
+Keep the matching original application/data pair for intentional development rollback; the supported
+released source set remains1..7. This adds no released8 migration or automatic retention policy.
+
Those observations are retained in [task evidence](../evidence/task-execution-foundation.json).
They are distinct from image/cross-architecture adoption, real consumer ledgers and a restoration on
the owner's HA, which remain required before integrated release claims. No automatic backup schedule
diff --git a/specs/recurrence-storage-continuation.md b/specs/recurrence-storage-continuation.md
index 8ba7aa4..c94533b 100644
--- a/specs/recurrence-storage-continuation.md
+++ b/specs/recurrence-storage-continuation.md
@@ -166,6 +166,9 @@ Use real independent SQLite connections and exact before/after version/row check
| backend/src/domus/infrastructure/sqlite_recurrence.py | create |
| backend/src/domus/infrastructure/sqlite_recurrence_schema.py | create |
| backend/src/domus/infrastructure/sqlite_recurrence_records.py | create |
+| backend/src/domus/infrastructure/sqlite_recurrence_rows.py | create |
+| backend/src/domus/infrastructure/sqlite_relative_recurrence.py | create |
+| backend/src/domus/infrastructure/sqlite_relative_recurrence_records.py | create |
| tests/backend/test_sqlite_recurrence.py | create |
| tests/backend/test_recurrence_records.py | create |
| tests/backend/test_recurrence_creation.py | create |
@@ -185,6 +188,10 @@ Use real independent SQLite connections and exact before/after version/row check
| tests/backend/test_recurrence_cursor_evidence.py | create |
| tests/backend/test_recurrence_until_proof.py | create |
| tests/backend/test_recurrence_slot_original.py | create |
+| tests/backend/test_relative_recurrence_evidence.py | create |
+| tests/backend/test_sqlite_relative_recurrence.py | create |
+| tests/backend/test_sqlite_relative_recurrence_records.py | create |
+| tests/backend/test_sqlite_relative_integrity.py | create |
| tests/backend/test_recurrence_slot_membership.py | create |
| tests/backend/test_sqlite_recurrence_records.py | create |
| tests/backend/test_sqlite_recurrence_integrity.py | create |
@@ -192,6 +199,7 @@ Use real independent SQLite connections and exact before/after version/row check
| tests/fixtures/sqlite/v7.sql | create |
| tests/fixtures/sqlite/unreleased-c581-schema8.sql | create |
| tests/fixtures/sqlite/unreleased-b2bcc69-schema8.sql | create |
+| tests/fixtures/sqlite/unreleased-06b3b9f-schema8.sql | create |
| tests/backend/test_sqlite_transactions.py | update |
| tests/backend/test_sqlite_snapshots.py | update |
| tests/backend/test_activation_compatibility.py | update |
@@ -215,6 +223,7 @@ Use real independent SQLite connections and exact before/after version/row check
| docs/evidence/recurrence-execution.json | update |
| docs/evidence/recurrence-until-integrity.json | create |
| docs/evidence/recurrence-slot-original.json | create |
+| docs/evidence/recurrence-relative-storage.json | create |
Existing retained path declarations describe their original publication baseline; the table above identifies this continuation's actual new and existing storage paths at the immutable base. Declare any additional real implementation/test path before committing it. Update exact evidence and current acceptance limits without relabeling historical results.
@@ -274,6 +283,14 @@ The worker carries cancellation into every run and joins before runtime identity
## 19. Definition of Done
+Relative persistence uses the same globally unique rule/progress identity with an explicit closed timing discriminator. Deferred generation relationships include that kind, and CAS predicates cannot overwrite the other kind. One nonretired rule owns each definition's current timing; retired history remains. This also preserves the runner's definition-based selection budget. Independent legacy pagination fixtures must represent different definitions rather than multiple current owners of one definition. This does not apply a timing-mode replacement or provide a new public configuration command.
+
+Every relative continuation, including its unconsumed bootstrap, binds the full immutable generation: initial due fact, interval, visibility, policy, scope and pattern revision. A nonbootstrap advance also references an exact original command/operation whose canonical result matches its business state. CAS revision and observed last-run time are not business cursor identity. A revision-bound proof header, canonical record and current or retained original must agree. A present slot additionally verifies its complete original generation/local/source fingerprint and actual task/source-cycle relationship. Current approval or closure cannot rewrite the normalized original completion facts; storage validation grants no source eligibility or execution authority.
+
+Actual restore controls distinguish a generated task removed by restoration from an adopted manual task that still exists while its later recurrence mapping is absent. Neither permits automatic recreation of the processed mapping, new task identities, original receipts or event delivery. Historical pointers retain explicit original proof; existing manual work survives. Source-cycle closure dominance, durable consumer inbox/closure, source eligibility, authorized creation, actual relative materialization, invalidation, mixed-rule budgets and runtime wiring still require their own implementation and acceptance. Synthetic repository receipts and packaged codec controls are not evidence of those missing consumer journeys.
+
+This adds relative slots and typed ownership to the unreleased schema8 layout. Keep released1..7 inputs unchanged. Preserve the genuine06 source/data pair and complete captured header pragmas; refuse its prior five-table development layout without relabeling or claiming a released8 migration. The current owner still runs Edge.5/schema7. These are retained storage/execution refinements, not additional counted product cases.
+
Older slot snapshots remain permitted after a current rule edit, but that relationship alone cannot prove original generation facts. Compare each persisted slot with its exact current materialization receipt: scoped identity, slot/occurrence/rule/definition identifiers, original cursor pattern-and-policy binding, explicit policy snapshot and complete nominal/resolved last-slot facts. Compare canonical JSON documents so bool/integer aliases do not pass. Reuse this pure relation in independent repository point/page reads and checkpoint validation; malformed, foreign, historical-absent or missing originals fail safely. Keep real valid older-generation controls. Existing synthetic repository fixtures must carry explicit complete synthetic proof rather than bare identifiers, without claiming actual materializer acceptance. No extra case count, provider I/O, schema relabeling or alteration of retained original receipts is introduced.
Browser CI later recorded a503 rejection whose body was unavailable through Chromium CDP, while the actual application showed the rejected outcome and preserved its draft. Retain that trace; its generic navigation guidance does not establish an actual unexpected navigation. Contention tests may buffer the actual upstream HTTP response before forwarding the same bytes, status and headers to the browser, with redirects and network retries disabled. Preserve real independent SQLite writer contention, browser response checks, exact request metadata, current-authorized replay, draft/journal identity and no-duplicate assertions. A test observation repair changes neither product behavior nor storage timeouts, and adds no counted product journey.
diff --git a/tests/backend/test_relative_recurrence_evidence.py b/tests/backend/test_relative_recurrence_evidence.py
new file mode 100644
index 0000000..920091e
--- /dev/null
+++ b/tests/backend/test_relative_recurrence_evidence.py
@@ -0,0 +1,137 @@
+"""Relative original proof binds facts, while never granting current maintenance authority."""
+
+from dataclasses import replace
+
+import pytest
+from test_relative_recurrence_records import values
+
+from domus.domain.errors import InvalidInput
+from domus.domain.relative_recurrence import RelativeOperation
+from domus.domain.storage import JsonDocument, Receipt, RestorationProvenance
+
+
+def original(timed=True):
+ rule, progress, slot, _ = values(timed)
+ progress = replace(
+ progress,
+ current_occurrence_id=slot.occurrence_id,
+ current_slot_key=slot.key,
+ source_after=17,
+ last_run_at=slot.created_at,
+ evidence=RelativeOperation(slot.command, "original"),
+ )
+ receipt = Receipt(
+ slot.command,
+ JsonDocument.from_value({}).fingerprint,
+ "original",
+ JsonDocument.from_value(
+ {
+ "slot_key": slot.key,
+ "occurrence_id": slot.occurrence_id,
+ "schedule_id": rule.schedule_id,
+ "definition_id": rule.definition_id,
+ "relative_slot_fingerprint": slot.evidence_fingerprint,
+ "relative_progress": progress.state_document().decode(),
+ }
+ ),
+ slot.created_at,
+ )
+ return rule, progress, slot, receipt
+
+
+@pytest.mark.parametrize("timed", [False, True])
+def test_complete_original_proof_binds_progress_and_slot_without_mutable_record_revision(timed):
+ rule, progress, slot, receipt = original(timed)
+ assert progress.matches_schedule(rule) and progress.matches_receipt(receipt)
+ assert slot.matches_receipt(receipt)
+ assert (
+ progress.state_document()
+ == replace(progress, revision=2, last_run_at=slot.created_at + 1).state_document()
+ )
+ assert (
+ replace(slot, schedule=replace(rule, revision=2)).evidence_fingerprint
+ == slot.evidence_fingerprint
+ )
+ changed = replace(slot, occurrence_id="other")
+ assert changed.key == slot.key and changed.evidence_fingerprint != slot.evidence_fingerprint
+ assert not changed.matches_receipt(receipt)
+ absent = replace(receipt, restoration=RestorationProvenance("restore", False))
+ assert progress.matches_receipt(absent) and not slot.matches_receipt(absent)
+
+
+@pytest.mark.parametrize("change", ["none", "command", "operation", "position", "boolean"])
+def test_progress_rejects_a_different_original_scope_operation_or_continuation(change):
+ _, progress, _, receipt = original()
+ if change == "none":
+ receipt = None
+ elif change == "command":
+ receipt = replace(receipt, command=replace(receipt.command, key="other"))
+ elif change == "operation":
+ receipt = replace(receipt, operation_id="other")
+ else:
+ body = receipt.result.decode()
+ body["relative_progress"]["pattern_revision" if change == "boolean" else "source_after"] = (
+ True if change == "boolean" else 18
+ )
+ receipt = replace(receipt, result=JsonDocument.from_value(body))
+ assert not progress.matches_receipt(receipt)
+
+
+def test_unconsumed_bootstrap_is_the_only_progress_that_needs_no_original():
+ rule, progress, _, _ = original()
+ empty = values()[1]
+ assert empty.pristine and empty.matches_receipt(None)
+ assert not replace(progress, evidence=None).matches_receipt(None)
+ assert not replace(empty, source_after=1).matches_receipt(None)
+ assert not replace(empty, last_run_at=100).matches_receipt(None)
+ foreign = replace(
+ progress,
+ evidence=replace(
+ progress.evidence, command=replace(progress.evidence.command, household_id="other")
+ ),
+ )
+ assert not foreign.matches_schedule(rule)
+ with pytest.raises(InvalidInput):
+ replace(empty, evidence="wrong")
+ with pytest.raises(InvalidInput):
+ RelativeOperation(None, "operation")
+ with pytest.raises(InvalidInput):
+ RelativeOperation(progress.evidence.command, "")
+
+
+@pytest.mark.parametrize("change", ["none", "command", "fingerprint", "slot", "definition"])
+def test_slot_cannot_borrow_another_original_materialization(change):
+ _, _, slot, receipt = original()
+ if change == "none":
+ receipt = None
+ elif change == "command":
+ receipt = replace(receipt, command=replace(receipt.command, actor_id="other"))
+ else:
+ body = receipt.result.decode()
+ body[
+ {
+ "fingerprint": "relative_slot_fingerprint",
+ "slot": "slot_key",
+ "definition": "definition_id",
+ }[change]
+ ] = "other"
+ receipt = replace(receipt, result=JsonDocument.from_value(body))
+ assert not slot.matches_receipt(receipt)
+
+
+def test_unchanged_cycle_identity_cannot_replace_original_performers_in_evidence():
+ _, _, slot, receipt = original()
+ source = slot.source
+ cycle = replace(
+ source.cycle, performers=(replace(source.cycle.performers[0], member_id="different"),)
+ )
+ altered = replace(slot, source=replace(source, cycle=cycle))
+ assert altered.key == slot.key and altered.evidence_fingerprint != slot.evidence_fingerprint
+ assert not altered.matches_receipt(receipt)
+
+
+@pytest.mark.parametrize("binding", [None, True, "", "a" * 63, "A" * 64])
+def test_relative_continuation_binding_is_a_canonical_exact_fingerprint(binding):
+ progress = values()[1]
+ with pytest.raises(InvalidInput):
+ replace(progress, binding=binding)
diff --git a/tests/backend/test_relative_recurrence_records.py b/tests/backend/test_relative_recurrence_records.py
index e8b1d2c..d9e9d73 100644
--- a/tests/backend/test_relative_recurrence_records.py
+++ b/tests/backend/test_relative_recurrence_records.py
@@ -52,7 +52,7 @@ def values(timed=True, *, restricted=False):
local.instant_ms + 100,
source,
)
- return schedule, RelativeProgress("relative", "home", 1), slot, completed
+ return schedule, RelativeProgress("relative", "home", 1, schedule.binding), slot, completed
@pytest.mark.parametrize("timed", [False, True])
diff --git a/tests/backend/test_sqlite_recurrence_integrity.py b/tests/backend/test_sqlite_recurrence_integrity.py
index f25e4ea..58a60a9 100644
--- a/tests/backend/test_sqlite_recurrence_integrity.py
+++ b/tests/backend/test_sqlite_recurrence_integrity.py
@@ -271,8 +271,13 @@ def test_pattern_edit_requires_matching_progress_and_preserves_existing_slot_sna
def test_rule_list_limit_and_keyset_return_only_the_requested_owned_rows(tmp_path):
store, rule, progress, _ = prepared(tmp_path)
with store.write() as unit:
+ definition = unit.tasks.definition("home", rule.definition_id)
for key in ["a", "z"]:
- unit.recurrence.save_schedule(replace(rule, schedule_id=key), 0)
+ new_definition = replace(definition, definition_id="definition-" + key)
+ unit.tasks.save_definition(new_definition, 0)
+ unit.recurrence.save_schedule(
+ replace(rule, schedule_id=key, definition_id=new_definition.definition_id), 0
+ )
unit.recurrence.save_progress(replace(progress, schedule_id=key), 0)
with store.read() as unit:
first = unit.recurrence.schedules("home", limit=1)
diff --git a/tests/backend/test_sqlite_relative_integrity.py b/tests/backend/test_sqlite_relative_integrity.py
new file mode 100644
index 0000000..f55fce6
--- /dev/null
+++ b/tests/backend/test_sqlite_relative_integrity.py
@@ -0,0 +1,351 @@
+"""Relative state binds its exact generation and original evidence across corruption and restore."""
+
+from dataclasses import replace
+from datetime import timedelta
+
+import pytest
+from test_sqlite_relative_recurrence import prepared, proof
+from test_sqlite_snapshots import Clock
+
+from domus.domain.errors import DatabaseRejected, StorageFailure
+from domus.domain.recurrence_planning import PlannedSlot
+from domus.domain.recurrence_rules import NominalDate
+from domus.domain.relative_recurrence import RelativeSlot, SuccessorVisibility
+from domus.infrastructure.sqlite_relative_recurrence_records import relative_schedule_document
+from domus.infrastructure.sqlite_snapshots import SQLiteSnapshots
+
+
+def successor(tmp_path, *, restricted=False, before_successor=None):
+ from test_household_commands import Actor
+ from test_household_commands import Clock as CommandClock
+
+ from domus.application.relative_recurrence import RelativeRecurrencePlanner
+ from domus.application.task_commands import CompleteTask, TaskCommands
+ from domus.infrastructure.application_runtime import SecureIdentifiers
+ from domus.infrastructure.recurrence_time import PinnedRecurrenceTime
+
+ store, rule, progress, initial, _ = committed(tmp_path)
+ commands = TaskCommands(store, Actor(), CommandClock(), SecureIdentifiers())
+ with store.read() as unit:
+ member = unit.members.by_user("home", "ha_admin").member_id
+ done = commands.complete(
+ "complete-source",
+ CompleteTask(initial.occurrence_id, 1, ("child" if restricted else member,), 90),
+ )
+ with store.read() as unit:
+ cycle = unit.tasks.cycle("home", done.result.decode()["cycle"]["cycle_id"])
+ definition = unit.tasks.definition("home", rule.definition_id)
+ event = unit.tasks.lifecycle_events("home")[0]
+ if before_successor is not None:
+ before_successor(store)
+ plan = RelativeRecurrencePlanner(PinnedRecurrenceTime()).plan(rule, cycle)
+ work = definition.new_occurrence("generated-next", 101)
+ slot = RelativeSlot(
+ store.installation, rule, plan.planned, work.occurrence_id, work.created_at, plan.source
+ )
+ with store.write() as unit:
+ unit.tasks.save_occurrence(work, 0)
+ unit.recurrence.add_relative_slot(slot)
+ next_progress, receipt = proof(
+ unit, slot, replace(progress, source_after=event.position), "successor-operation"
+ )
+ unit.recurrence.save_relative_progress(next_progress, 2)
+ return commands, store, rule, next_progress, slot, receipt
+
+
+def committed(tmp_path):
+ store, rule, progress, work_id = prepared(tmp_path)
+ slot = RelativeSlot(store.installation, rule, rule.initial, work_id, 100)
+ with store.write() as unit:
+ unit.recurrence.add_relative_slot(slot)
+ progress, receipt = proof(unit, slot, progress)
+ unit.recurrence.save_relative_progress(progress, 1)
+ return store, rule, progress, slot, receipt
+
+
+@pytest.mark.parametrize("field", ["interval", "initial", "policy", "visibility"])
+@pytest.mark.parametrize("surface", ["value", "read", "snapshot"])
+def test_valid_fields_cannot_rebind_unconsumed_relative_progress(tmp_path, field, surface):
+ store, rule, progress, _ = prepared(tmp_path)
+ changes = {
+ "interval": {"interval": replace(rule.interval, days=31)},
+ "initial": {
+ "initial": PlannedSlot(
+ NominalDate(rule.initial.nominal.local + timedelta(days=1)), None
+ )
+ },
+ "policy": {"policy": replace(rule.policy, revision=2)},
+ "visibility": {"visibility": SuccessorVisibility.AT_VERIFICATION},
+ }
+ altered = replace(rule, **changes[field])
+ if surface == "value":
+ assert not progress.matches_schedule(altered)
+ return
+ with store._connection() as connection:
+ connection.execute(
+ "UPDATE recurrence_rules SET document=?", (relative_schedule_document(altered).encoded,)
+ )
+ original = store.path.read_bytes()
+ if surface == "read":
+ with store.read() as unit:
+ with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
+ unit.recurrence.relative_progress("home", rule.schedule_id)
+ else:
+ with pytest.raises(DatabaseRejected, match="invalid_snapshot_records"):
+ SQLiteSnapshots(store, tmp_path / "bad", store.installation, Clock()).create("bad")
+ assert store.path.read_bytes() == original
+
+
+def test_restore_of_a_missing_mapping_retains_its_original_and_existing_manual_work(tmp_path):
+ from test_activation_compatibility import identity_at
+
+ from domus.infrastructure.owned_storage import open_storage
+ from domus.infrastructure.sqlite_activation import SQLiteActivation
+ from domus.infrastructure.sqlite_restoration import SQLiteRestorations
+
+ root = tmp_path / "source"
+ root.mkdir(mode=0o700)
+ store, rule, progress, work_id = prepared(root)
+ identity_at(root, store.installation)
+ archive, target = tmp_path / "checkpoint", tmp_path / "prepared"
+ checkpoint = SQLiteSnapshots(store, archive, store.installation, Clock()).create(
+ "before-mapping"
+ )
+ slot = RelativeSlot(store.installation, rule, rule.initial, work_id, 100)
+ with store.write() as unit:
+ unit.recurrence.add_relative_slot(slot)
+ advanced, receipt = proof(unit, slot, progress)
+ unit.recurrence.save_relative_progress(advanced, 1)
+ SQLiteRestorations(store, archive, target, store.installation, Clock(), checkpoint).prepare(
+ "relative-restore"
+ )
+ assert SQLiteActivation(root, store.installation, Clock(), target).activate().state == "active"
+ storage = open_storage(root, "edge")
+ try:
+ with storage.database.write() as unit:
+ historical = unit.receipts.get(slot.command)
+ assert historical.historical and historical.operation_id == receipt.operation_id
+ assert unit.tasks.occurrence("home", work_id) is not None
+ assert unit.recurrence.relative_slot("home", slot.key) is None
+ unit.recurrence.save_relative_progress(advanced, 1)
+ with storage.database.read() as unit:
+ assert unit.recurrence.relative_progress("home", rule.schedule_id) == advanced
+ assert unit.recurrence.relative_slots("home", rule.schedule_id) == ()
+ assert unit.receipts.get(slot.command).historical
+ assert all(item.event_id != receipt.operation_id for item in unit.outbox.pending())
+ assert SQLiteSnapshots(
+ storage.database, tmp_path / "after", store.installation, Clock()
+ ).create("after")
+ finally:
+ storage.close()
+
+
+def test_actual_source_cycle_survives_approval_and_closure_without_rewriting_slot_facts(tmp_path):
+ from domus.application.task_commands import ReopenTask, VerifyTask
+ from domus.domain.task_values import VerificationState
+
+ commands, store, rule, progress, slot, _ = successor(tmp_path, restricted=True)
+ original_source = slot.source
+ assert original_source.cycle.verification is VerificationState.PENDING
+ assert SQLiteSnapshots(store, tmp_path / "before", store.installation, Clock()).create("before")
+ commands.verify(
+ "approve",
+ VerifyTask(
+ slot.source.cycle.occurrence_id,
+ 2,
+ slot.source.cycle.cycle_id,
+ VerificationState.APPROVED,
+ ),
+ )
+ commands.reopen(
+ "reopen",
+ ReopenTask(slot.source.cycle.occurrence_id, 3, slot.source.cycle.cycle_id, "Repeat"),
+ )
+ with store.read() as unit:
+ assert unit.recurrence.relative_slot("home", slot.key) == slot
+ assert unit.recurrence.relative_progress("home", rule.schedule_id) == progress
+ assert unit.tasks.cycle("home", slot.source.cycle.cycle_id).closed_at is not None
+ assert slot.source == original_source
+ # This verifies retained storage facts; a recurrence lifecycle consumer must still
+ # invalidate/review the successor rather than treating this read as execution.
+ assert SQLiteSnapshots(store, tmp_path / "after", store.installation, Clock()).create("after")
+
+
+def test_self_consistent_saved_source_proof_cannot_replace_the_actual_live_completion(tmp_path):
+ from domus.domain.storage import JsonDocument
+ from domus.infrastructure.recurrence_time import PinnedRecurrenceTime
+ from domus.infrastructure.sqlite_relative_recurrence_records import relative_slot_document
+
+ _, store, _, _, slot, receipt = successor(tmp_path)
+ cycle = replace(slot.source.cycle, performed_at=89)
+ source = replace(
+ slot.source,
+ cycle=cycle,
+ performed_local=PinnedRecurrenceTime().local_at(89, slot.schedule.policy),
+ )
+ altered = replace(slot, source=source)
+ assert altered.key == slot.key
+ body = receipt.result.decode()
+ body["relative_slot_fingerprint"] = altered.evidence_fingerprint
+ changed_receipt = replace(receipt, result=JsonDocument.from_value(body))
+ assert altered.matches_receipt(changed_receipt)
+ with store._connection() as connection:
+ connection.execute(
+ "UPDATE recurrence_relative_slots SET document=? WHERE slot_key=?",
+ (relative_slot_document(altered).encoded, slot.key),
+ )
+ connection.execute(
+ "UPDATE receipts SET result=? WHERE operation_id=?",
+ (changed_receipt.result.encoded, receipt.operation_id),
+ )
+ before = store.path.read_bytes()
+ with store.read() as unit:
+ with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
+ unit.recurrence.relative_slot("home", slot.key)
+ with pytest.raises(DatabaseRejected, match="invalid_snapshot_records"):
+ SQLiteSnapshots(store, tmp_path / "bad", store.installation, Clock()).create("bad")
+ assert store.path.read_bytes() == before
+
+
+@pytest.mark.parametrize(
+ "change",
+ [
+ "missing-header",
+ "header-revision",
+ "header-operation",
+ "header-fingerprint",
+ "missing-original",
+ "original-json",
+ "original-state",
+ "slot-header",
+ "slot-source",
+ "slot-original",
+ ],
+)
+def test_current_relative_records_refuse_altered_headers_and_originals(tmp_path, change):
+ from domus.domain.storage import JsonDocument
+
+ _, store, rule, _, slot, receipt = successor(tmp_path)
+ with store._connection() as connection:
+ if change == "missing-header":
+ connection.execute("DELETE FROM recurrence_progress_evidence")
+ elif change.startswith("header-"):
+ column, value = {
+ "header-revision": ("progress_revision", 99),
+ "header-operation": ("operation_id", "wrong"),
+ "header-fingerprint": ("evidence_fingerprint", "b" * 64),
+ }[change]
+ connection.execute(f"UPDATE recurrence_progress_evidence SET {column}=?", (value,))
+ elif change == "missing-original":
+ connection.execute("DELETE FROM receipts WHERE operation_id=?", (receipt.operation_id,))
+ elif change == "slot-header":
+ connection.execute(
+ "UPDATE recurrence_relative_slots SET created_at=102 WHERE slot_key=?", (slot.key,)
+ )
+ elif change == "slot-source":
+ connection.execute(
+ "UPDATE recurrence_relative_slots SET source_cycle_id='missing' WHERE slot_key=?",
+ (slot.key,),
+ )
+ else:
+ body = receipt.result.decode()
+ if change == "original-state":
+ body["relative_progress"]["source_after"] += 1
+ if change == "slot-original":
+ body["relative_slot_fingerprint"] = "b" * 64
+ result = (
+ "invalid JSON"
+ if change == "original-json"
+ else JsonDocument.from_value(body).encoded
+ )
+ connection.execute(
+ "UPDATE receipts SET result=? WHERE operation_id=?", (result, receipt.operation_id)
+ )
+ original = store.path.read_bytes()
+ with store.read() as unit:
+ with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
+ unit.recurrence.relative_progress("home", rule.schedule_id)
+ assert store.path.read_bytes() == original
+
+
+@pytest.mark.parametrize(
+ "change", ["missing-progress", "unknown-kind", "missing-rule", "slot-json"]
+)
+def test_semantic_snapshot_checks_cover_missing_and_unrecognized_relative_records(tmp_path, change):
+ from domus.infrastructure.sqlite_recurrence_integrity import verify_recurrence_records
+
+ store, _, _, slot, receipt = committed(tmp_path)
+ with store._connection() as connection:
+ if change == "missing-progress":
+ connection.execute("DELETE FROM recurrence_progress")
+ elif change == "unknown-kind":
+ connection.execute("PRAGMA ignore_check_constraints=ON")
+ connection.execute("UPDATE recurrence_rules SET timing_kind='unknown'")
+ elif change == "missing-rule":
+ connection.execute("DELETE FROM recurrence_rules")
+ else:
+ connection.execute(
+ "UPDATE receipts SET result='invalid JSON' WHERE operation_id=?",
+ (receipt.operation_id,),
+ )
+ with pytest.raises(StorageFailure):
+ verify_recurrence_records(connection, store.installation)
+ if change == "slot-json":
+ with store.read() as unit:
+ with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
+ unit.recurrence.relative_slot("home", slot.key)
+
+
+def test_real_restore_retains_a_generated_successor_proof_without_recreating_its_task(tmp_path):
+ from test_activation_compatibility import identity_at
+
+ from domus.infrastructure.owned_storage import open_storage
+ from domus.infrastructure.sqlite_activation import SQLiteActivation
+ from domus.infrastructure.sqlite_restoration import SQLiteRestorations
+
+ root, archive, target = tmp_path / "source", tmp_path / "before", tmp_path / "prepared"
+ root.mkdir(mode=0o700)
+ checkpoints = []
+
+ def freeze(store):
+ identity_at(root, store.installation)
+ checkpoints.append(
+ SQLiteSnapshots(store, archive, store.installation, Clock()).create("before-child")
+ )
+
+ _, store, rule, progress, slot, receipt = successor(root, before_successor=freeze)
+ SQLiteRestorations(store, archive, target, store.installation, Clock(), checkpoints[0]).prepare(
+ "relative-generated-restore"
+ )
+ assert SQLiteActivation(root, store.installation, Clock(), target).activate().state == "active"
+ storage = open_storage(root, "edge")
+ try:
+ with storage.database.write() as unit:
+ assert unit.tasks.occurrence("home", slot.occurrence_id) is None
+ assert unit.receipts.get(slot.command).historical
+ unit.recurrence.save_relative_progress(progress, 2)
+ with storage.database.read() as unit:
+ assert unit.recurrence.relative_progress("home", rule.schedule_id) == progress
+ assert unit.recurrence.relative_slot("home", slot.key) is None
+ assert len(unit.tasks.occurrences("home")) == 1
+ assert unit.receipts.get(slot.command).operation_id == receipt.operation_id
+ assert all(item.event_id != receipt.operation_id for item in unit.outbox.pending())
+ assert SQLiteSnapshots(
+ storage.database, tmp_path / "after", store.installation, Clock()
+ ).create("after")
+ finally:
+ storage.close()
+
+
+def test_corrupt_original_on_a_write_is_storage_failure_without_partial_state(tmp_path):
+ store, rule, progress, _, receipt = committed(tmp_path)
+ with store._connection() as connection:
+ connection.execute(
+ "UPDATE receipts SET result='invalid JSON' WHERE operation_id=?",
+ (receipt.operation_id,),
+ )
+ original = store.path.read_bytes()
+ with pytest.raises(StorageFailure, match="invalid_recurrence_record"):
+ with store.write() as unit:
+ unit.recurrence.save_relative_progress(replace(progress, revision=3), 2)
+ assert store.path.read_bytes() == original
diff --git a/tests/backend/test_sqlite_relative_recurrence.py b/tests/backend/test_sqlite_relative_recurrence.py
new file mode 100644
index 0000000..c3fc6de
--- /dev/null
+++ b/tests/backend/test_sqlite_relative_recurrence.py
@@ -0,0 +1,348 @@
+"""Relative repository fixtures use explicit synthetic proof, never claim consumer delivery."""
+
+from dataclasses import replace
+from datetime import date
+
+import pytest
+from test_task_commands import fixture, resource
+
+from domus.application.task_requests import CreateTask
+from domus.domain.errors import Conflict, DatabaseRejected, InvalidInput, StorageFailure
+from domus.domain.recurrence_planning import PlannedSlot
+from domus.domain.recurrence_records import ScheduleState
+from domus.domain.recurrence_rules import NominalDate
+from domus.domain.recurrence_time import RelativeInterval, TimePolicy
+from domus.domain.relative_recurrence import (
+ RelativeOperation,
+ RelativeProgress,
+ RelativeSchedule,
+ RelativeSlot,
+)
+from domus.domain.storage import AuditEntry, JsonDocument, OutboxEvent, Receipt
+from domus.domain.task_values import TaskContent
+from domus.infrastructure.recurrence_time import TIMEZONE_DATA_IDENTITY
+from domus.infrastructure.sqlite import SQLiteDatabase
+
+
+def prepared(tmp_path):
+ commands, store, _, _ = fixture(tmp_path)
+ definition_id, occurrence_id = resource(
+ commands.create("base", CreateTask(TaskContent("Relative base")))
+ )
+ rule = RelativeSchedule(
+ "relative",
+ "home",
+ definition_id,
+ PlannedSlot(NominalDate(date(1970, 1, 1)), None),
+ RelativeInterval(30),
+ TimePolicy("UTC", TIMEZONE_DATA_IDENTITY),
+ 100,
+ )
+ progress = RelativeProgress(rule.schedule_id, rule.household_id, 1, rule.binding)
+ with store.write() as unit:
+ unit.recurrence.save_relative_schedule(rule, 0)
+ unit.recurrence.save_relative_progress(progress, 0)
+ return store, rule, progress, occurrence_id
+
+
+def proof(unit, slot, progress, operation_id="relative-operation"):
+ advanced = replace(
+ progress,
+ current_occurrence_id=slot.occurrence_id,
+ current_slot_key=slot.key,
+ revision=progress.revision + 1,
+ last_run_at=slot.created_at,
+ evidence=RelativeOperation(slot.command, operation_id),
+ )
+ result = JsonDocument.from_value(
+ {
+ "slot_key": slot.key,
+ "occurrence_id": slot.occurrence_id,
+ "schedule_id": slot.schedule.schedule_id,
+ "definition_id": slot.schedule.definition_id,
+ "relative_slot_fingerprint": slot.evidence_fingerprint,
+ "relative_progress": advanced.state_document().decode(),
+ }
+ )
+ receipt = Receipt(
+ slot.command, JsonDocument.from_value({}).fingerprint, operation_id, result, slot.created_at
+ )
+ unit.receipts.add(receipt)
+ unit.audit.append(
+ AuditEntry(operation_id, slot.command, JsonDocument.from_value({}), slot.created_at)
+ )
+ unit.outbox.append(
+ OutboxEvent(operation_id, "home", slot.command.action, result, slot.created_at)
+ )
+ return advanced, receipt
+
+
+def test_relative_rule_and_unconsumed_progress_survive_an_independent_reopen(tmp_path):
+ store, rule, progress, _ = prepared(tmp_path)
+ reopened = SQLiteDatabase(store.path, store.installation)
+ with reopened.read() as unit:
+ assert unit.recurrence.relative_schedule("home", rule.schedule_id) == rule
+ assert unit.recurrence.relative_progress("home", rule.schedule_id) == progress
+ assert unit.recurrence.relative_schedules("home") == (rule,)
+ assert unit.recurrence.relative_slots("home", rule.schedule_id) == ()
+ assert unit.recurrence.schedule("home", rule.schedule_id) is None
+ assert unit.recurrence.schedules("home") == ()
+
+
+def test_initial_slot_progress_and_original_receipt_commit_and_read_together(tmp_path):
+ store, rule, progress, work_id = prepared(tmp_path)
+ slot = RelativeSlot(store.installation, rule, rule.initial, work_id, 100)
+ with store.write() as unit:
+ unit.recurrence.add_relative_slot(slot)
+ advanced, receipt = proof(unit, slot, progress)
+ unit.recurrence.save_relative_progress(advanced, 1)
+ with store.read() as unit:
+ assert unit.recurrence.relative_slot("home", slot.key) == slot
+ assert unit.recurrence.relative_progress("home", rule.schedule_id) == advanced
+ assert unit.receipts.get(slot.command) == receipt
+
+
+def test_relative_progress_cannot_claim_a_run_or_skip_source_positions_without_original_proof(
+ tmp_path,
+):
+ store, _, progress, _ = prepared(tmp_path)
+ with pytest.raises(Conflict):
+ with store.write() as unit:
+ unit.recurrence.save_relative_progress(
+ replace(progress, revision=2, source_after=100), 1
+ )
+ with store.read() as unit:
+ assert unit.recurrence.relative_progress("home", progress.schedule_id) == progress
+
+
+def test_genuine_prior_schema8_layout_is_refused_unchanged_before_relative_use(tmp_path):
+ import hashlib
+ import sqlite3
+ from contextlib import closing
+ from pathlib import Path
+
+ from domus.domain.storage import Installation
+
+ fixture = Path("tests/fixtures/sqlite/unreleased-06b3b9f-schema8.sql")
+ assert (
+ hashlib.sha256(fixture.read_bytes()).hexdigest()
+ == "aed613d7a6c2718827c05d44cd3fbbdb9d996fce44097f893b5c9bfd4dc28565"
+ )
+ path = tmp_path / "prior.sqlite3"
+ with closing(sqlite3.connect(path, autocommit=True)) as connection:
+ connection.executescript(fixture.read_text())
+ identity = Installation(
+ *connection.execute("SELECT instance_id,channel FROM installation").fetchone()
+ )
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 8
+ assert (
+ connection.execute("SELECT count(*) FROM recurrence_progress_evidence").fetchone()[0]
+ == 1
+ )
+ store = SQLiteDatabase(path, identity)
+ original = path.read_bytes()
+ for operation in (store.read, store.write):
+ with pytest.raises(DatabaseRejected):
+ with operation():
+ pytest.fail("Unknown development layout admitted")
+ assert path.read_bytes() == original
+ with pytest.raises(DatabaseRejected):
+ store.initialize(require_existing=True)
+ assert path.read_bytes() == original
+
+
+def test_relative_cas_scope_kind_and_immutable_generation_are_independent(tmp_path):
+ from test_recurrence_records import values
+
+ from domus.domain.recurrence_planning import FixedCursor, cursor_binding
+ from domus.domain.recurrence_records import FixedProgress
+
+ store, rule, progress, _ = prepared(tmp_path)
+ with store.write() as unit:
+ for value, expected in [
+ (replace(rule, revision=3), 1),
+ (replace(rule, revision=2, created_at=101), 1),
+ (replace(rule, revision=2, definition_id="other"), 1),
+ (replace(rule, revision=2, pattern_revision=2), 1),
+ (rule, 0),
+ ]:
+ with pytest.raises(Conflict):
+ unit.recurrence.save_relative_schedule(value, expected)
+ for value, expected in [
+ (replace(progress, revision=3), 1),
+ (replace(progress, revision=2, binding="a" * 64), 1),
+ (replace(progress, schedule_id="missing"), 0),
+ ]:
+ with pytest.raises(Conflict):
+ unit.recurrence.save_relative_progress(value, expected)
+ for action in [
+ lambda: unit.recurrence.save_relative_schedule(rule, True),
+ lambda: unit.recurrence.save_relative_progress(progress, -1),
+ ]:
+ with pytest.raises(InvalidInput):
+ action()
+ fixed = replace(
+ values()[0], schedule_id=rule.schedule_id, definition_id=rule.definition_id, revision=2
+ )
+ with pytest.raises(Conflict):
+ unit.recurrence.save_schedule(fixed, 1)
+ assert unit.recurrence.relative_schedule("home", rule.schedule_id) == rule
+ assert unit.recurrence.progress("home", rule.schedule_id) is None
+ assert unit.recurrence.relative_progress("other", rule.schedule_id) is None
+ assert unit.recurrence.relative_schedule("other", rule.schedule_id) is None
+ # Retiring releases the one current timing owner while preserving its original row.
+ unit.recurrence.save_relative_schedule(
+ replace(rule, revision=2, state=ScheduleState.RETIRED), 1
+ )
+ fixed = replace(fixed, schedule_id="fixed", revision=1)
+ unit.recurrence.save_schedule(fixed, 0)
+ unit.recurrence.save_progress(
+ FixedProgress(
+ "fixed", "home", 1, FixedCursor(cursor_binding(fixed.pattern, fixed.policy))
+ ),
+ 0,
+ )
+ with pytest.raises(Conflict):
+ unit.recurrence.save_relative_schedule(replace(rule, revision=3), 2)
+ with pytest.raises(Conflict):
+ unit.recurrence.save_relative_schedule(
+ replace(rule, schedule_id="fixed", revision=2), 1
+ )
+ assert unit.recurrence.relative_schedule("home", "fixed") is None
+
+
+def test_rule_and_progress_generation_must_change_together_and_fail_atomically(tmp_path):
+ store, rule, progress, _ = prepared(tmp_path)
+ with pytest.raises(StorageFailure):
+ with store.write() as unit:
+ unit.recurrence.save_relative_schedule(
+ replace(rule, revision=2, pattern_revision=2, interval=RelativeInterval(31)), 1
+ )
+ with store.read() as unit:
+ assert unit.recurrence.relative_schedule("home", rule.schedule_id) == rule
+ changed = replace(rule, revision=2, pattern_revision=2, interval=RelativeInterval(31))
+ with store.write() as unit:
+ unit.recurrence.save_relative_schedule(changed, 1)
+ unit.recurrence.save_relative_progress(
+ replace(progress, revision=2, pattern_revision=2, binding=changed.binding), 1
+ )
+ with store.read() as unit:
+ assert unit.recurrence.relative_schedule("home", rule.schedule_id) == changed
+
+
+@pytest.mark.parametrize(
+ "change",
+ ["installation", "channel", "missing-rule", "generation", "missing-work", "wrong-creation"],
+)
+def test_relative_slot_write_requires_current_scope_generation_and_real_work(tmp_path, change):
+ store, rule, _, work = prepared(tmp_path)
+ value = RelativeSlot(store.installation, rule, rule.initial, work, 100)
+ if change in ("installation", "channel"):
+ installation = replace(
+ store.installation,
+ **(
+ {"channel": "stable"}
+ if change == "channel"
+ else {"instance_id": "00000000-0000-0000-0000-000000000001"}
+ ),
+ )
+ value = replace(value, installation=installation)
+ elif change == "missing-rule":
+ value = replace(value, schedule=replace(rule, schedule_id="missing"))
+ elif change == "generation":
+ value = replace(value, schedule=replace(rule, revision=2, pattern_revision=2))
+ elif change == "missing-work":
+ value = replace(value, occurrence_id="missing")
+ else:
+ value = replace(value, created_at=101)
+ with pytest.raises(Conflict):
+ with store.write() as unit:
+ unit.recurrence.add_relative_slot(value)
+ with store.read() as unit:
+ assert unit.recurrence.relative_slots("home", rule.schedule_id) == ()
+
+
+def test_scoped_relative_pages_and_duplicate_slot_originals_are_bounded(tmp_path):
+ from test_sqlite_relative_integrity import committed
+
+ store, rule, progress, slot, _ = committed(tmp_path)
+ with pytest.raises(Conflict):
+ with store.write() as unit:
+ unit.recurrence.add_relative_slot(slot)
+ with store.read() as unit:
+ assert unit.recurrence.relative_schedules("home", state=ScheduleState.ACTIVE) == (rule,)
+ assert unit.recurrence.relative_schedules("home", state=ScheduleState.PAUSED) == ()
+ assert unit.recurrence.relative_schedules("home", after=rule.schedule_id) == ()
+ assert unit.recurrence.relative_slots("home", rule.schedule_id, after=slot.key) == ()
+ assert unit.recurrence.relative_slot("other", slot.key) is None
+ assert unit.recurrence.relative_slots("other", rule.schedule_id) == ()
+ for call in [
+ lambda: unit.recurrence.relative_schedules("home", state="active"),
+ lambda: unit.recurrence.relative_slots("home", rule.schedule_id, limit=True),
+ lambda: unit.recurrence.relative_schedules("home", after="';DROP"),
+ ]:
+ with pytest.raises(InvalidInput):
+ call()
+ assert unit.recurrence.relative_progress("home", rule.schedule_id) == progress
+
+
+def test_missing_exact_slot_receipt_and_late_failure_leave_no_partial_relative_records(tmp_path):
+ store, rule, progress, work = prepared(tmp_path)
+ slot = RelativeSlot(store.installation, rule, rule.initial, work, 100)
+ with pytest.raises(StorageFailure):
+ with store.write() as unit:
+ unit.recurrence.add_relative_slot(slot)
+ with pytest.raises(RuntimeError, match="injected"):
+ with store.write() as unit:
+ unit.recurrence.add_relative_slot(slot)
+ advanced, receipt = proof(unit, slot, progress)
+ unit.recurrence.save_relative_progress(advanced, 1)
+ raise RuntimeError("injected")
+ with store.read() as unit:
+ assert unit.recurrence.relative_slot("home", slot.key) is None
+ assert unit.recurrence.relative_progress("home", rule.schedule_id) == progress
+ assert unit.receipts.get(slot.command) is None
+
+
+@pytest.mark.parametrize("operation", ["rule", "slot"])
+def test_independent_relative_writers_have_one_complete_winner(tmp_path, operation):
+ from concurrent.futures import ThreadPoolExecutor
+ from threading import Barrier
+
+ from domus.domain.errors import StorageBusy
+
+ store, rule, progress, work = prepared(tmp_path)
+ slot = RelativeSlot(store.installation, rule, rule.initial, work, 100)
+ gate = Barrier(2)
+
+ def write(_):
+ independent = SQLiteDatabase(store.path, store.installation, busy_timeout=5)
+ with independent.read() as unit:
+ seen = unit.recurrence.relative_schedule("home", rule.schedule_id)
+ gate.wait(timeout=5)
+ try:
+ with independent.write() as unit:
+ if operation == "rule":
+ unit.recurrence.save_relative_schedule(replace(seen, revision=2), 1)
+ else:
+ unit.recurrence.add_relative_slot(slot)
+ advanced, _ = proof(unit, slot, progress)
+ unit.recurrence.save_relative_progress(advanced, 1)
+ return "committed"
+ except Conflict:
+ return "conflict"
+ except StorageBusy:
+ return "busy"
+
+ with ThreadPoolExecutor(max_workers=2) as pool:
+ outcomes = list(pool.map(write, range(2)))
+ assert outcomes.count("committed") == 1 and len(set(outcomes) & {"conflict", "busy"}) == 1
+ with store.read() as unit:
+ if operation == "rule":
+ assert unit.recurrence.relative_schedule("home", rule.schedule_id).revision == 2
+ else:
+ assert unit.recurrence.relative_slots("home", rule.schedule_id) == (slot,)
+ assert unit.recurrence.relative_progress("home", rule.schedule_id).revision == 2
+ assert (
+ len([x for x in unit.outbox.pending() if x.event_type == slot.command.action]) == 1
+ )
diff --git a/tests/backend/test_sqlite_relative_recurrence_records.py b/tests/backend/test_sqlite_relative_recurrence_records.py
new file mode 100644
index 0000000..92c204f
--- /dev/null
+++ b/tests/backend/test_sqlite_relative_recurrence_records.py
@@ -0,0 +1,117 @@
+"""Closed relative records preserve original work without granting execution authority."""
+
+import json
+from dataclasses import replace
+
+import pytest
+from test_relative_recurrence_records import values
+
+from domus.domain.errors import StorageFailure
+from domus.domain.relative_recurrence import RelativeSlot
+from domus.domain.storage import JsonDocument
+from domus.infrastructure.sqlite_relative_recurrence_records import (
+ read_relative_progress,
+ read_relative_schedule,
+ read_relative_slot,
+ relative_progress_document,
+ relative_schedule_document,
+ relative_slot_document,
+)
+
+
+def records(timed, restricted):
+ schedule, progress, successor, _ = values(timed, restricted=restricted)
+ initial = RelativeSlot(successor.installation, schedule, schedule.initial, "initial", 101)
+ current = replace(
+ progress,
+ current_occurrence_id=successor.occurrence_id,
+ current_slot_key=successor.key,
+ source_after=31,
+ last_run_at=successor.created_at,
+ )
+ return (
+ (schedule, relative_schedule_document, read_relative_schedule),
+ (progress, relative_progress_document, read_relative_progress),
+ (current, relative_progress_document, read_relative_progress),
+ (initial, relative_slot_document, read_relative_slot),
+ (successor, relative_slot_document, read_relative_slot),
+ )
+
+
+@pytest.mark.parametrize("timed", [False, True])
+@pytest.mark.parametrize("restricted", [False, True])
+def test_relative_records_preserve_full_original_initial_and_successor_facts(timed, restricted):
+ for value, encode, decode in records(timed, restricted):
+ document = encode(value)
+ assert decode(document.encoded) == value
+ assert encode(decode(document.encoded)).encoded == document.encoded
+
+
+@pytest.mark.parametrize(
+ "mutation", ["whitespace", "pretty", "order", "version", "boolean-version", "extra"]
+)
+def test_relative_records_refuse_noncanonical_text_and_unknown_versions_or_fields(mutation):
+ for value, encode, decode in records(True, True):
+ document = encode(value)
+ data = document.decode()
+ if mutation == "whitespace":
+ text = " " + document.encoded
+ elif mutation == "pretty":
+ text = json.dumps(data, indent=2)
+ elif mutation == "order":
+ text = json.dumps(dict(reversed(list(data.items()))), separators=(",", ":"))
+ else:
+ if mutation == "version":
+ data["version"] = 2
+ elif mutation == "boolean-version":
+ data["version"] = True
+ else:
+ data["unrecognized"] = None
+ text = JsonDocument.from_value(data).encoded
+ with pytest.raises(StorageFailure):
+ decode(text)
+
+
+def test_relative_slot_codec_cannot_replace_actual_work_basis_with_approval_time():
+ *_, (slot, encode, decode) = records(True, True)
+ data = encode(slot).decode()
+ data["source"]["cycle"]["performed_at"] += 86_400_000
+ with pytest.raises(StorageFailure):
+ decode(JsonDocument.from_value(data).encoded)
+
+
+def test_actual_epoch_local_basis_before1970_round_trips_without_becoming_a_nominal_deadline():
+ from datetime import date
+
+ from test_tasks import cycle
+
+ from domus.domain.recurrence_planning import PlannedSlot
+ from domus.domain.recurrence_rules import NominalDate
+ from domus.domain.recurrence_time import RelativeInterval, TimePolicy
+ from domus.domain.relative_recurrence import RelativeSchedule, RelativeSource
+ from domus.infrastructure.recurrence_time import PinnedRecurrenceTime
+
+ times = PinnedRecurrenceTime()
+ policy = TimePolicy("America/New_York", times.data_identity)
+ local = times.local_at(0, policy)
+ assert local.local.year == 1969
+ schedule, _, example, _ = values(False)
+ schedule = RelativeSchedule(
+ schedule.schedule_id,
+ schedule.household_id,
+ schedule.definition_id,
+ PlannedSlot(NominalDate(date(1970, 1, 1)), None),
+ RelativeInterval(1),
+ policy,
+ 100,
+ )
+ original = cycle(performed_at=0, recorded_at=1)
+ slot = RelativeSlot(
+ example.installation,
+ schedule,
+ schedule.initial,
+ "next",
+ 100,
+ RelativeSource.from_cycle(original, local),
+ )
+ assert read_relative_slot(relative_slot_document(slot).encoded) == slot
diff --git a/tests/backend/test_sqlite_task_reviews.py b/tests/backend/test_sqlite_task_reviews.py
index da146bc..1baf853 100644
--- a/tests/backend/test_sqlite_task_reviews.py
+++ b/tests/backend/test_sqlite_task_reviews.py
@@ -51,6 +51,7 @@ def test_original_schema6_migration_preserves_rows_and_projects_existing_cause(t
"recurrence_slots",
"recurrence_frontiers",
"recurrence_progress_evidence",
+ "recurrence_relative_slots",
}
assert set(upgraded) == set(original) | added
assert all(upgraded[table] == values for table, values in original.items())
diff --git a/tests/containers/test_recurrence_image.py b/tests/containers/test_recurrence_image.py
index c5a3f86..8cbf8ad 100644
--- a/tests/containers/test_recurrence_image.py
+++ b/tests/containers/test_recurrence_image.py
@@ -34,14 +34,16 @@ def test_current_image_executes_recurrence_creation_planning_and_closed_record_c
from domus.composition import ingress, server
from domus.domain.errors import DatabaseRejected, InvalidInput, StorageBusy, StorageFailure
from domus.domain.recurrence_frontier import RecurrenceFrontier
-from domus.domain.relative_recurrence import RelativeSchedule
+from domus.domain.relative_recurrence import (
+ RelativeSchedule, RelativeProgress, RelativeSlot, RelativeOperation,
+)
from domus.domain.identity import HumanIdentity
from domus.domain.nominal_recurrence import CalendarNominalExpansion
from domus.domain.recurrence_planning import FixedCursor, cursor_binding
from domus.domain.recurrence_records import FixedProgress, FixedSchedule, ScheduledSlot
from domus.domain.recurrence_rule_text import parse_fixed_pattern
from domus.domain.recurrence_time import NominalTime, TimePolicy, TimeResolution, RelativeInterval
-from domus.domain.storage import Installation
+from domus.domain.storage import Installation, Receipt, AuditEntry, OutboxEvent, JsonDocument
from domus.domain.task_values import TaskContent
from domus.infrastructure.application_runtime import SecureIdentifiers, SystemClock, TimeZoneCatalog
from domus.infrastructure.recurrence_time import PinnedRecurrenceTime, TIMEZONE_DATA_IDENTITY
@@ -302,6 +304,43 @@ async def serve_once(runtime):
else:
raise AssertionError('Checkpoint accepted altered original generation facts')
assert store.path.read_bytes() == source_bytes
+with TemporaryDirectory(prefix='domus-current-relative-store-') as directory:
+ store = SQLiteDatabase(Path(directory)/'domus.sqlite3', installation)
+ store.initialize()
+ HouseholdCommands(store, human, UnusedDirectory(), TimeZoneCatalog(), clock, identifiers).setup(
+ 'setup', SetupHousehold('Fictional relative persistence', 'UTC'))
+ # Explicit synthetic repository proof exercises packaging/storage, not a relative consumer.
+ manual = TaskCommands(store, human, clock, identifiers).create('relative-base',
+ CreateTask(TaskContent('Relative storage boundary'))).result.decode()
+ with store.read() as unit:
+ work = unit.tasks.occurrence('home', manual['occurrence_id'])
+ rule = RelativeSchedule('relative-store', 'home', manual['definition_id'], first,
+ RelativeInterval(30), policy, work.definition_snapshot.created_at)
+ progress = RelativeProgress(rule.schedule_id, 'home', 1, rule.binding)
+ slot = RelativeSlot(installation, rule, first, work.occurrence_id, work.created_at)
+ opid = identifiers.new()
+ advanced = replace(progress, current_occurrence_id=work.occurrence_id,
+ current_slot_key=slot.key, revision=2, last_run_at=clock.now(),
+ evidence=RelativeOperation(slot.command, opid))
+ result = JsonDocument.from_value({'slot_key': slot.key, 'occurrence_id': work.occurrence_id,
+ 'schedule_id': rule.schedule_id, 'definition_id': rule.definition_id,
+ 'relative_slot_fingerprint': slot.evidence_fingerprint,
+ 'relative_progress': advanced.state_document().decode()})
+ timestamp = clock.now()
+ with store.write() as unit:
+ unit.recurrence.save_relative_schedule(rule, 0)
+ unit.recurrence.save_relative_progress(progress, 0)
+ unit.recurrence.add_relative_slot(slot)
+ unit.receipts.add(Receipt(slot.command, JsonDocument.from_value({}).fingerprint,
+ opid, result, timestamp))
+ unit.audit.append(AuditEntry(opid, slot.command, JsonDocument.from_value({}), timestamp))
+ unit.outbox.append(OutboxEvent(opid, 'home', slot.command.action, result, timestamp))
+ unit.recurrence.save_relative_progress(advanced, 1)
+ with store.read() as unit:
+ assert unit.recurrence.relative_progress('home', rule.schedule_id) == advanced
+ assert unit.recurrence.relative_slots('home', rule.schedule_id) == (slot,)
+ assert unit.recurrence.schedules('home') == ()
+ assert SQLiteSnapshots(store, Path(directory)/'good', installation, clock).create('good')
print('Current-source recurrence, both owned runtimes and pinned-time codecs passed.')
""",
)
diff --git a/tests/fixtures/sqlite/unreleased-06b3b9f-schema8.sql b/tests/fixtures/sqlite/unreleased-06b3b9f-schema8.sql
new file mode 100644
index 0000000..cf5706f
--- /dev/null
+++ b/tests/fixtures/sqlite/unreleased-06b3b9f-schema8.sql
@@ -0,0 +1,74 @@
+-- Actual original source database header values; no relabeling.
+PRAGMA application_id=1146047829;
+PRAGMA user_version=8;
+BEGIN TRANSACTION;
+CREATE TABLE audit(operation_id TEXT PRIMARY KEY, household_id TEXT NOT NULL REFERENCES households(household_id), actor_id TEXT NOT NULL, action TEXT NOT NULL, command_key TEXT NOT NULL, detail TEXT NOT NULL, created_at INTEGER NOT NULL);
+INSERT INTO "audit" VALUES('identity-2','home','ha_admin','household.setup','setup','{"administrator_member_id":"identity-1","mapping_mode":"all_ha_administrators","transition":"household.configured"}',100);
+INSERT INTO "audit" VALUES('identity-5','home','ha_admin','recurrence.fixed.create','genuine-source8','{"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":"Clean the filter"},"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":"identity-3","first_occurrence_id":null,"pattern_revision":1,"processing":"pending","revision":1,"schedule_id":"identity-4","state":"active"},"transition":"recurrence.fixed.created"}',100);
+INSERT INTO "audit" VALUES('identity-7','home','system:recurrence','recurrence.materialize','259a69c04141d744e9458abb6accd428b26969ec1bf245334de7e9ea7435b11a','{"request":{"definition_id":"identity-3","nominal":{"kind":"time","local":"2026-03-29T02:30:00"},"pattern":"92d046b4f7ae037572eb6a31467ba74a3041498a271dfa1cfb1ddc5c58b21727","resolved_at":1774746000000,"slot_key":"259a69c04141d744e9458abb6accd428b26969ec1bf245334de7e9ea7435b11a","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":"identity-3","nominal":{"kind":"time","local":"2026-03-29T02:30:00"},"occurrence_id":"identity-6","resolved_at":1774746000000,"schedule_id":"identity-4","slot_key":"259a69c04141d744e9458abb6accd428b26969ec1bf245334de7e9ea7435b11a"}}',100);
+INSERT INTO "audit" VALUES('identity-9','home','system:recurrence','recurrence.materialize','d2d8c76a6d2ab2deefbf2d432cb0a4c2e44b50802e11af25dac2c964f7108758','{"request":{"definition_id":"identity-3","nominal":{"kind":"time","local":"2026-03-30T02:30:00"},"pattern":"92d046b4f7ae037572eb6a31467ba74a3041498a271dfa1cfb1ddc5c58b21727","resolved_at":1774830600000,"slot_key":"d2d8c76a6d2ab2deefbf2d432cb0a4c2e44b50802e11af25dac2c964f7108758","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":"identity-3","nominal":{"kind":"time","local":"2026-03-30T02:30:00"},"occurrence_id":"identity-8","resolved_at":1774830600000,"schedule_id":"identity-4","slot_key":"d2d8c76a6d2ab2deefbf2d432cb0a4c2e44b50802e11af25dac2c964f7108758"}}',100);
+INSERT INTO "audit" VALUES('identity-10','home','system:recurrence','recurrence.scan','1b1bfe8e47991e5be0eaff717cc25aaacdf6ba660c3764c8037498048c4658b3','{"request":{"before":{"kind":"time","local":"2026-04-02T12:00:00"},"from_cursor":{"binding":"6bd1edfcf2c489f27216f5c1f782f53a052d26823e3127112d9c9e11f71bbcf6","closed":false,"nominal_count":2,"offset":1,"period_index":1},"from_revision":3,"pattern_revision":1,"schedule_id":"identity-4","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":"identity-4"}}',100);
+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,'Home','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,'047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6','edge');
+CREATE TABLE members(member_id TEXT PRIMARY KEY, household_id TEXT NOT NULL REFERENCES households(household_id), ha_user_id TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('administrator','adult','restricted','viewer')), status TEXT NOT NULL CHECK(status IN ('pending','active','suspended','archived')), revision INTEGER NOT NULL CHECK(revision>=1), exceptions TEXT NOT NULL, UNIQUE(household_id,ha_user_id));
+INSERT INTO "members" VALUES('identity-1','home','ha_admin','viewer','active',1,'{"rules":[]}');
+INSERT INTO "members" VALUES('child','home','ha_child','restricted','active',1,'{"rules":[]}');
+INSERT INTO "members" VALUES('other','home','ha_other','restricted','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,'identity-2','home','household.setup','{"household_id":"home","revision":1}',100,0);
+INSERT INTO "outbox" VALUES(2,'identity-5','home','recurrence.fixed.create','{"definition_id":"identity-3","first_occurrence_id":null,"kind":"recurrence.fixed.created","pattern_revision":1,"processing":"pending","revision":1,"schedule_id":"identity-4","state":"active","version":1}',100,0);
+INSERT INTO "outbox" VALUES(3,'identity-7','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":"identity-3","kind":"recurrence.materialize","nominal":{"kind":"time","local":"2026-03-29T02:30:00"},"occurrence_id":"identity-6","resolved_at":1774746000000,"schedule_id":"identity-4","slot_key":"259a69c04141d744e9458abb6accd428b26969ec1bf245334de7e9ea7435b11a","version":1}',100,0);
+INSERT INTO "outbox" VALUES(4,'identity-9','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":"identity-3","kind":"recurrence.materialize","nominal":{"kind":"time","local":"2026-03-30T02:30:00"},"occurrence_id":"identity-8","resolved_at":1774830600000,"schedule_id":"identity-4","slot_key":"d2d8c76a6d2ab2deefbf2d432cb0a4c2e44b50802e11af25dac2c964f7108758","version":1}',100,0);
+INSERT INTO "outbox" VALUES(5,'identity-10','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":"identity-4","version":1}',100,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','ha_admin','household.setup','setup','fd81d9a903eff49c605ff960bfae80f61f684b2f1f12ff15fb363cd7bdea3c51','identity-2','{"administrator_member_id":"identity-1","household_id":"home","mapping_mode":"all_ha_administrators","member_id":"identity-1","policy_revision":1,"revision":1,"state":"configured"}',100);
+INSERT INTO "receipts" VALUES('home','ha_admin','recurrence.fixed.create','genuine-source8','3dabb4a2d54b0864f80411a1ac6f95d4a090369bbe039e3b212aaec0b1366cc1','identity-5','{"definition_id":"identity-3","first_occurrence_id":null,"pattern_revision":1,"processing":"pending","revision":1,"schedule_id":"identity-4","state":"active"}',100);
+INSERT INTO "receipts" VALUES('home','system:recurrence','recurrence.materialize','259a69c04141d744e9458abb6accd428b26969ec1bf245334de7e9ea7435b11a','47f75faa1aba7c1f38c59e3d46abec0702bc0663a5b7cf149ede3f44e000da74','identity-7','{"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":"identity-3","nominal":{"kind":"time","local":"2026-03-29T02:30:00"},"occurrence_id":"identity-6","resolved_at":1774746000000,"schedule_id":"identity-4","slot_key":"259a69c04141d744e9458abb6accd428b26969ec1bf245334de7e9ea7435b11a"}',100);
+INSERT INTO "receipts" VALUES('home','system:recurrence','recurrence.materialize','d2d8c76a6d2ab2deefbf2d432cb0a4c2e44b50802e11af25dac2c964f7108758','83edd3aa06dbb46082d8051b290fa554c176bd1614ceda277b6c46f36f28b93e','identity-9','{"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":"identity-3","nominal":{"kind":"time","local":"2026-03-30T02:30:00"},"occurrence_id":"identity-8","resolved_at":1774830600000,"schedule_id":"identity-4","slot_key":"d2d8c76a6d2ab2deefbf2d432cb0a4c2e44b50802e11af25dac2c964f7108758"}',100);
+INSERT INTO "receipts" VALUES('home','system:recurrence','recurrence.scan','1b1bfe8e47991e5be0eaff717cc25aaacdf6ba660c3764c8037498048c4658b3','1b1bfe8e47991e5be0eaff717cc25aaacdf6ba660c3764c8037498048c4658b3','identity-10','{"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":"identity-4"}',100);
+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), document TEXT NOT NULL, UNIQUE(household_id,schedule_id,pattern_revision), FOREIGN KEY(household_id,schedule_id,pattern_revision) REFERENCES recurrence_rules(household_id,schedule_id,pattern_revision) DEFERRABLE INITIALLY DEFERRED);
+INSERT INTO "recurrence_progress" VALUES('identity-4','home',1,4,'{"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":"1b1bfe8e47991e5be0eaff717cc25aaacdf6ba660c3764c8037498048c4658b3"},"operation_id":"identity-10","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":100,"pattern_revision":1,"range_complete":true,"revision":4,"schedule_id":"identity-4","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','identity-4',4,'identity-10','7a6b3392f133afd3bcb0b79d05ea78bd4a5738654a81153a236f6645669b44bf');
+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')), document TEXT NOT NULL, UNIQUE(household_id,schedule_id), UNIQUE(household_id,schedule_id,definition_id), UNIQUE(household_id,schedule_id,pattern_revision), FOREIGN KEY(household_id,definition_id) REFERENCES task_definitions(household_id,definition_id), FOREIGN KEY(household_id,schedule_id,pattern_revision) REFERENCES recurrence_progress(household_id,schedule_id,pattern_revision) DEFERRABLE INITIALLY DEFERRED);
+INSERT INTO "recurrence_rules" VALUES('identity-4','home','identity-3',1,1,'active','{"created_at":100,"definition_id":"identity-3","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":"identity-4","state":"active","version":1}');
+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'), 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) REFERENCES recurrence_rules(household_id,schedule_id,definition_id), 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','259a69c04141d744e9458abb6accd428b26969ec1bf245334de7e9ea7435b11a','identity-4','identity-3',1,'identity-6','time','2026-03-29T02:30:00',1774746000000,100,'system:recurrence','recurrence.materialize','{"created_at":100,"installation":{"channel":"edge","instance_id":"047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6"},"occurrence_id":"identity-6","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":100,"definition_id":"identity-3","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":"identity-4","state":"active","version":1},"version":1}');
+INSERT INTO "recurrence_slots" VALUES('home','d2d8c76a6d2ab2deefbf2d432cb0a4c2e44b50802e11af25dac2c964f7108758','identity-4','identity-3',1,'identity-8','time','2026-03-30T02:30:00',1774830600000,100,'system:recurrence','recurrence.materialize','{"created_at":100,"installation":{"channel":"edge","instance_id":"047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6"},"occurrence_id":"identity-8","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":100,"definition_id":"identity-3","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":"identity-4","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('identity-3','home',1,'active','{"content":{"description":"","priority":"normal","tags":[],"title":"Clean the filter"},"created_at":100,"definition_id":"identity-3","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('identity-6','home','identity-3',1,'available',NULL,NULL,'{"assignee_ids":[],"created_at":100,"current_cycle_id":null,"current_cycle_revision":null,"definition_snapshot":{"content":{"description":"","priority":"normal","tags":[],"title":"Clean the filter"},"created_at":100,"definition_id":"identity-3","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":"identity-6","prerequisite_ids":[],"required_subtask_ids":[],"revision":1,"state":"available","state_reason":"","version":2}');
+INSERT INTO "task_occurrences" VALUES('identity-8','home','identity-3',1,'available',NULL,NULL,'{"assignee_ids":[],"created_at":100,"current_cycle_id":null,"current_cycle_revision":null,"definition_snapshot":{"content":{"description":"","priority":"normal","tags":[],"title":"Clean the filter"},"created_at":100,"definition_id":"identity-3","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":"identity-8","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_progress_revision ON recurrence_progress(household_id,schedule_id,revision);
+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);
+DELETE FROM "sqlite_sequence";
+INSERT INTO "sqlite_sequence" VALUES('outbox',5);
+COMMIT;
From ed7cd14190d750846833cf1bb822988b628560da Mon Sep 17 00:00:00 2001
From: Efra Espada
Date: Mon, 2 Nov 2026 13:35:00 +0100
Subject: [PATCH 16/16] feat: Create explicit completion-relative task
intentions atomically
---
.../relative_recurrence_commands.py | 150 ++++++
.../domus/application/task_authorization.py | 1 +
docs/architecture-recurrence.md | 24 +-
docs/evidence/recurrence-creation.json | 7 +-
.../recurrence-relative-creation.json | 161 +++++++
docs/evidence/recurrence-storage.json | 7 +-
specs/recurrence-storage-continuation.md | 7 +-
.../test_relative_recurrence_creation.py | 455 ++++++++++++++++++
tests/containers/test_recurrence_image.py | 24 +
9 files changed, 832 insertions(+), 4 deletions(-)
create mode 100644 backend/src/domus/application/relative_recurrence_commands.py
create mode 100644 docs/evidence/recurrence-relative-creation.json
create mode 100644 tests/backend/test_relative_recurrence_creation.py
diff --git a/backend/src/domus/application/relative_recurrence_commands.py b/backend/src/domus/application/relative_recurrence_commands.py
new file mode 100644
index 0000000..002207f
--- /dev/null
+++ b/backend/src/domus/application/relative_recurrence_commands.py
@@ -0,0 +1,150 @@
+"""Current-authorized relative creation preserves an explicit unconsumed first obligation."""
+
+import json
+from collections.abc import Callable
+from dataclasses import asdict, dataclass
+
+from domus.application.commands import AtomicCommands, Change
+from domus.application.identity_ports import CurrentHuman
+from domus.application.ports import Clock, Identifiers, Transactions, UnitOfWork
+from domus.application.recurrence_ports import RecurrenceTimes
+from domus.application.task_authorization import TaskAccess, TaskCommandAuthorization
+from domus.application.task_creation import TaskCreation
+from domus.application.task_material_ports import TaskInventory
+from domus.application.task_requests import CreateTask
+from domus.domain.errors import IntegrationUnavailable, InvalidInput
+from domus.domain.recurrence_planning import PlannedSlot
+from domus.domain.recurrence_rules import NominalDate, NominalSlot
+from domus.domain.recurrence_time import NominalTime, RelativeInterval, ResolvedTime, TimePolicy
+from domus.domain.relative_recurrence import (
+ RelativeProgress,
+ RelativeSchedule,
+ SuccessorVisibility,
+)
+from domus.domain.storage import CommandKey, JsonDocument, Receipt, validate_identifier
+from domus.domain.task_values import DefinitionState, TaskOverrides
+
+
+@dataclass(frozen=True, slots=True)
+class CreateRelativeTask:
+ task: CreateTask
+ initial: NominalSlot
+ interval: RelativeInterval
+ policy: TimePolicy
+ visibility: SuccessorVisibility = SuccessorVisibility.AT_COMPLETION
+
+ def __post_init__(self) -> None:
+ if (
+ not isinstance(self.task, CreateTask)
+ or self.task.state is not DefinitionState.ACTIVE
+ or not isinstance(self.initial, NominalDate | NominalTime)
+ or not isinstance(self.interval, RelativeInterval)
+ or not isinstance(self.policy, TimePolicy)
+ or not isinstance(self.visibility, SuccessorVisibility)
+ ):
+ raise InvalidInput("invalid_recurrence_create")
+
+ def document(self) -> JsonDocument:
+ return JsonDocument(
+ json.dumps(
+ {
+ "version": 1,
+ "task": self.task.document().decode(),
+ "initial": {
+ "kind": "date" if isinstance(self.initial, NominalDate) else "time",
+ "local": self.initial.local.isoformat(),
+ },
+ "interval_days": self.interval.days,
+ "policy": asdict(self.policy),
+ "visibility": self.visibility.value,
+ }
+ )
+ )
+
+
+@dataclass
+class RelativeRecurrenceCommands:
+ transactions: Transactions
+ actor: CurrentHuman
+ clock: Clock
+ identifiers: Identifiers
+ times: RecurrenceTimes
+ household_id: str = "home"
+ inventory_factory: Callable[[UnitOfWork], TaskInventory] | None = None
+
+ def __post_init__(self) -> None:
+ validate_identifier(self.household_id)
+
+ def create(self, key: str, request: CreateRelativeTask) -> Receipt:
+ payload = request.document()
+ access = TaskAccess(self.actor.current())
+ command = CommandKey(
+ self.household_id, access.human.ha_user_id, "recurrence.relative.create", key
+ )
+ authority = TaskCommandAuthorization(
+ access,
+ self.household_id,
+ command.action,
+ needs_configuration=request.task.overrides != TaskOverrides(),
+ needs_material_link=request.task.materials is not None,
+ )
+ commands = AtomicCommands(self.transactions, authority, self.clock, self.identifiers)
+ previous = commands.replay(command, payload)
+ if previous is not None:
+ return previous
+ # Actual timezone work precedes the writer; execute checks current local grants again.
+ self.times.require_policy(request.policy)
+ resolved = None
+ if isinstance(request.initial, NominalTime):
+ resolved = self.times.resolve(request.initial, request.policy)
+ if (
+ not isinstance(resolved, ResolvedTime)
+ or resolved.nominal != request.initial
+ or resolved.policy != request.policy
+ ):
+ raise IntegrationUnavailable("recurrence_time_source_invalid")
+ initial = PlannedSlot(request.initial, resolved)
+
+ def apply(unit: UnitOfWork) -> Change:
+ item = TaskCreation(self.clock, self.identifiers, self.inventory_factory).definition(
+ unit, access, self.household_id, request.task
+ )
+ schedule = RelativeSchedule(
+ self.identifiers.new(),
+ self.household_id,
+ item.definition_id,
+ initial,
+ request.interval,
+ request.policy,
+ item.created_at,
+ request.visibility,
+ )
+ progress = RelativeProgress(
+ schedule.schedule_id, self.household_id, 1, schedule.binding
+ )
+ unit.recurrence.save_relative_schedule(schedule, 0)
+ unit.recurrence.save_relative_progress(progress, 0)
+ result = JsonDocument.from_value(
+ {
+ "schedule_id": schedule.schedule_id,
+ "definition_id": item.definition_id,
+ "revision": 1,
+ "pattern_revision": 1,
+ "state": schedule.state.value,
+ "processing": "pending",
+ "first_occurrence_id": None,
+ }
+ )
+ detail = JsonDocument.from_value(
+ {
+ "transition": "recurrence.relative.created",
+ "request": payload.decode(),
+ "result": result.decode(),
+ }
+ )
+ event = JsonDocument.from_value(
+ {"version": 1, "kind": "recurrence.relative.created", **result.decode()}
+ )
+ return Change(result, detail, event)
+
+ return commands.execute(command, payload, apply)
diff --git a/backend/src/domus/application/task_authorization.py b/backend/src/domus/application/task_authorization.py
index e7b8ddd..840f4db 100644
--- a/backend/src/domus/application/task_authorization.py
+++ b/backend/src/domus/application/task_authorization.py
@@ -194,6 +194,7 @@ def approver(
_GLOBAL_PERMISSIONS = {
"task.create": Permission.TASKS_CREATE,
"recurrence.fixed.create": Permission.TASKS_CREATE,
+ "recurrence.relative.create": Permission.TASKS_CREATE,
"task.policy.update": Permission.TASKS_CONFIGURE,
}
_DEFINITION_ACTIONS = frozenset({"task.definition.update", "task.occurrence.create"})
diff --git a/docs/architecture-recurrence.md b/docs/architecture-recurrence.md
index 3cd6112..0ca6770 100644
--- a/docs/architecture-recurrence.md
+++ b/docs/architecture-recurrence.md
@@ -372,7 +372,29 @@ The schema8 layout is unreleased. Supported released1..7 fixtures remain unchang
than relabeled or silently upgraded. No owner HA installation changes. See
[relative storage evidence](evidence/recurrence-relative-storage.json) for exact measured scope.
-Authorized relative creation, actual initial/successor materialization, source inbox/closure and
+Actual relative initial/successor materialization, reviewed attachment, source inbox/closure and
eligibility, restore-source reconciliation, successor invalidation, mixed-rule execution and runtime
wiring remain required. Synthetic repository receipts and image codec/storage controls do not
establish those consumer journeys or the remaining calendar/UI/HA/all-domain release acceptance.
+
+## Authorized relative creation
+
+RelativeRecurrenceCommands now accepts a typed explicit initial local date or time, interval,
+time policy and successor visibility. Its immutable request fingerprints those facts with the
+validated task content, overrides and material selections. Existing creation/configuration/material
+grants apply. Current HA proof and local authority precede original lookup; local grants are checked
+again inside the writer after external timezone preparation. A caller cannot supply a resolved
+initial deadline in place of the pinned semantic time port.
+
+The atomic operation saves one task definition, one active relative rule and pristine generation-bound
+progress together with the original receipt, audit and outbox. It leaves the first obligation
+unconsumed for actual materialization; ordinary title-only manual creation keeps its existing behavior.
+The initial gap moves to the first valid local instant and folds retain one configured appearance.
+Date-only obligations retain their local date without becoming midnight instants.
+
+Tests include independent simultaneous SQLite writers, failure after every write, restart replay,
+provider/grant races, material ownership and real offline restoration of the whole creation. A
+historically processed creation returns original evidence without recreating its restored-away
+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.
diff --git a/docs/evidence/recurrence-creation.json b/docs/evidence/recurrence-creation.json
index 8d758ed..42de956 100644
--- a/docs/evidence/recurrence-creation.json
+++ b/docs/evidence/recurrence-creation.json
@@ -142,5 +142,10 @@
"images": "Image control now exercises actual SQLite fixed creation/replay and COUNT rejection. Local execution of that script is not actual CPU-image evidence; fresh hosted both-architecture jobs remain required.",
"ownerHa": "Installed Edge.5 and retained Edge.3 unchanged. No deployment, release or registry publication.",
"copilot": "No Copilot resource/configuration/bound/credential changed. Known progress evidence capacity and partial conversation reports remain upstream851/843.",
- "retainedEvidenceDirectory": "/private/tmp/domus-race-71c54ab"
+ "retainedEvidenceDirectory": "/private/tmp/domus-race-71c54ab",
+ "relativeCreationContinuation": {
+ "evidence": "recurrence-relative-creation.json",
+ "backendSourceInventorySHA256": "bc605845ba2175a01eff18642fbf1bd277b990ff77c86e4f7df1a62f29de0b1a",
+ "scope": "Historical measurements above remain unchanged. This later creation increment preserves explicit pending actual materialization/source/calendar/product scope."
+ }
}
diff --git a/docs/evidence/recurrence-relative-creation.json b/docs/evidence/recurrence-relative-creation.json
new file mode 100644
index 0000000..2a53d3e
--- /dev/null
+++ b/docs/evidence/recurrence-relative-creation.json
@@ -0,0 +1,161 @@
+{
+ "status": "partial; current-authorized relative creation implemented, full retained issue20 execution remains in progress",
+ "recordedAtUTC": "2026-11-02T12:34:25.758374+00:00",
+ "issue": "https://github.com/vypdev/domus/issues/20",
+ "pullRequest": "https://github.com/vypdev/domus/pull/22",
+ "sourceBase": "19ff35cb2cb2ceb4a0397901341f1089001cc619",
+ "backendSourceInventorySHA256": "bc605845ba2175a01eff18642fbf1bd277b990ff77c86e4f7df1a62f29de0b1a",
+ "sourceManifestMethod": "SHA256 of compact sorted JSON mapping every authored backend Python module path to its byte SHA256.",
+ "implemented": [
+ "Typed active-task creation with an explicit initial nominal local date/time, relative interval, snapshotted time policy and per-task successor visibility. The request fingerprints all timing and existing task content/override/material facts.",
+ "Current HA proof and existing task-create/configuration/inventory-link grants precede original lookup. External time preparation stays outside writers; local grants are checked again inside the atomic write.",
+ "Pinned initial date/instant resolution retains date-only facts, first-valid gap and one configured fold appearance with actual millisecond precision. Contradictory semantic time-port responses are refused.",
+ "One atomic task definition, relative rule and pristine generation-bound continuation commits with original receipt/audit/outbox. No accidental manual work, slot, consumed source or initial materialization is created.",
+ "Restart and historically absent-effect replay return the same original identity without timezone work, new identifiers, work/rule recreation or outbox delivery. Changed requests retain idempotency conflict.",
+ "Real independent SQLite writers keep one complete original. Failure injected after policy, definition, rule, progress, receipt, audit and outbox preserves atomic rollback."
+ ],
+ "testsFirst": {
+ "original": "Collection failed on missing domus.application.relative_recurrence_commands before implementation; the exact original output is retained. This is a missing implementation, not passing runtime behavior.",
+ "firstImplementation": "39 creation/permission/timing/rollback executions passed before adding the actual independent-writer and whole-creation restore controls.",
+ "focusedFinal": {
+ "executions": 44,
+ "durationSeconds": 0.87,
+ "result": "passed"
+ },
+ "fixtureCorrection": "The first additional concurrency/restore run failed test collection on an unparenthesized multiple-exception as-clause. Its syntax was corrected before final acceptance; it is not claimed as a production defect or meaningful behavior regression.",
+ "scope": "44 new backend executions relative to3987 baseline; parameterizations and a creation-only image control are not independent completed recurrence/product journeys."
+ },
+ "completeBackend": {
+ "executions": 4031,
+ "durationSeconds": 104.86,
+ "authoredModules": 137,
+ "coverageTotals": {
+ "covered_lines": 9849,
+ "num_statements": 9849,
+ "percent_covered": 100.0,
+ "percent_covered_display": "100.00",
+ "missing_lines": 0,
+ "excluded_lines": 292,
+ "percent_statements_covered": 100.0,
+ "percent_statements_covered_display": "100.00",
+ "num_branches": 2804,
+ "num_partial_branches": 0,
+ "covered_branches": 2804,
+ "missing_branches": 0,
+ "percent_branches_covered": 100.0,
+ "percent_branches_covered_display": "100.00"
+ },
+ "independentGlobalAndPerModuleAudit": "passed against the final report and current137-module inventory"
+ },
+ "checks": {
+ "strictTypes": "passed;137 source files",
+ "ruff": "check and final format passed;347 files",
+ "architecture": {
+ "contractsKept": 3,
+ "contractsBroken": 0,
+ "filesAnalyzed": 181,
+ "dependencies": 1191
+ },
+ "originalPinnedSdd": {
+ "upstream": "3e7ceea08e6131360e026937d1c0cc1b120472e0",
+ "base": "0806a38f8a2de1f102a017bff25ad6856fb17bcd",
+ "selected": "specs/recurrence-storage-continuation.md",
+ "originalPolicyModulesVerified": 7,
+ "format": "PASS",
+ "consistency": "PASS",
+ "composition": "explicit contributor integration; original publication retained in Git",
+ "unchangedCatalog": "PASS",
+ "immutableOwnerDocumentsUsed": 18,
+ "retainedCompanionCases": 52,
+ "newCases": 4,
+ "rawCharacters": 42903,
+ "renderedCharacters": 42903,
+ "composedCharacters": 42903,
+ "applicableDraftMaximumCharacters": 70000,
+ "sha256": "c79c018e359e26e6f6ab0d774c55c7bd39d39452dc06dee81f0d138eb469b43b",
+ "scope": "Review input only; no gate admission, branch publication, source implementation or runtime acceptance.",
+ "retainedOwningFilesUnchanged": true,
+ "workingDocumentValidated": true
+ },
+ "freshPackageAndArchitecture": {
+ "result": "passed",
+ "executions": 52,
+ "durationSeconds": 6.96
+ },
+ "exactWheel": {
+ "modules": 137,
+ "backendSourceInventorySHA256": "bc605845ba2175a01eff18642fbf1bd277b990ff77c86e4f7df1a62f29de0b1a",
+ "wheelSHA256": "c1220e0358f6c61135f4253063ae985ac512a73051fdb5f611b0b2e1b5a9fed6",
+ "scope": "Every current backend module byte matches the fresh wheel; no CPU or owner HA claim."
+ },
+ "exactCurrentImageScriptHost": "passed; includes actual relative creation with first-valid initial time, original replay, pristine progress and checkpoint. Earlier fixed/runtime/time/synthetic-relative storage controls remain. Actual relative consumer is not implemented.",
+ "freshHosted": "Required after normal publication for current source; host control does not prove real CPU images or native review."
+ },
+ "actualRestore": {
+ "input": "Real schema8 household checkpoint taken before relative creation, then a later real authorized creation with original receipt/audit/outbox.",
+ "operation": "SQLiteRestorations.prepare, SQLiteActivation.activate and open_storage select the historical state and retain later processed original proof.",
+ "observations": "Original current-authorized retry is historical with the same operation/result. Definition/rule/work and task policy remain absent, no new IDs are allocated and only original checkpoint delivery remains. A changed retry conflicts. A subsequent full checkpoint passes.",
+ "limits": "Not the still-required relative lifecycle-source restoration/closure/materialization acceptance and not an owner HA restoration."
+ },
+ "priorPublishedReview": {
+ "head": "19ff35cb2cb2ceb4a0397901341f1089001cc619",
+ "productRun": 41442811569,
+ "aggregateJob": 125271140255,
+ "productResult": "all applicable productCI suites passed, including both actual CPU images",
+ "nativeRun": 41442811542,
+ "nativeJob": 125268584741,
+ "reviewCheck": 125270397194,
+ "partitions": "14/14",
+ "fragments": 128,
+ "files": 102,
+ "validPriorFindingsFixed": 6,
+ "newFinding": "One reproduced false double-encoding assertion; initial native open=1 is not relabeled as native-confirmed fixed.",
+ "falsePositiveReport": "https://github.com/vypdev/copilot/issues/855",
+ "evidenceReply": "https://github.com/vypdev/domus/pull/22#discussion_r4442303467",
+ "manualDisposition": "Thread resolved after exact unchanged-source4 positive and4 harmful-suggestion negative controls plus6 existing codec/storage/restore tests. Correct production code was unchanged.",
+ "context": "Partial/neutral; human8 fetched/6 retained/2 omitted/0 truncated/limitReached. Separate issue6/6,PR12/12,threads6/6,diff102/102 complete. No omitted-item identity, new owner answers or whole-prompt absence inferred.",
+ "progressReference": "e818e4f7-749a-4646-9148-c8f52e646bd1;102 required paths,4 source parts,recovery usage null; no concrete capacity/continuation facts."
+ },
+ "compatibility": {
+ "schema": 8,
+ "layoutChanged": false,
+ "supportedReleasedSources": [
+ 1,
+ 2,
+ 3,
+ 4,
+ 5,
+ 6,
+ 7
+ ],
+ "copilotChanged": false,
+ "dependenciesChanged": false,
+ "timeoutsChanged": false,
+ "providerIoInsideWriterAdded": false,
+ "ownerHa": "No HA request, update, migration, release or deployment. Edge.5/schema7 and retained rollback remain unchanged."
+ },
+ "retainedScope": {
+ "companionCases": 52,
+ "originalOwnerCases": 37,
+ "additionalStorageCases": 4,
+ "newCountedProductJourneys": 0,
+ "owningSddsUnchanged": true
+ },
+ "copilotReports": [
+ "https://github.com/vypdev/copilot/issues/855",
+ "https://github.com/vypdev/copilot/issues/851#issuecomment-6565229213",
+ "https://github.com/vypdev/copilot/issues/843#issuecomment-6565011301"
+ ],
+ "rawEvidence": {
+ "privateCampaign": "/private/tmp/domus-relative-create-19ff35c",
+ "consumer": "CurrentPR22 relative creation review: original missing-module failure, final44/4031 tests and100% coverage, exact137-module wheel, original SDK/package/host controls. Compact terminal producer trees after publication while preserving original06 input, current rollback and next source/closure plan."
+ },
+ "pending": [
+ "Fresh current-source product CI, actual amd64/aarch64 images and native review after publication.",
+ "Actual relative initial/successor materialization, reviewed attachment and compatible adoption.",
+ "Bounded current/historical source observations, durable inbox, cycle closure dominance, eligibility, invalidation and restore-source reconciliation.",
+ "One shared mixed-rule definition/slot budget and frontier, with both owned runtime compositions.",
+ "Protected calendar/operation APIs, reviewed schedules/exceptions/classification and configurable household timing defaults.",
+ "Complete retained52+37 cases and full UI/HA/Stable-Edge/all-domain release acceptance. KeepPR22 open until complete issue20 scope."
+ ]
+}
diff --git a/docs/evidence/recurrence-storage.json b/docs/evidence/recurrence-storage.json
index bde89b9..723951c 100644
--- a/docs/evidence/recurrence-storage.json
+++ b/docs/evidence/recurrence-storage.json
@@ -85,7 +85,7 @@
"policyModulesByteVerified": 7,
"immutableBaseOwners": 18,
"retainedOwnersUnchanged": true,
- "currentCompanionSHA256": "a317ee4f8b3cc4517098f244966bd04a127f91bc7a70ca6c67dc5723cef57c04",
+ "currentCompanionSHA256": "c79c018e359e26e6f6ab0d774c55c7bd39d39452dc06dee81f0d138eb469b43b",
"scope": "Explicit contributor integration; original first publication remains in Git. Format, reciprocal inventory/path consistency and current catalog pass."
},
"packageAndArchitectureTests": {
@@ -290,5 +290,10 @@
"evidence": "recurrence-relative-storage.json",
"backendSourceInventorySHA256": "43e40358642c0f0f48282eb4a38d9b65f666ef95e29cd85794b750f231784921",
"scope": "Historical initial storage measurements above remain unchanged. See this later increment for typed shared identity and relative persistence; full consumer and product acceptance remain pending."
+ },
+ "relativeCreationContinuation": {
+ "evidence": "recurrence-relative-creation.json",
+ "backendSourceInventorySHA256": "bc605845ba2175a01eff18642fbf1bd277b990ff77c86e4f7df1a62f29de0b1a",
+ "scope": "Historical measurements above remain unchanged. This later creation increment preserves explicit pending actual materialization/source/calendar/product scope."
}
}
diff --git a/specs/recurrence-storage-continuation.md b/specs/recurrence-storage-continuation.md
index c94533b..7075891 100644
--- a/specs/recurrence-storage-continuation.md
+++ b/specs/recurrence-storage-continuation.md
@@ -142,6 +142,7 @@ Use real independent SQLite connections and exact before/after version/row check
| backend/src/domus/application/fixed_materialization.py | create |
| backend/src/domus/application/fair_recurrence.py | create |
| backend/src/domus/application/relative_recurrence.py | create |
+| backend/src/domus/application/relative_recurrence_commands.py | create |
| backend/src/domus/application/commands.py | update |
| backend/src/domus/application/task_creation.py | create |
| backend/src/domus/application/task_commands.py | update |
@@ -172,6 +173,7 @@ Use real independent SQLite connections and exact before/after version/row check
| tests/backend/test_sqlite_recurrence.py | create |
| tests/backend/test_recurrence_records.py | create |
| tests/backend/test_recurrence_creation.py | create |
+| tests/backend/test_relative_recurrence_creation.py | create |
| tests/backend/test_fixed_materialization.py | create |
| tests/backend/test_fixed_materialization_shutdown.py | create |
| tests/backend/test_fair_recurrence.py | create |
@@ -224,6 +226,7 @@ Use real independent SQLite connections and exact before/after version/row check
| docs/evidence/recurrence-until-integrity.json | create |
| docs/evidence/recurrence-slot-original.json | create |
| docs/evidence/recurrence-relative-storage.json | create |
+| docs/evidence/recurrence-relative-creation.json | create |
Existing retained path declarations describe their original publication baseline; the table above identifies this continuation's actual new and existing storage paths at the immutable base. Declare any additional real implementation/test path before committing it. Update exact evidence and current acceptance limits without relabeling historical results.
@@ -283,11 +286,13 @@ The worker carries cancellation into every run and joins before runtime identity
## 19. Definition of Done
+Relative scheduled creation requires an explicit typed initial local date/time, interval, policy and successor visibility. Resolve the initial time through the pinned semantic port outside the writer; reject contradictory facts. Reuse current task creation, override/material permissions and the original atomic command boundary. Save one definition, one relative rule and pristine generation-bound progress with receipt/audit/outbox; do not create ordinary manual work or consume the first obligation. Current authorization precedes original replay and is checked again in the writer. Restart replay and historical absent-effect replay preserve original identity without provider work, new IDs or effects. Independent writers and failure after every write preserve a single complete original. This verifies creation only; actual relative materialization, reviewed attachment, source/closure/invalidation, mixed budgets and complete retained acceptance remain required.
+
Relative persistence uses the same globally unique rule/progress identity with an explicit closed timing discriminator. Deferred generation relationships include that kind, and CAS predicates cannot overwrite the other kind. One nonretired rule owns each definition's current timing; retired history remains. This also preserves the runner's definition-based selection budget. Independent legacy pagination fixtures must represent different definitions rather than multiple current owners of one definition. This does not apply a timing-mode replacement or provide a new public configuration command.
Every relative continuation, including its unconsumed bootstrap, binds the full immutable generation: initial due fact, interval, visibility, policy, scope and pattern revision. A nonbootstrap advance also references an exact original command/operation whose canonical result matches its business state. CAS revision and observed last-run time are not business cursor identity. A revision-bound proof header, canonical record and current or retained original must agree. A present slot additionally verifies its complete original generation/local/source fingerprint and actual task/source-cycle relationship. Current approval or closure cannot rewrite the normalized original completion facts; storage validation grants no source eligibility or execution authority.
-Actual restore controls distinguish a generated task removed by restoration from an adopted manual task that still exists while its later recurrence mapping is absent. Neither permits automatic recreation of the processed mapping, new task identities, original receipts or event delivery. Historical pointers retain explicit original proof; existing manual work survives. Source-cycle closure dominance, durable consumer inbox/closure, source eligibility, authorized creation, actual relative materialization, invalidation, mixed-rule budgets and runtime wiring still require their own implementation and acceptance. Synthetic repository receipts and packaged codec controls are not evidence of those missing consumer journeys.
+Actual restore controls distinguish a generated task removed by restoration from an adopted manual task that still exists while its later recurrence mapping is absent. Neither permits automatic recreation of the processed mapping, new task identities, original receipts or event delivery. Historical pointers retain explicit original proof; existing manual work survives. Source-cycle closure dominance, durable consumer inbox/closure, source eligibility, actual relative materialization, invalidation, mixed-rule budgets and runtime wiring still require their own implementation and acceptance. Synthetic repository receipts and packaged codec controls are not evidence of those missing consumer journeys.
This adds relative slots and typed ownership to the unreleased schema8 layout. Keep released1..7 inputs unchanged. Preserve the genuine06 source/data pair and complete captured header pragmas; refuse its prior five-table development layout without relabeling or claiming a released8 migration. The current owner still runs Edge.5/schema7. These are retained storage/execution refinements, not additional counted product cases.
diff --git a/tests/backend/test_relative_recurrence_creation.py b/tests/backend/test_relative_recurrence_creation.py
new file mode 100644
index 0000000..a3e3b01
--- /dev/null
+++ b/tests/backend/test_relative_recurrence_creation.py
@@ -0,0 +1,455 @@
+"""Actual relative creation keeps current authority, explicit timing and one atomic intention."""
+
+from dataclasses import replace
+from datetime import date, datetime, timedelta
+
+import pytest
+from test_household_commands import Clock
+from test_task_commands import counts, fixture
+
+from domus.application.relative_recurrence_commands import (
+ CreateRelativeTask,
+ RelativeRecurrenceCommands,
+)
+from domus.application.task_requests import CreateTask
+from domus.domain.errors import (
+ Conflict,
+ Forbidden,
+ IdentityUnavailable,
+ IntegrationUnavailable,
+ InvalidInput,
+ StorageFailure,
+)
+from domus.domain.identity import HumanIdentity, MemberStatus, Permission, PermissionRule, Role
+from domus.domain.recurrence_rules import NominalDate
+from domus.domain.recurrence_time import FoldPolicy, NominalTime, RelativeInterval, TimePolicy
+from domus.domain.relative_recurrence import SuccessorVisibility
+from domus.domain.task_values import DefinitionState, TaskContent, TaskOverrides
+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 setup(tmp_path):
+ tasks, store, actor, identifiers = fixture(tmp_path)
+ commands = RelativeRecurrenceCommands(
+ store, actor, Clock(), identifiers, PinnedRecurrenceTime()
+ )
+ return commands, tasks, store, actor, identifiers
+
+
+def request(*, timed=False, visibility=SuccessorVisibility.AT_COMPLETION):
+ initial = (
+ NominalTime(datetime(2026, 3, 29, 2, 30, 0, 123000))
+ if timed
+ else NominalDate(date(2026, 3, 29))
+ )
+ return CreateRelativeTask(
+ CreateTask(TaskContent("Clean the filter")),
+ initial,
+ RelativeInterval(30),
+ TimePolicy("Europe/Madrid", PinnedRecurrenceTime().data_identity),
+ visibility,
+ )
+
+
+@pytest.mark.parametrize("timed", [False, True])
+@pytest.mark.parametrize("visibility", list(SuccessorVisibility))
+def test_relative_creation_commits_one_unconsumed_intention_without_manual_work(
+ tmp_path, timed, visibility
+):
+ commands, tasks, store, _, _ = setup(tmp_path)
+ value = request(timed=timed, visibility=visibility)
+ before = counts(store)
+ receipt = commands.create("relative", value)
+ result = receipt.result.decode()
+ assert receipt.command.action == "recurrence.relative.create"
+ assert result["processing"] == "pending" and result["first_occurrence_id"] is None
+ with store.read() as unit:
+ rule = unit.recurrence.relative_schedule("home", result["schedule_id"])
+ progress = unit.recurrence.relative_progress("home", rule.schedule_id)
+ item = unit.tasks.definition("home", result["definition_id"])
+ assert rule.definition_id == item.definition_id
+ assert rule.initial.nominal == value.initial
+ assert rule.interval == value.interval and rule.policy == value.policy
+ assert rule.visibility == visibility and rule.created_at == item.created_at == 100
+ assert progress.pristine and progress.matches_schedule(rule)
+ assert progress.source_after == 0 and progress.last_run_at is None
+ assert progress.current_occurrence_id is progress.current_slot_key is None
+ assert unit.recurrence.relative_slots("home", rule.schedule_id) == ()
+ assert unit.recurrence.schedules("home") == ()
+ assert unit.tasks.occurrences("home") == ()
+ assert unit.tasks.policy("home").revision == 1
+ if timed:
+ assert rule.initial.resolved.resolved_local == datetime(2026, 3, 29, 3)
+ else:
+ assert rule.initial.resolved is None
+ assert unit.receipts.get(receipt.command) == receipt
+ assert (
+ unit.audit.entries("home")[-1].detail.decode()["request"] == value.document().decode()
+ )
+ event = unit.outbox.pending()[-1]
+ assert event.event_id == receipt.operation_id
+ assert event.payload.decode() == {
+ "version": 1,
+ "kind": "recurrence.relative.created",
+ **result,
+ }
+ assert counts(store) == (1, 0, before[2] + 1, before[3] + 1)
+ tasks.create("ordinary", CreateTask(TaskContent("Manual")))
+ assert counts(store)[:2] == (2, 1)
+
+
+@pytest.mark.parametrize("fold", list(FoldPolicy))
+def test_explicit_initial_fold_keeps_one_configured_instant_and_milliseconds(tmp_path, fold):
+ commands, _, store, _, _ = setup(tmp_path)
+ value = request(timed=True)
+ value = replace(
+ value,
+ initial=NominalTime(datetime(2026, 10, 25, 2, 30, 0, 321000)),
+ policy=replace(value.policy, repeated_time=fold),
+ )
+ receipt = commands.create("fold", value)
+ with store.read() as unit:
+ rule = unit.recurrence.relative_schedule("home", receipt.result.decode()["schedule_id"])
+ assert rule.initial.resolved == PinnedRecurrenceTime().resolve(value.initial, value.policy)
+ assert rule.initial.resolved.instant_ms % 1000 == 321
+ assert rule.initial.resolved.offset_seconds == (7200 if fold is FoldPolicy.FIRST else 3600)
+ assert unit.tasks.occurrences("home") == ()
+
+
+def test_authorized_original_replay_precedes_provider_work_or_new_identity_allocation(
+ tmp_path, monkeypatch
+):
+ commands, _, store, actor, identifiers = setup(tmp_path)
+ value = request(timed=True)
+ original = commands.create("original", value)
+ before, allocated, calls = counts(store), identifiers.count, actor.calls
+
+ def unavailable(*args):
+ raise AssertionError("An authorized original is returned before provider work")
+
+ monkeypatch.setattr(PinnedRecurrenceTime, "require_policy", unavailable)
+ monkeypatch.setattr(PinnedRecurrenceTime, "resolve", unavailable)
+ again = replace(commands, transactions=SQLiteDatabase(store.path, store.installation))
+ assert again.create("original", value) == original
+ assert counts(store) == before and identifiers.count == allocated and actor.calls == calls + 1
+
+
+@pytest.mark.parametrize("change", ["initial", "interval", "policy", "visibility", "task"])
+def test_original_command_cannot_be_reused_for_a_different_relative_intention(tmp_path, change):
+ commands, _, store, _, _ = setup(tmp_path)
+ value = request()
+ commands.create("original", value)
+ changes = {
+ "initial": {"initial": NominalDate(value.initial.local + timedelta(days=1))},
+ "interval": {"interval": RelativeInterval(31)},
+ "policy": {"policy": replace(value.policy, zone="Unknown/Zone")},
+ "visibility": {"visibility": SuccessorVisibility.AT_VERIFICATION},
+ "task": {"task": CreateTask(TaskContent("Different"))},
+ }
+ before = counts(store)
+ with pytest.raises(Conflict, match="idempotency_conflict"):
+ commands.create("original", replace(value, **changes[change]))
+ assert counts(store) == before
+
+
+@pytest.mark.parametrize(
+ "stage", ["policy", "definition", "schedule", "progress", "receipt", "audit", "outbox"]
+)
+def test_every_relative_creation_write_rolls_back_policy_rule_and_original_evidence(
+ tmp_path, monkeypatch, stage
+):
+ commands, _, store, _, _ = setup(tmp_path)
+ target, name = {
+ "policy": (SQLiteTasks, "save_policy"),
+ "definition": (SQLiteTasks, "save_definition"),
+ "schedule": (SQLiteRelativeRecurrences, "save_relative_schedule"),
+ "progress": (SQLiteRelativeRecurrences, "save_relative_progress"),
+ "receipt": (SQLiteReceipts, "add"),
+ "audit": (SQLiteAudit, "append"),
+ "outbox": (SQLiteOutbox, "append"),
+ }[stage]
+ original = getattr(target, name)
+ before = counts(store)
+
+ def fail_after(self, *args):
+ original(self, *args)
+ raise StorageFailure("fixture_after_write")
+
+ with monkeypatch.context() as patch:
+ patch.setattr(target, name, fail_after)
+ with pytest.raises(StorageFailure, match="fixture_after_write"):
+ commands.create("relative", request())
+ assert counts(store) == before
+ with store.read() as unit:
+ assert unit.tasks.policy("home") is None
+ assert unit.recurrence.relative_schedules("home") == ()
+ assert commands.create("relative", request()).result.decode()["processing"] == "pending"
+
+
+@pytest.mark.parametrize("change", ["restricted", "suspended", "unlinked", "outage", "override"])
+def test_fresh_identity_and_current_grants_precede_new_effect_and_original_replay(tmp_path, change):
+ commands, _, store, actor, _ = setup(tmp_path)
+ value = request()
+ commands.create("original", value)
+ if change in ("restricted", "unlinked"):
+ actor.human = HumanIdentity(
+ "ha_child" if change == "restricted" else "ha_absent", False, True, False
+ )
+ elif change == "suspended":
+ with store.write() as unit:
+ member = unit.members.by_user("home", actor.human.ha_user_id)
+ unit.members.save(replace(member, status=MemberStatus.SUSPENDED, revision=2), 1)
+ elif change == "outage":
+ actor.error = IdentityUnavailable("ha_unavailable")
+ else:
+ actor.human = HumanIdentity("ha_other", False, True, False)
+ with store.write() as unit:
+ member = unit.members.by_user("home", actor.human.ha_user_id)
+ unit.members.save(replace(member, role=Role.ADULT, revision=2), 1)
+ value = replace(
+ value,
+ task=replace(value.task, overrides=TaskOverrides(adult_performer_may_approve=False)),
+ )
+ before = counts(store)
+ for key in ("new", "original"):
+ with pytest.raises(IdentityUnavailable if change == "outage" else Forbidden):
+ commands.create(key, value)
+ assert counts(store) == before
+
+
+@pytest.mark.parametrize("timed", [False, True])
+def test_grant_revocation_during_external_time_preparation_is_rechecked_in_writer(
+ tmp_path, monkeypatch, timed
+):
+ commands, _, store, actor, _ = setup(tmp_path)
+ original = PinnedRecurrenceTime.require_policy
+
+ def revoke(self, policy):
+ original(self, policy)
+ with store.write() as unit:
+ member = unit.members.by_user("home", actor.human.ha_user_id)
+ unit.members.save(replace(member, status=MemberStatus.SUSPENDED, revision=2), 1)
+
+ monkeypatch.setattr(PinnedRecurrenceTime, "require_policy", revoke)
+ before = counts(store)
+ with pytest.raises(Forbidden):
+ commands.create("relative", request(timed=timed))
+ assert counts(store) == before
+
+
+@pytest.mark.parametrize(
+ "change,code", [("zone", "unknown_time_zone"), ("data", "timezone_data_mismatch")]
+)
+def test_unavailable_initial_time_policy_leaves_no_partial_relative_intention(
+ tmp_path, change, code
+):
+ commands, _, store, _, _ = setup(tmp_path)
+ value = request()
+ value = replace(
+ value,
+ policy=replace(
+ value.policy,
+ **(
+ {"zone": "Unknown/Zone"} if change == "zone" else {"data_identity": "fixture:wrong"}
+ ),
+ ),
+ )
+ before = counts(store)
+ with pytest.raises(InvalidInput if change == "zone" else IntegrationUnavailable, match=code):
+ commands.create("relative", value)
+ assert counts(store) == before
+
+
+@pytest.mark.parametrize("change", ["none", "nominal", "policy"])
+def test_contradictory_time_port_response_cannot_enter_saved_initial_slot(
+ tmp_path, monkeypatch, change
+):
+ commands, _, store, _, _ = setup(tmp_path)
+ value = request(timed=True)
+ resolved = PinnedRecurrenceTime().resolve(value.initial, value.policy)
+ altered = (
+ None
+ if change == "none"
+ else replace(
+ resolved,
+ **(
+ {"nominal": NominalTime(value.initial.local - timedelta(minutes=1))}
+ if change == "nominal"
+ else {"policy": replace(value.policy, revision=2)}
+ ),
+ )
+ )
+ monkeypatch.setattr(PinnedRecurrenceTime, "resolve", lambda *args: altered)
+ before = counts(store)
+ with pytest.raises(IntegrationUnavailable, match="recurrence_time_source_invalid"):
+ commands.create("relative", value)
+ assert counts(store) == before
+
+
+@pytest.mark.parametrize("field", ["task", "initial", "interval", "policy", "visibility"])
+def test_relative_request_requires_typed_explicit_timing_and_visibility(field):
+ with pytest.raises(InvalidInput, match="invalid_recurrence_create"):
+ replace(request(), **{field: None})
+
+
+def test_nonactive_relative_task_requires_a_deliberate_later_configuration():
+ with pytest.raises(InvalidInput, match="invalid_recurrence_create"):
+ replace(request(), task=CreateTask(TaskContent("Draft"), state=DefinitionState.DRAFT))
+
+
+def test_relative_creation_reuses_material_ownership_and_current_link_grants(tmp_path):
+ from test_task_material_commands import Inventory
+
+ from domus.domain.task_material_values import MaterialQuantity, MaterialSelection
+
+ commands, _, store, actor, _ = setup(tmp_path)
+ inventory = Inventory(tmp_path / "inventory.sqlite3")
+ commands.inventory_factory = inventory.factory
+ value = request()
+ value = replace(
+ value,
+ task=replace(
+ value.task, materials=(MaterialSelection("binding", MaterialQuantity("0.5")),)
+ ),
+ )
+ receipt = commands.create("relative", value)
+ with store.read() as unit:
+ item = unit.tasks.definition("home", receipt.result.decode()["definition_id"])
+ assert item.materials[0].binding == inventory.bindings["binding"]
+ member = unit.members.by_user("home", actor.human.ha_user_id)
+ with store.write() as unit:
+ unit.members.save(
+ replace(
+ member, exceptions=(PermissionRule(Permission.INVENTORY_MANAGE, False),), revision=2
+ ),
+ 1,
+ )
+ before = counts(store)
+ for key in ("new", "relative"):
+ with pytest.raises(Forbidden):
+ commands.create(key, value)
+ assert counts(store) == before
+
+
+def test_relative_override_is_snapshotted_without_replacing_household_defaults(tmp_path):
+ commands, _, store, _, _ = setup(tmp_path)
+ value = request()
+ value = replace(
+ value, task=replace(value.task, overrides=TaskOverrides(adult_performer_may_approve=False))
+ )
+ receipt = commands.create("relative", value)
+ with store.read() as unit:
+ item = unit.tasks.definition("home", receipt.result.decode()["definition_id"])
+ assert item.overrides == value.task.overrides
+ assert unit.tasks.policy("home").rules.adult_performer_may_approve
+
+
+@pytest.mark.parametrize("timed", [False, True])
+@pytest.mark.parametrize("different", [False, True])
+def test_independent_creation_writers_keep_one_atomic_original_and_explicit_retry(
+ tmp_path, timed, different
+):
+ from concurrent.futures import ThreadPoolExecutor
+ from threading import Barrier
+
+ from domus.domain.errors import StorageBusy
+ from domus.infrastructure.application_runtime import SecureIdentifiers
+ from domus.infrastructure.sqlite_snapshots import SQLiteSnapshots
+
+ commands, _, store, actor, _ = setup(tmp_path)
+ gate = Barrier(2)
+
+ class SimultaneousTimes(PinnedRecurrenceTime):
+ def require_policy(self, policy):
+ super().require_policy(policy)
+ gate.wait(timeout=5)
+
+ first = request(timed=timed)
+ second = replace(first, interval=RelativeInterval(31)) if different else first
+
+ def create(value):
+ independent = SQLiteDatabase(store.path, store.installation)
+ writer = RelativeRecurrenceCommands(
+ independent, actor, Clock(), SecureIdentifiers(), SimultaneousTimes()
+ )
+ try:
+ return writer.create("concurrent", value)
+ except (Conflict, StorageBusy) as error:
+ return error
+
+ with ThreadPoolExecutor(max_workers=2) as pool:
+ results = tuple(pool.map(create, (first, second)))
+ winners = tuple(value for value in results if not isinstance(value, (Conflict, StorageBusy)))
+ assert len(winners) in (1, 2) and len({value.operation_id for value in winners}) == 1
+ winner = winners[0]
+ for value in (first, second):
+ if value.document().fingerprint == winner.payload_fingerprint:
+ assert commands.create("concurrent", value) == winner
+ else:
+ with pytest.raises(Conflict, match="idempotency_conflict"):
+ commands.create("concurrent", value)
+ with store.read() as unit:
+ assert len(unit.recurrence.relative_schedules("home")) == 1
+ assert len(unit.tasks.definitions("home")) == 1
+ assert unit.tasks.occurrences("home") == ()
+ assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 2
+ assert unit.receipts.get(winner.command) == winner
+ assert SQLiteSnapshots(store, tmp_path / "after", store.installation, Clock()).create("after")
+
+
+def test_real_restore_keeps_missing_creation_historical_without_recreating_rule_or_work(
+ tmp_path, monkeypatch
+):
+ from test_activation_compatibility import identity_at
+ from test_sqlite_snapshots import Clock as SnapshotClock
+
+ 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 / "source", tmp_path / "before", tmp_path / "prepared"
+ root.mkdir(mode=0o700)
+ commands, _, store, _, identifiers = setup(root)
+ identity_at(root, store.installation)
+ checkpoint = SQLiteSnapshots(store, archive, store.installation, SnapshotClock()).create(
+ "before"
+ )
+ value = request(timed=True)
+ original = commands.create("relative", value)
+ allocated = identifiers.count
+ SQLiteRestorations(
+ store, archive, target, store.installation, SnapshotClock(), checkpoint
+ ).prepare("relative-creation-restore")
+ assert (
+ SQLiteActivation(root, store.installation, SnapshotClock(), target).activate().state
+ == "active"
+ )
+
+ def unavailable(*args):
+ raise AssertionError("Historical creation must not replay provider work")
+
+ monkeypatch.setattr(PinnedRecurrenceTime, "require_policy", unavailable)
+ monkeypatch.setattr(PinnedRecurrenceTime, "resolve", unavailable)
+ storage = open_storage(root, "edge")
+ try:
+ restored_commands = replace(commands, transactions=storage.database)
+ restored = restored_commands.create("relative", value)
+ assert restored.historical and restored.operation_id == original.operation_id
+ assert restored.result == original.result and identifiers.count == allocated
+ with storage.database.read() as unit:
+ assert unit.tasks.definitions("home") == unit.tasks.occurrences("home") == ()
+ assert unit.recurrence.relative_schedules("home") == ()
+ assert unit.tasks.policy("home") is None
+ assert len(unit.audit.entries("home")) == len(unit.outbox.pending()) == 1
+ assert all(event.event_id != original.operation_id for event in unit.outbox.pending())
+ with pytest.raises(Conflict, match="idempotency_conflict"):
+ restored_commands.create("relative", replace(value, interval=RelativeInterval(31)))
+ assert SQLiteSnapshots(
+ storage.database, tmp_path / "after", store.installation, SnapshotClock()
+ ).create("after")
+ finally:
+ storage.close()
diff --git a/tests/containers/test_recurrence_image.py b/tests/containers/test_recurrence_image.py
index 8cbf8ad..b5a3ec7 100644
--- a/tests/containers/test_recurrence_image.py
+++ b/tests/containers/test_recurrence_image.py
@@ -31,6 +31,9 @@ def test_current_image_executes_recurrence_creation_planning_and_closed_record_c
from domus.application.task_commands import TaskCommands, CompleteTask
from domus.application.task_events import TaskEventFeed
from domus.application.relative_recurrence import RelativeRecurrencePlanner
+from domus.application.relative_recurrence_commands import (
+ CreateRelativeTask, RelativeRecurrenceCommands,
+)
from domus.composition import ingress, server
from domus.domain.errors import DatabaseRejected, InvalidInput, StorageBusy, StorageFailure
from domus.domain.recurrence_frontier import RecurrenceFrontier
@@ -341,6 +344,27 @@ async def serve_once(runtime):
assert unit.recurrence.relative_slots('home', rule.schedule_id) == (slot,)
assert unit.recurrence.schedules('home') == ()
assert SQLiteSnapshots(store, Path(directory)/'good', installation, clock).create('good')
+with TemporaryDirectory(prefix='domus-current-relative-creation-') as directory:
+ store = SQLiteDatabase(Path(directory)/'domus.sqlite3', installation)
+ store.initialize()
+ HouseholdCommands(store, human, UnusedDirectory(), TimeZoneCatalog(), clock, identifiers).setup(
+ 'setup', SetupHousehold('Fictional relative creation', 'UTC'))
+ creation = RelativeRecurrenceCommands(store, human, clock, identifiers, times)
+ request = CreateRelativeTask(CreateTask(TaskContent('Relative initial intention')),
+ NominalTime(datetime(2026, 3, 29, 2, 30, 0, 123000)), RelativeInterval(30), policy)
+ receipt = creation.create('relative-create', request)
+ assert creation.create('relative-create', request) == receipt
+ with store.read() as unit:
+ facts = receipt.result.decode()
+ rule = unit.recurrence.relative_schedule('home', facts['schedule_id'])
+ assert rule.initial.nominal == request.initial
+ assert rule.initial.resolved.resolved_local == datetime(2026, 3, 29, 3)
+ assert unit.recurrence.relative_progress('home', rule.schedule_id).pristine
+ assert len(unit.tasks.definitions('home')) == 1 and unit.tasks.occurrences('home') == ()
+ assert unit.recurrence.relative_slots('home', rule.schedule_id) == ()
+ assert unit.receipts.get(receipt.command) == receipt
+ # Actual creation is verified; the relative materializer/source consumer is still required.
+ assert SQLiteSnapshots(store, Path(directory)/'created', installation, clock).create('created')
print('Current-source recurrence, both owned runtimes and pinned-time codecs passed.')
""",
)