diff --git a/README.md b/README.md
index bbbaa22..556184c 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,14 @@ router. `civiccore.search` now ships normalization and
fusion helpers, but not a full search engine or indexer.
`civiccore.notifications` now ships notice deadline and compliance
helpers, but not delivery queues or outbound notification orchestration.
+`civiccore.platform` now ships the Windows-local desktop contracts for module
+manifests, install-profile validation, operator health summaries, durable local
+task envelopes, backup/restore manifests, and installer/runtime action results.
+It also ships the PostgreSQL-backed `civiccore_local_tasks` migration, async
+queue helpers, and `python -m civiccore.tasks.worker` entry point for the local
+desktop worker. Product modules still own task handlers and UI, while CivicCore
+owns shared validation, queue state, retry semantics, and plain-English
+operator state.
`civiccore.verification` now ships the first release-evidence helper
surface, while sovereignty verification remains future work.
`civiccore.connectors` now also ships shared local-payload import
@@ -83,7 +91,10 @@ on top of shared vendor delta request planning plus reusable no-network
mock-city proof contracts for agenda vendors, municipal OIDC, and backup
retention/off-host storage, on top of shared live connector sync retry/circuit-breaker primitives,
including run-result normalization, operator health copy, retry delay policy, and async HTTP retry,
-on top of shared persisted audit-log hash and verification helpers for
+on top of shared Windows-local module/runtime contracts for the desktop shell,
+including no-Docker/no-WSL manifest validation, plain-English health summaries,
+PostgreSQL-backed local task queue helpers, and backup/restore checksum
+manifests, on top of shared persisted audit-log hash and verification helpers for
database-backed module audit rows on top of shared trusted-header auth config
loading and proxy-source enforcement helpers on top of shipped
trusted-header auth helpers on top of shipped
@@ -345,7 +356,35 @@ assert verify_persisted_audit_chain([
These APIs are deliberately offline-first. They do not provide JWT
issuance, SSO, user directories, credential storage, vendor-specific network
-adapters, worker scheduling, legal determinations, or vendor write-back.
+adapters, worker execution, legal determinations, or vendor write-back.
+
+## Windows-local platform contracts
+
+`civiccore.platform` exposes the shared contracts and queue helpers the
+CivicSuite Windows desktop shell uses to keep future modules pluggable without
+making clerks learn infrastructure:
+
+```python
+from civiccore.platform import (
+ ModuleManifest,
+ build_module_registry,
+ PlatformHealthCheck,
+ summarize_platform_health,
+ LocalTaskEnvelope,
+ enqueue_local_task,
+ claim_next_local_task,
+ record_task_attempt,
+ run_one_local_task,
+ build_backup_manifest,
+ plan_restore,
+)
+```
+
+For the `windows_local` install profile, module manifests cannot require
+Docker, WSL, Linux shell setup, or a terminal-only operator path. CivicCore also
+ships task envelopes, a durable PostgreSQL task table, async queue helpers, and
+a worker CLI so downstream modules share retry, health, checksum, and
+restore-safety semantics while registering their own task handlers.
## Document ingestion
@@ -534,9 +573,10 @@ router integration, and persistence orchestration remain future work.
## Scheduling helper
`civiccore.scheduling` exposes the shared cron expression contract used by
-module background jobs. Modules keep their own Celery/worker/runtime wiring,
-but should reuse this validation so one-minute accidental or adversarial
-schedules are blocked consistently across CivicSuite.
+module background jobs. `civiccore.platform` exposes the local task envelope,
+PostgreSQL queue helpers, and retry contract. Modules keep their own task
+handlers, but should reuse these helpers so one-minute accidental or adversarial
+schedules and task retry behavior are handled consistently across CivicSuite.
```python
from civiccore.scheduling import compute_next_sync_at, validate_cron_expression
diff --git a/civiccore/__init__.py b/civiccore/__init__.py
index c5e1583..1d85980 100644
--- a/civiccore/__init__.py
+++ b/civiccore/__init__.py
@@ -112,6 +112,53 @@
next_profile_prompt,
parse_profile_answer,
)
+from civiccore.platform import (
+ WINDOWS_LOCAL_BLOCKED_RUNTIME_KINDS,
+ BackupItem,
+ BackupManifest,
+ BackupRestoreAction,
+ BackupRestorePlan,
+ BackupValidationResult,
+ LocalRuntimeProfile,
+ LocalTask,
+ LocalTaskEnvelope,
+ LocalTaskResult,
+ ModuleBackupHook,
+ ModuleDependency,
+ ModuleHealthCheck,
+ ModuleManifest,
+ ModuleMigration,
+ ModuleModelRequirement,
+ ModulePermission,
+ ModuleRegistryEntry,
+ ModuleRegistryState,
+ ModuleRoute,
+ ModuleRuntimeRequirement,
+ ModuleService,
+ PlatformHealthCheck,
+ PlatformHealthStatus,
+ PlatformHealthSummary,
+ RuntimeActionResult,
+ TaskQueueSummary,
+ TaskHandler,
+ TaskRetryPolicy,
+ build_backup_manifest,
+ build_module_registry,
+ can_run_task,
+ claim_next_local_task,
+ complete_local_task,
+ enqueue_local_task,
+ fail_local_task,
+ next_retry_at,
+ plan_restore,
+ record_task_attempt,
+ run_one_local_task,
+ summarize_platform_health,
+ summarize_task_queue,
+ task_row_to_envelope,
+ validate_backup_manifest,
+ validate_windows_local_manifest,
+)
from civiccore.provenance import (
CitationTarget,
DocumentMetadata,
@@ -245,6 +292,40 @@
"ModuleEnablement",
"OnboardingField",
"OnboardingProgress",
+ "BackupItem",
+ "BackupManifest",
+ "BackupRestoreAction",
+ "BackupRestorePlan",
+ "BackupValidationResult",
+ "LocalRuntimeProfile",
+ "LocalTask",
+ "LocalTaskEnvelope",
+ "LocalTaskResult",
+ "ModuleBackupHook",
+ "ModuleDependency",
+ "ModuleHealthCheck",
+ "ModuleManifest",
+ "ModuleMigration",
+ "ModuleModelRequirement",
+ "ModulePermission",
+ "ModuleRegistryEntry",
+ "ModuleRegistryState",
+ "ModuleRoute",
+ "ModuleRuntimeRequirement",
+ "ModuleService",
+ "PlatformHealthCheck",
+ "PlatformHealthStatus",
+ "PlatformHealthSummary",
+ "RuntimeActionResult",
+ "TaskQueueSummary",
+ "TaskHandler",
+ "TaskRetryPolicy",
+ "WINDOWS_LOCAL_BLOCKED_RUNTIME_KINDS",
+ "build_backup_manifest",
+ "build_module_registry",
+ "can_run_task",
+ "claim_next_local_task",
+ "complete_local_task",
"access_level_allows",
"completed_profile_fields",
"compute_onboarding_status",
@@ -269,7 +350,13 @@
"normalize_search_query",
"normalize_search_text",
"min_interval_minutes",
+ "enqueue_local_task",
+ "fail_local_task",
+ "next_retry_at",
"parse_profile_answer",
+ "plan_restore",
+ "record_task_attempt",
+ "run_one_local_task",
"roles_grant_access",
"search_text_matches_query",
"reciprocal_rank_fusion",
@@ -282,8 +369,13 @@
"ingest_structured_record",
"register_handler",
"validate_cited_sentences",
+ "summarize_platform_health",
+ "summarize_task_queue",
+ "task_row_to_envelope",
+ "validate_backup_manifest",
"validate_cron_expression",
"validate_fernet_key_setting",
+ "validate_windows_local_manifest",
"validate_odbc_connection_string",
"validate_password_setting",
"validate_secret_setting",
diff --git a/civiccore/migrations/versions/civiccore_0003_local_task_queue.py b/civiccore/migrations/versions/civiccore_0003_local_task_queue.py
new file mode 100644
index 0000000..fdec2b6
--- /dev/null
+++ b/civiccore/migrations/versions/civiccore_0003_local_task_queue.py
@@ -0,0 +1,44 @@
+"""CivicCore migration 0003 - local task queue."""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+from civiccore.migrations.guards import idempotent_create_index, idempotent_create_table
+
+
+revision = "civiccore_0003_local_task_queue"
+down_revision = "civiccore_0002_llm"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ idempotent_create_table(
+ "civiccore_local_tasks",
+ sa.Column("task_id", sa.String(128), primary_key=True),
+ sa.Column("module_id", sa.String(64), nullable=False),
+ sa.Column("task_type", sa.String(100), nullable=False),
+ sa.Column("status", sa.String(20), nullable=False, server_default="queued"),
+ sa.Column(
+ "payload",
+ postgresql.JSONB(astext_type=sa.Text()),
+ nullable=False,
+ server_default=sa.text("'{}'::jsonb"),
+ ),
+ sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column("queued_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
+ sa.Column("available_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("last_error", sa.Text(), nullable=True),
+ sa.Column("idempotency_key", sa.String(255), nullable=True, unique=True),
+ sa.Column("audit_subject_id", sa.String(255), nullable=True),
+ )
+ idempotent_create_index("ix_civiccore_local_tasks_module", "civiccore_local_tasks", ["module_id"])
+ idempotent_create_index("ix_civiccore_local_tasks_status", "civiccore_local_tasks", ["status"])
+ idempotent_create_index("ix_civiccore_local_tasks_type", "civiccore_local_tasks", ["task_type"])
+
+
+def downgrade() -> None:
+ """No-op; local task queue data is preserved for point-in-time restore."""
+ return None
diff --git a/civiccore/models/__init__.py b/civiccore/models/__init__.py
index 450d112..f960306 100644
--- a/civiccore/models/__init__.py
+++ b/civiccore/models/__init__.py
@@ -1,11 +1,13 @@
"""CivicCore shared SQLAlchemy ORM model exports."""
from civiccore.ingest.models import DataSource, Document, DocumentChunk, IngestionStatus, SourceType
+from civiccore.platform.task_queue import LocalTask
__all__ = [
"DataSource",
"Document",
"DocumentChunk",
"IngestionStatus",
+ "LocalTask",
"SourceType",
]
diff --git a/civiccore/platform/__init__.py b/civiccore/platform/__init__.py
new file mode 100644
index 0000000..e0db969
--- /dev/null
+++ b/civiccore/platform/__init__.py
@@ -0,0 +1,106 @@
+"""Windows-local platform contracts for CivicSuite desktop deployments."""
+
+from __future__ import annotations
+
+from civiccore.platform.backup import (
+ BackupItem,
+ BackupManifest,
+ BackupRestoreAction,
+ BackupRestorePlan,
+ BackupValidationResult,
+ build_backup_manifest,
+ plan_restore,
+ validate_backup_manifest,
+)
+from civiccore.platform.health import (
+ PlatformHealthCheck,
+ PlatformHealthStatus,
+ PlatformHealthSummary,
+ summarize_platform_health,
+)
+from civiccore.platform.modules import (
+ WINDOWS_LOCAL_BLOCKED_RUNTIME_KINDS,
+ ModuleBackupHook,
+ ModuleDependency,
+ ModuleHealthCheck,
+ ModuleManifest,
+ ModuleMigration,
+ ModuleModelRequirement,
+ ModulePermission,
+ ModuleRegistryEntry,
+ ModuleRegistryState,
+ ModuleRoute,
+ ModuleRuntimeRequirement,
+ ModuleService,
+ build_module_registry,
+ validate_windows_local_manifest,
+)
+from civiccore.platform.runtime import LocalRuntimeProfile, RuntimeActionResult
+from civiccore.platform.task_queue import (
+ LocalTask,
+ TaskHandler,
+ claim_next_local_task,
+ complete_local_task,
+ enqueue_local_task,
+ fail_local_task,
+ run_one_local_task,
+ task_row_to_envelope,
+)
+from civiccore.platform.tasks import (
+ LocalTaskEnvelope,
+ LocalTaskResult,
+ TaskQueueSummary,
+ TaskRetryPolicy,
+ can_run_task,
+ next_retry_at,
+ record_task_attempt,
+ summarize_task_queue,
+)
+
+__all__ = [
+ "BackupItem",
+ "BackupManifest",
+ "BackupRestoreAction",
+ "BackupRestorePlan",
+ "BackupValidationResult",
+ "LocalRuntimeProfile",
+ "LocalTask",
+ "LocalTaskEnvelope",
+ "LocalTaskResult",
+ "ModuleBackupHook",
+ "ModuleDependency",
+ "ModuleHealthCheck",
+ "ModuleManifest",
+ "ModuleMigration",
+ "ModuleModelRequirement",
+ "ModulePermission",
+ "ModuleRegistryEntry",
+ "ModuleRegistryState",
+ "ModuleRoute",
+ "ModuleRuntimeRequirement",
+ "ModuleService",
+ "PlatformHealthCheck",
+ "PlatformHealthStatus",
+ "PlatformHealthSummary",
+ "RuntimeActionResult",
+ "TaskQueueSummary",
+ "TaskHandler",
+ "TaskRetryPolicy",
+ "WINDOWS_LOCAL_BLOCKED_RUNTIME_KINDS",
+ "build_backup_manifest",
+ "build_module_registry",
+ "can_run_task",
+ "claim_next_local_task",
+ "complete_local_task",
+ "enqueue_local_task",
+ "fail_local_task",
+ "next_retry_at",
+ "plan_restore",
+ "record_task_attempt",
+ "run_one_local_task",
+ "summarize_platform_health",
+ "summarize_task_queue",
+ "task_row_to_envelope",
+ "validate_backup_manifest",
+ "validate_windows_local_manifest",
+]
diff --git a/civiccore/platform/backup.py b/civiccore/platform/backup.py
new file mode 100644
index 0000000..6e27da3
--- /dev/null
+++ b/civiccore/platform/backup.py
@@ -0,0 +1,209 @@
+"""Backup and restore manifest contracts for local CivicSuite installs."""
+
+from __future__ import annotations
+
+import hashlib
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import Literal
+
+from pydantic import BaseModel, ConfigDict, Field
+
+RestoreActionStatus = Literal["ready", "missing", "hash_mismatch", "would_overwrite"]
+
+
+class BackupItem(BaseModel):
+ """One file captured in a local backup."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ module_id: str = Field(pattern=r"^[a-z][a-z0-9-]{1,63}$")
+ relative_path: str = Field(min_length=1)
+ size_bytes: int = Field(ge=0)
+ sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
+ required: bool = True
+
+
+class BackupManifest(BaseModel):
+ """Versioned backup manifest for a CivicSuite local profile."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ schema_version: str = "1.0"
+ backup_id: str = Field(min_length=1)
+ city_profile_id: str = Field(min_length=1)
+ civiccore_version: str = Field(min_length=1)
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
+ modules: list[str] = Field(default_factory=list)
+ items: list[BackupItem]
+
+
+class BackupValidationResult(BaseModel):
+ """Result of checking a backup manifest against files on disk."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ valid: bool
+ missing: list[str] = Field(default_factory=list)
+ hash_mismatches: list[str] = Field(default_factory=list)
+
+
+class BackupRestoreAction(BaseModel):
+ """One file restore action and its safety status."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ source_relative_path: str
+ target_relative_path: str
+ status: RestoreActionStatus
+ message: str
+
+
+class BackupRestorePlan(BaseModel):
+ """A non-destructive restore plan."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ ready: bool
+ actions: list[BackupRestoreAction]
+ blocked_reason: str | None = None
+
+
+def build_backup_manifest(
+ *,
+ root: Path,
+ files: list[Path],
+ backup_id: str,
+ city_profile_id: str,
+ civiccore_version: str,
+ module_id: str,
+) -> BackupManifest:
+ """Build a checksum manifest for files under a local data root."""
+
+ resolved_root = root.resolve()
+ items: list[BackupItem] = []
+ for path in files:
+ resolved_path = path.resolve()
+ _assert_inside(resolved_path, resolved_root)
+ if not resolved_path.is_file():
+ raise ValueError(f"backup item is not a file: {resolved_path}")
+ relative_path = resolved_path.relative_to(resolved_root).as_posix()
+ items.append(
+ BackupItem(
+ module_id=module_id,
+ relative_path=relative_path,
+ size_bytes=resolved_path.stat().st_size,
+ sha256=_sha256_file(resolved_path),
+ )
+ )
+
+ return BackupManifest(
+ backup_id=backup_id,
+ city_profile_id=city_profile_id,
+ civiccore_version=civiccore_version,
+ modules=sorted({module_id for _item in items}),
+ items=sorted(items, key=lambda item: item.relative_path),
+ )
+
+
+def validate_backup_manifest(manifest: BackupManifest, *, root: Path) -> BackupValidationResult:
+ """Check that a manifest still matches a backup directory."""
+
+ resolved_root = root.resolve()
+ missing: list[str] = []
+ mismatches: list[str] = []
+ for item in manifest.items:
+ path = (resolved_root / item.relative_path).resolve()
+ _assert_inside(path, resolved_root)
+ if not path.is_file():
+ missing.append(item.relative_path)
+ continue
+ if _sha256_file(path) != item.sha256:
+ mismatches.append(item.relative_path)
+
+ return BackupValidationResult(
+ valid=not missing and not mismatches,
+ missing=missing,
+ hash_mismatches=mismatches,
+ )
+
+
+def plan_restore(
+ manifest: BackupManifest,
+ *,
+ backup_root: Path,
+ restore_root: Path,
+ overwrite: bool = False,
+) -> BackupRestorePlan:
+ """Return a non-destructive restore plan for a backup manifest."""
+
+ resolved_backup_root = backup_root.resolve()
+ resolved_restore_root = restore_root.resolve()
+ actions: list[BackupRestoreAction] = []
+
+ for item in manifest.items:
+ source = (resolved_backup_root / item.relative_path).resolve()
+ target = (resolved_restore_root / item.relative_path).resolve()
+ _assert_inside(source, resolved_backup_root)
+ _assert_inside(target, resolved_restore_root)
+
+ if not source.is_file():
+ actions.append(
+ BackupRestoreAction(
+ source_relative_path=item.relative_path,
+ target_relative_path=item.relative_path,
+ status="missing",
+ message="Backup file is missing.",
+ )
+ )
+ continue
+ if _sha256_file(source) != item.sha256:
+ actions.append(
+ BackupRestoreAction(
+ source_relative_path=item.relative_path,
+ target_relative_path=item.relative_path,
+ status="hash_mismatch",
+ message="Backup file checksum does not match the manifest.",
+ )
+ )
+ continue
+ if target.exists() and not overwrite:
+ actions.append(
+ BackupRestoreAction(
+ source_relative_path=item.relative_path,
+ target_relative_path=item.relative_path,
+ status="would_overwrite",
+ message="Restore target already exists.",
+ )
+ )
+ continue
+ actions.append(
+ BackupRestoreAction(
+ source_relative_path=item.relative_path,
+ target_relative_path=item.relative_path,
+ status="ready",
+ message="Ready to restore.",
+ )
+ )
+
+ blockers = [action for action in actions if action.status != "ready"]
+ return BackupRestorePlan(
+ ready=not blockers,
+ actions=actions,
+ blocked_reason=None if not blockers else "Restore plan has file safety blockers.",
+ )
+
+
+def _assert_inside(path: Path, root: Path) -> None:
+ try:
+ path.relative_to(root)
+ except ValueError as exc:
+ raise ValueError(f"path is outside the expected root: {path}") from exc
+
+
+def _sha256_file(path: Path) -> str:
+ hasher = hashlib.sha256()
+ with path.open("rb") as handle:
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
+ hasher.update(chunk)
+ return hasher.hexdigest()
diff --git a/civiccore/platform/health.py b/civiccore/platform/health.py
new file mode 100644
index 0000000..d6fe659
--- /dev/null
+++ b/civiccore/platform/health.py
@@ -0,0 +1,104 @@
+"""Shared platform health projection for local CivicSuite surfaces."""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from typing import Literal
+
+from pydantic import BaseModel, ConfigDict, Field
+
+PlatformHealthStatus = Literal["ok", "needs_setup", "degraded", "blocked"]
+_STATUS_RANK: dict[PlatformHealthStatus, int] = {
+ "ok": 0,
+ "needs_setup": 1,
+ "degraded": 2,
+ "blocked": 3,
+}
+
+
+class PlatformHealthCheck(BaseModel):
+ """One health check result suitable for staff/admin health screens."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ component: str = Field(min_length=1)
+ label: str = Field(min_length=1)
+ status: PlatformHealthStatus
+ message: str = Field(min_length=1)
+ next_action: str = Field(min_length=1)
+ blocking: bool = False
+ checked_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
+ admin_detail: str | None = Field(default=None, min_length=1)
+
+
+class PlatformHealthSummary(BaseModel):
+ """Aggregated health status for the umbrella shell."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ status: PlatformHealthStatus
+ checks: list[PlatformHealthCheck]
+ next_action: str
+ staff_message: str
+ admin_message: str
+ blocked_components: list[str]
+ degraded_components: list[str]
+
+ @property
+ def is_release_blocked(self) -> bool:
+ return self.status == "blocked" or bool(self.blocked_components)
+
+
+def summarize_platform_health(checks: list[PlatformHealthCheck]) -> PlatformHealthSummary:
+ """Return the worst health status plus plain-English action copy."""
+
+ if not checks:
+ return PlatformHealthSummary(
+ status="needs_setup",
+ checks=[],
+ next_action="Run first-run setup so CivicSuite can verify the local services.",
+ staff_message="CivicSuite has not completed setup yet.",
+ admin_message="No platform health checks have reported.",
+ blocked_components=[],
+ degraded_components=[],
+ )
+
+ worst = max((check.status for check in checks), key=lambda status: _STATUS_RANK[status])
+ action_source = next(
+ (
+ check
+ for check in checks
+ if check.blocking or check.status in {"blocked", "needs_setup", "degraded"}
+ ),
+ checks[0],
+ )
+ blocked = [
+ check.component for check in checks if check.blocking or check.status == "blocked"
+ ]
+ degraded = [check.component for check in checks if check.status == "degraded"]
+
+ if blocked:
+ staff_message = "CivicSuite needs attention before affected workflows can continue."
+ elif worst == "degraded":
+ staff_message = "CivicSuite is running, but one or more background checks need attention."
+ elif worst == "needs_setup":
+ staff_message = "CivicSuite needs setup before all workflows are available."
+ else:
+ staff_message = "CivicSuite is healthy."
+
+ return PlatformHealthSummary(
+ status=worst,
+ checks=checks,
+ next_action=action_source.next_action,
+ staff_message=staff_message,
+ admin_message=_admin_message(checks),
+ blocked_components=blocked,
+ degraded_components=degraded,
+ )
+
+
+def _admin_message(checks: list[PlatformHealthCheck]) -> str:
+ details = [check.admin_detail for check in checks if check.admin_detail]
+ if details:
+ return " ".join(details)
+ return "All reported checks are available in the local health center."
diff --git a/civiccore/platform/modules.py b/civiccore/platform/modules.py
new file mode 100644
index 0000000..9610617
--- /dev/null
+++ b/civiccore/platform/modules.py
@@ -0,0 +1,368 @@
+"""Module manifest and registry contracts for local CivicSuite deployments."""
+
+from __future__ import annotations
+
+from typing import Any, Literal
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
+
+ModuleSurface = Literal["staff", "resident", "admin", "system"]
+InstallProfile = Literal["windows_local", "server", "developer"]
+ServiceType = Literal[
+ "python-service",
+ "postgres-schema",
+ "file-store",
+ "ollama-model",
+ "tauri-command",
+ "local-api",
+ "background-worker",
+]
+RuntimeRequirementKind = Literal[
+ "civiccore",
+ "postgres",
+ "pgvector",
+ "python",
+ "file_storage",
+ "ollama",
+ "llm_model",
+ "tauri_command",
+ "local_api",
+ "windows_service",
+ "webview2",
+ "external_connector",
+ "docker",
+ "wsl",
+ "linux_shell",
+ "terminal",
+]
+ModuleRegistryStatus = Literal["enabled", "disabled", "blocked"]
+
+MODULE_ID_PATTERN = r"^[a-z][a-z0-9-]{1,63}$"
+SLUG_PATTERN = r"^[a-z][a-z0-9_-]{1,63}$"
+WINDOWS_LOCAL_BLOCKED_RUNTIME_KINDS = frozenset({"docker", "wsl", "linux_shell", "terminal"})
+
+
+class ModuleDependency(BaseModel):
+ """Another CivicSuite module required by a manifest."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ module_id: str = Field(pattern=MODULE_ID_PATTERN)
+ min_version: str | None = Field(default=None, min_length=1)
+ required: bool = True
+
+
+class ModulePermission(BaseModel):
+ """Permission surfaced to the shell and module manager."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ key: str = Field(pattern=SLUG_PATTERN)
+ label: str = Field(min_length=1)
+ description: str = Field(min_length=1)
+ surface: ModuleSurface = "staff"
+ roles: list[str] = Field(default_factory=list)
+
+ @field_validator("roles")
+ @classmethod
+ def validate_roles(cls, value: list[str]) -> list[str]:
+ if not value:
+ raise ValueError("roles must name at least one role that grants the permission")
+ return value
+
+
+class ModuleRoute(BaseModel):
+ """A route the desktop shell may expose for a module."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ id: str = Field(pattern=SLUG_PATTERN)
+ path: str = Field(pattern=r"^/[a-z0-9/_:-]*$")
+ label: str = Field(min_length=1)
+ surface: ModuleSurface = "staff"
+ permission: str | None = Field(default=None, pattern=SLUG_PATTERN)
+
+
+class ModuleService(BaseModel):
+ """A local service, schema, or command needed by a module."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ id: str = Field(pattern=SLUG_PATTERN)
+ service_type: ServiceType
+ description: str = Field(min_length=1)
+ required: bool = True
+ start_order: int = Field(default=100, ge=0)
+ health_check_id: str | None = Field(default=None, pattern=SLUG_PATTERN)
+
+
+class ModuleMigration(BaseModel):
+ """A migration contract that the installer/runtime must apply or verify."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ id: str = Field(pattern=SLUG_PATTERN)
+ description: str = Field(min_length=1)
+ owner_schema: str | None = Field(default=None, min_length=1)
+ required: bool = True
+
+
+class ModuleHealthCheck(BaseModel):
+ """A module health check known to the umbrella health center."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ id: str = Field(pattern=SLUG_PATTERN)
+ label: str = Field(min_length=1)
+ description: str = Field(min_length=1)
+ blocking: bool = True
+ surface: Literal["staff", "admin", "system"] = "admin"
+ repair_action: str | None = Field(default=None, min_length=1)
+
+
+class ModuleBackupHook(BaseModel):
+ """A backup/restore hook the module contributes to the shared backup plan."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ id: str = Field(pattern=SLUG_PATTERN)
+ label: str = Field(min_length=1)
+ includes: list[str] = Field(min_length=1)
+ restore_supported: bool = True
+
+
+class ModuleModelRequirement(BaseModel):
+ """A model requirement declared by a module."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ id: str = Field(pattern=SLUG_PATTERN)
+ provider: Literal["ollama", "local", "none", "openai", "anthropic"] = "ollama"
+ model_name: str = Field(min_length=1)
+ required: bool = True
+ local_only: bool = True
+ minimum_context_tokens: int | None = Field(default=None, ge=1)
+ checksum_sha256: str | None = Field(default=None, pattern=r"^[a-fA-F0-9]{64}$")
+ download_url: str | None = Field(default=None, min_length=1)
+ license_name: str | None = Field(default=None, min_length=1)
+
+
+class ModuleRuntimeRequirement(BaseModel):
+ """Runtime capability needed by a module."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ kind: RuntimeRequirementKind
+ name: str = Field(min_length=1)
+ description: str = Field(min_length=1)
+ required: bool = True
+ operator_visible: bool = False
+
+
+class ModuleManifest(BaseModel):
+ """Complete manifest for a CivicSuite module package."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ module_id: str = Field(pattern=MODULE_ID_PATTERN)
+ name: str = Field(min_length=1)
+ version: str = Field(min_length=1)
+ package_name: str = Field(min_length=1)
+ civiccore_min_version: str = Field(min_length=1)
+ enabled_by_default: bool = True
+ surfaces: list[ModuleSurface] = Field(default_factory=lambda: ["staff"])
+ install_profiles: list[InstallProfile] = Field(default_factory=lambda: ["windows_local"])
+ dependencies: list[ModuleDependency] = Field(default_factory=list)
+ routes: list[ModuleRoute] = Field(default_factory=list)
+ permissions: list[ModulePermission] = Field(default_factory=list)
+ services: list[ModuleService] = Field(default_factory=list)
+ migrations: list[ModuleMigration] = Field(default_factory=list)
+ health_checks: list[ModuleHealthCheck] = Field(default_factory=list)
+ backup_hooks: list[ModuleBackupHook] = Field(default_factory=list)
+ model_requirements: list[ModuleModelRequirement] = Field(default_factory=list)
+ runtime_requirements: list[ModuleRuntimeRequirement] = Field(default_factory=list)
+ settings_schema_version: str | None = Field(default=None, min_length=1)
+ metadata: dict[str, Any] = Field(default_factory=dict)
+
+ @field_validator("surfaces", "install_profiles")
+ @classmethod
+ def validate_non_empty_list(cls, value: list[str]) -> list[str]:
+ if not value:
+ raise ValueError("list must contain at least one item")
+ return value
+
+ @model_validator(mode="after")
+ def validate_cross_references(self) -> ModuleManifest:
+ _assert_unique("permission", [item.key for item in self.permissions])
+ _assert_unique("route", [item.id for item in self.routes])
+ _assert_unique("service", [item.id for item in self.services])
+ _assert_unique("migration", [item.id for item in self.migrations])
+ _assert_unique("health_check", [item.id for item in self.health_checks])
+ _assert_unique("backup_hook", [item.id for item in self.backup_hooks])
+ _assert_unique("model_requirement", [item.id for item in self.model_requirements])
+
+ permission_keys = {item.key for item in self.permissions}
+ for route in self.routes:
+ if route.permission and route.permission not in permission_keys:
+ raise ValueError(
+ f"route {route.id!r} references unknown permission {route.permission!r}"
+ )
+
+ health_check_ids = {item.id for item in self.health_checks}
+ for service in self.services:
+ if service.health_check_id and service.health_check_id not in health_check_ids:
+ raise ValueError(
+ f"service {service.id!r} references unknown health check "
+ f"{service.health_check_id!r}"
+ )
+
+ for dependency in self.dependencies:
+ if dependency.module_id == self.module_id:
+ raise ValueError("module cannot depend on itself")
+
+ if "windows_local" in self.install_profiles:
+ _validate_windows_runtime_requirements(self)
+
+ return self
+
+
+class ModuleRegistryEntry(BaseModel):
+ """One module's resolved state in a runtime profile."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ module_id: str = Field(pattern=MODULE_ID_PATTERN)
+ name: str = Field(min_length=1)
+ version: str = Field(min_length=1)
+ enabled: bool
+ locked: bool = False
+ status: ModuleRegistryStatus
+ reason: str | None = None
+ manifest: ModuleManifest
+
+
+class ModuleRegistryState(BaseModel):
+ """Resolved module registry for the desktop shell and installer."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ profile: InstallProfile = "windows_local"
+ civiccore_version: str = Field(min_length=1)
+ civiccore_locked: bool = True
+ entries: list[ModuleRegistryEntry]
+
+ @property
+ def enabled_module_ids(self) -> list[str]:
+ return [entry.module_id for entry in self.entries if entry.enabled]
+
+ @property
+ def blocked_module_ids(self) -> list[str]:
+ return [entry.module_id for entry in self.entries if entry.status == "blocked"]
+
+
+def validate_windows_local_manifest(manifest: ModuleManifest | dict[str, Any]) -> ModuleManifest:
+ """Validate a manifest for the Windows desktop profile."""
+
+ parsed = manifest if isinstance(manifest, ModuleManifest) else ModuleManifest.model_validate(manifest)
+ if "windows_local" not in parsed.install_profiles:
+ raise ValueError(f"{parsed.module_id} does not declare support for windows_local")
+ _validate_windows_runtime_requirements(parsed)
+ return parsed
+
+
+def build_module_registry(
+ manifests: list[ModuleManifest | dict[str, Any]],
+ *,
+ civiccore_version: str,
+ selected_module_ids: set[str] | None = None,
+ profile: InstallProfile = "windows_local",
+) -> ModuleRegistryState:
+ """Resolve manifests into an installer/runtime registry state."""
+
+ parsed = [
+ item if isinstance(item, ModuleManifest) else ModuleManifest.model_validate(item)
+ for item in manifests
+ ]
+ ids = [item.module_id for item in parsed]
+ _assert_unique("module", ids)
+
+ by_id = {item.module_id: item for item in parsed}
+ selected = (
+ set(selected_module_ids)
+ if selected_module_ids is not None
+ else {item.module_id for item in parsed if item.enabled_by_default}
+ )
+ unknown = selected - set(by_id)
+ if unknown:
+ raise ValueError(f"selected modules are not installed: {sorted(unknown)}")
+
+ entries: list[ModuleRegistryEntry] = []
+ for manifest in parsed:
+ enabled = manifest.module_id in selected
+ status: ModuleRegistryStatus = "enabled" if enabled else "disabled"
+ reason: str | None = None
+
+ if profile not in manifest.install_profiles:
+ enabled = False
+ status = "blocked"
+ reason = f"{manifest.name} is not available for the {profile} profile."
+ elif enabled:
+ missing = [
+ dependency.module_id
+ for dependency in manifest.dependencies
+ if dependency.required and dependency.module_id not in selected
+ ]
+ if missing:
+ enabled = False
+ status = "blocked"
+ reason = f"Missing required module dependencies: {', '.join(sorted(missing))}."
+
+ entries.append(
+ ModuleRegistryEntry(
+ module_id=manifest.module_id,
+ name=manifest.name,
+ version=manifest.version,
+ enabled=enabled,
+ locked=False,
+ status=status,
+ reason=reason,
+ manifest=manifest,
+ )
+ )
+
+ return ModuleRegistryState(
+ profile=profile,
+ civiccore_version=civiccore_version,
+ civiccore_locked=True,
+ entries=sorted(entries, key=lambda entry: entry.module_id),
+ )
+
+
+def _assert_unique(label: str, values: list[str]) -> None:
+ seen: set[str] = set()
+ duplicates = sorted({value for value in values if value in seen or seen.add(value)})
+ if duplicates:
+ raise ValueError(f"duplicate {label} ids: {', '.join(duplicates)}")
+
+
+def _validate_windows_runtime_requirements(manifest: ModuleManifest) -> None:
+ blocked = [
+ requirement.kind
+ for requirement in manifest.runtime_requirements
+ if requirement.required and requirement.kind in WINDOWS_LOCAL_BLOCKED_RUNTIME_KINDS
+ ]
+ if blocked:
+ blocked_list = ", ".join(sorted(set(blocked)))
+ raise ValueError(
+ f"{manifest.module_id} cannot require {blocked_list} for the Windows local profile"
+ )
+ terminal_only = [
+ requirement.name
+ for requirement in manifest.runtime_requirements
+ if requirement.required and requirement.operator_visible and requirement.kind == "terminal"
+ ]
+ if terminal_only:
+ raise ValueError(
+ f"{manifest.module_id} exposes terminal-only operator setup: "
+ f"{', '.join(sorted(terminal_only))}"
+ )
diff --git a/civiccore/platform/runtime.py b/civiccore/platform/runtime.py
new file mode 100644
index 0000000..24d7dfd
--- /dev/null
+++ b/civiccore/platform/runtime.py
@@ -0,0 +1,63 @@
+"""Runtime action contracts for the Windows local desktop shell."""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import Literal
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator
+
+from civiccore.platform.modules import ModuleRegistryState
+
+RuntimeAction = Literal[
+ "install",
+ "first_run",
+ "health_check",
+ "repair",
+ "backup",
+ "restore",
+ "uninstall",
+ "module_install",
+ "module_disable",
+ "model_download",
+]
+RuntimeActionStatus = Literal["pending", "running", "succeeded", "needs_action", "failed", "blocked"]
+
+
+class RuntimeActionResult(BaseModel):
+ """Plain-English result returned by installer/runtime APIs."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ action: RuntimeAction
+ status: RuntimeActionStatus
+ title: str = Field(min_length=1)
+ message: str = Field(min_length=1)
+ next_action: str | None = Field(default=None, min_length=1)
+ evidence: dict[str, str | int | bool | None] = Field(default_factory=dict)
+ started_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
+ completed_at: datetime | None = None
+
+
+class LocalRuntimeProfile(BaseModel):
+ """Resolved local runtime profile for one installed city."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ profile_id: str = Field(min_length=1)
+ city_name: str = Field(min_length=1)
+ data_root: Path
+ backup_root: Path
+ module_registry: ModuleRegistryState
+ model_provider: str = "ollama"
+ local_only: bool = True
+ network_allowed_for: list[str] = Field(default_factory=list)
+
+ @field_validator("network_allowed_for")
+ @classmethod
+ def validate_network_reasons(cls, value: list[str]) -> list[str]:
+ for reason in value:
+ if not reason.strip():
+ raise ValueError("network_allowed_for entries cannot be blank")
+ return value
diff --git a/civiccore/platform/task_queue.py b/civiccore/platform/task_queue.py
new file mode 100644
index 0000000..24f6fc1
--- /dev/null
+++ b/civiccore/platform/task_queue.py
@@ -0,0 +1,211 @@
+"""PostgreSQL-backed local task queue for CivicSuite desktop runtimes."""
+
+from __future__ import annotations
+
+import inspect
+from collections.abc import Awaitable, Callable
+from datetime import UTC, datetime
+from typing import Any
+
+from sqlalchemy import DateTime, Integer, String, Text, select
+from sqlalchemy.dialects.postgresql import JSONB
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import Mapped, mapped_column
+
+from civiccore.db import Base
+from civiccore.platform.tasks import (
+ LocalTaskEnvelope,
+ TaskRetryPolicy,
+ record_task_attempt,
+)
+
+TaskHandler = Callable[[LocalTaskEnvelope], object | Awaitable[object]]
+
+
+class LocalTask(Base):
+ """Durable local task row used by the Windows local runtime."""
+
+ __tablename__ = "civiccore_local_tasks"
+
+ task_id: Mapped[str] = mapped_column(String(128), primary_key=True)
+ module_id: Mapped[str] = mapped_column(String(64), index=True)
+ task_type: Mapped[str] = mapped_column(String(100), index=True)
+ status: Mapped[str] = mapped_column(String(20), index=True, default="queued")
+ payload: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict)
+ attempt_count: Mapped[int] = mapped_column(Integer, default=0)
+ queued_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
+ available_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
+ idempotency_key: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True)
+ audit_subject_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
+
+
+def task_row_to_envelope(row: LocalTask) -> LocalTaskEnvelope:
+ """Convert an ORM row into the public task contract."""
+
+ return LocalTaskEnvelope(
+ task_id=row.task_id,
+ module_id=row.module_id,
+ task_type=row.task_type,
+ status=row.status, # type: ignore[arg-type]
+ payload=row.payload or {},
+ attempt_count=row.attempt_count,
+ queued_at=row.queued_at,
+ available_at=row.available_at,
+ last_error=row.last_error,
+ idempotency_key=row.idempotency_key,
+ audit_subject_id=row.audit_subject_id,
+ )
+
+
+async def enqueue_local_task(
+ session: AsyncSession,
+ task: LocalTaskEnvelope,
+) -> LocalTaskEnvelope:
+ """Insert a task unless its idempotency key already exists."""
+
+ if task.idempotency_key:
+ existing = await session.scalar(
+ select(LocalTask).where(LocalTask.idempotency_key == task.idempotency_key)
+ )
+ if existing is not None:
+ return task_row_to_envelope(existing)
+
+ row = LocalTask(
+ task_id=task.task_id,
+ module_id=task.module_id,
+ task_type=task.task_type,
+ status=task.status,
+ payload=task.payload,
+ attempt_count=task.attempt_count,
+ queued_at=task.queued_at,
+ available_at=task.available_at,
+ last_error=task.last_error,
+ idempotency_key=task.idempotency_key,
+ audit_subject_id=task.audit_subject_id,
+ )
+ session.add(row)
+ await session.flush()
+ return task_row_to_envelope(row)
+
+
+async def claim_next_local_task(
+ session: AsyncSession,
+ *,
+ module_id: str | None = None,
+ now: datetime | None = None,
+) -> LocalTaskEnvelope | None:
+ """Claim the next queued or due retry task."""
+
+ current_time = now or datetime.now(UTC)
+ query = (
+ select(LocalTask)
+ .where(
+ (LocalTask.status == "queued")
+ | ((LocalTask.status == "retry_wait") & (LocalTask.available_at <= current_time))
+ )
+ .order_by(LocalTask.queued_at.asc())
+ .with_for_update(skip_locked=True)
+ .limit(1)
+ )
+ if module_id is not None:
+ query = query.where(LocalTask.module_id == module_id)
+
+ row = await session.scalar(query)
+ if row is None:
+ return None
+
+ row.status = "running"
+ row.available_at = None
+ await session.flush()
+ return task_row_to_envelope(row)
+
+
+async def complete_local_task(session: AsyncSession, task_id: str) -> LocalTaskEnvelope:
+ """Mark a claimed task as succeeded."""
+
+ row = await _get_task_row(session, task_id)
+ row.status = "succeeded"
+ row.last_error = None
+ row.available_at = None
+ await session.flush()
+ return task_row_to_envelope(row)
+
+
+async def fail_local_task(
+ session: AsyncSession,
+ task_id: str,
+ *,
+ error: str,
+ now: datetime | None = None,
+ policy: TaskRetryPolicy | None = None,
+) -> LocalTaskEnvelope:
+ """Record a failed attempt and either schedule retry or exhaust the task."""
+
+ row = await _get_task_row(session, task_id)
+ result = record_task_attempt(
+ task_row_to_envelope(row),
+ success=False,
+ error=error,
+ now=now,
+ policy=policy,
+ )
+ _apply_envelope(row, result.task)
+ await session.flush()
+ return task_row_to_envelope(row)
+
+
+async def run_one_local_task(
+ session: AsyncSession,
+ *,
+ handlers: dict[str, TaskHandler],
+ module_id: str | None = None,
+ now: datetime | None = None,
+ retry_policy: TaskRetryPolicy | None = None,
+) -> LocalTaskEnvelope | None:
+ """Claim and run one task with a registered in-process handler."""
+
+ task = await claim_next_local_task(session, module_id=module_id, now=now)
+ if task is None:
+ return None
+
+ handler = handlers.get(task.task_type)
+ if handler is None:
+ return await fail_local_task(
+ session,
+ task.task_id,
+ error=f"No handler registered for task type {task.task_type!r}.",
+ now=now,
+ policy=retry_policy,
+ )
+
+ try:
+ result = handler(task)
+ if inspect.isawaitable(result):
+ await result
+ except Exception as exc: # pragma: no cover - exact handler failures are module-owned
+ return await fail_local_task(
+ session,
+ task.task_id,
+ error=str(exc),
+ now=now,
+ policy=retry_policy,
+ )
+ return await complete_local_task(session, task.task_id)
+
+
+async def _get_task_row(session: AsyncSession, task_id: str) -> LocalTask:
+ row = await session.get(LocalTask, task_id)
+ if row is None:
+ raise LookupError(f"local task not found: {task_id}")
+ return row
+
+
+def _apply_envelope(row: LocalTask, envelope: LocalTaskEnvelope) -> None:
+ row.status = envelope.status
+ row.payload = envelope.payload
+ row.attempt_count = envelope.attempt_count
+ row.available_at = envelope.available_at
+ row.last_error = envelope.last_error
+ row.idempotency_key = envelope.idempotency_key
+ row.audit_subject_id = envelope.audit_subject_id
diff --git a/civiccore/platform/tasks.py b/civiccore/platform/tasks.py
new file mode 100644
index 0000000..dfa5de7
--- /dev/null
+++ b/civiccore/platform/tasks.py
@@ -0,0 +1,166 @@
+"""Storage-neutral local task contracts for CivicSuite modules."""
+
+from __future__ import annotations
+
+from collections import Counter
+from datetime import UTC, datetime, timedelta
+from typing import Any, Literal
+
+from pydantic import BaseModel, ConfigDict, Field, model_validator
+
+TaskStatus = Literal["queued", "running", "succeeded", "retry_wait", "failed", "cancelled"]
+
+
+class TaskRetryPolicy(BaseModel):
+ """Retry settings for local durable task queues."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ max_attempts: int = Field(default=3, ge=1)
+ base_delay_seconds: int = Field(default=30, ge=0)
+ max_delay_seconds: int = Field(default=3600, ge=0)
+
+
+class LocalTaskEnvelope(BaseModel):
+ """Storage-neutral task record for module-owned persistence."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ task_id: str = Field(min_length=1)
+ module_id: str = Field(pattern=r"^[a-z][a-z0-9-]{1,63}$")
+ task_type: str = Field(min_length=1)
+ status: TaskStatus = "queued"
+ payload: dict[str, Any] = Field(default_factory=dict)
+ attempt_count: int = Field(default=0, ge=0)
+ queued_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
+ available_at: datetime | None = None
+ last_error: str | None = Field(default=None, min_length=1)
+ idempotency_key: str | None = Field(default=None, min_length=1)
+ audit_subject_id: str | None = Field(default=None, min_length=1)
+
+ @model_validator(mode="after")
+ def validate_retry_state(self) -> LocalTaskEnvelope:
+ if self.status == "retry_wait" and self.available_at is None:
+ raise ValueError("retry_wait tasks must include available_at")
+ if self.status == "failed" and not self.last_error:
+ raise ValueError("failed tasks must include last_error")
+ return self
+
+
+class LocalTaskResult(BaseModel):
+ """Result returned after recording a local task attempt."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ task: LocalTaskEnvelope
+ message: str
+ retry_at: datetime | None = None
+
+
+class TaskQueueSummary(BaseModel):
+ """Counts and operator copy for a module task queue."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ total: int
+ counts: dict[str, int]
+ blocked: bool
+ message: str
+
+
+def next_retry_at(
+ attempt_count: int,
+ failed_at: datetime,
+ *,
+ policy: TaskRetryPolicy | None = None,
+) -> datetime | None:
+ """Return the next retry time after a failed attempt, or None when exhausted."""
+
+ active_policy = policy or TaskRetryPolicy()
+ if attempt_count >= active_policy.max_attempts:
+ return None
+ delay = active_policy.base_delay_seconds * (2 ** max(0, attempt_count - 1))
+ delay = min(delay, active_policy.max_delay_seconds)
+ return failed_at + timedelta(seconds=delay)
+
+
+def can_run_task(task: LocalTaskEnvelope, *, now: datetime | None = None) -> bool:
+ """Return True when a queued or retry-wait task can be claimed."""
+
+ current_time = now or datetime.now(UTC)
+ if task.status == "queued":
+ return True
+ if task.status == "retry_wait" and task.available_at is not None:
+ return task.available_at <= current_time
+ return False
+
+
+def record_task_attempt(
+ task: LocalTaskEnvelope,
+ *,
+ success: bool,
+ now: datetime | None = None,
+ error: str | None = None,
+ policy: TaskRetryPolicy | None = None,
+) -> LocalTaskResult:
+ """Return an updated task envelope after one local worker attempt."""
+
+ current_time = now or datetime.now(UTC)
+ attempt_count = task.attempt_count + 1
+ if success:
+ updated = task.model_copy(
+ update={
+ "status": "succeeded",
+ "attempt_count": attempt_count,
+ "available_at": None,
+ "last_error": None,
+ }
+ )
+ return LocalTaskResult(task=updated, message="Task completed successfully.")
+
+ if not error:
+ raise ValueError("error is required when recording a failed task attempt")
+
+ retry_at = next_retry_at(attempt_count, current_time, policy=policy)
+ if retry_at is None:
+ updated = task.model_copy(
+ update={
+ "status": "failed",
+ "attempt_count": attempt_count,
+ "available_at": None,
+ "last_error": error,
+ }
+ )
+ return LocalTaskResult(task=updated, message="Task failed and retries are exhausted.")
+
+ updated = task.model_copy(
+ update={
+ "status": "retry_wait",
+ "attempt_count": attempt_count,
+ "available_at": retry_at,
+ "last_error": error,
+ }
+ )
+ return LocalTaskResult(task=updated, message="Task failed and is waiting to retry.", retry_at=retry_at)
+
+
+def summarize_task_queue(tasks: list[LocalTaskEnvelope]) -> TaskQueueSummary:
+ """Return counts and operator copy for a queue view."""
+
+ counts = Counter(task.status for task in tasks)
+ blocked = bool(counts.get("failed"))
+ if blocked:
+ message = "One or more local tasks failed and need review."
+ elif counts.get("retry_wait"):
+ message = "Some local tasks are waiting to retry."
+ elif counts.get("running") or counts.get("queued"):
+ message = "Local tasks are active."
+ else:
+ message = "No local task issues are reported."
+
+ return TaskQueueSummary(
+ total=len(tasks),
+ counts=dict(sorted(counts.items())),
+ blocked=blocked,
+ message=message,
+ )
diff --git a/civiccore/tasks/__init__.py b/civiccore/tasks/__init__.py
new file mode 100644
index 0000000..547de9a
--- /dev/null
+++ b/civiccore/tasks/__init__.py
@@ -0,0 +1,7 @@
+"""Local task worker entry points for CivicCore."""
+
+from __future__ import annotations
+
+from civiccore.tasks.registry import get_task_handlers, register_task_handler
+
+__all__ = ["get_task_handlers", "register_task_handler"]
diff --git a/civiccore/tasks/registry.py b/civiccore/tasks/registry.py
new file mode 100644
index 0000000..09b3a92
--- /dev/null
+++ b/civiccore/tasks/registry.py
@@ -0,0 +1,28 @@
+"""In-process task handler registry for local CivicSuite workers."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+
+from civiccore.platform.task_queue import TaskHandler
+
+_HANDLERS: dict[str, TaskHandler] = {}
+
+
+def register_task_handler(task_type: str) -> Callable[[TaskHandler], TaskHandler]:
+ """Register a callable task handler by task type."""
+
+ if not task_type.strip():
+ raise ValueError("task_type cannot be blank")
+
+ def decorator(handler: TaskHandler) -> TaskHandler:
+ _HANDLERS[task_type] = handler
+ return handler
+
+ return decorator
+
+
+def get_task_handlers() -> dict[str, TaskHandler]:
+ """Return a copy of the registered task handlers."""
+
+ return dict(_HANDLERS)
diff --git a/civiccore/tasks/worker.py b/civiccore/tasks/worker.py
new file mode 100644
index 0000000..189e2d6
--- /dev/null
+++ b/civiccore/tasks/worker.py
@@ -0,0 +1,78 @@
+"""CLI worker for the CivicCore PostgreSQL-backed local task queue."""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import importlib
+import os
+from collections.abc import Sequence
+
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+from civiccore.platform.task_queue import run_one_local_task
+from civiccore.platform.tasks import TaskRetryPolicy
+from civiccore.tasks.registry import get_task_handlers
+
+
+def _async_database_url(url: str) -> str:
+ if "+psycopg2" in url:
+ return url.replace("postgresql+psycopg2", "postgresql+asyncpg")
+ if url.startswith("postgresql://"):
+ return url.replace("postgresql://", "postgresql+asyncpg://", 1)
+ return url
+
+
+def _load_handler_modules(raw_modules: str | None) -> None:
+ if not raw_modules:
+ return
+ for module_name in [item.strip() for item in raw_modules.split(",") if item.strip()]:
+ importlib.import_module(module_name)
+
+
+async def _run_worker(args: argparse.Namespace) -> int:
+ database_url = os.environ.get("DATABASE_URL")
+ if not database_url:
+ raise RuntimeError("DATABASE_URL is required for civiccore.tasks.worker")
+ _load_handler_modules(os.environ.get("CIVICCORE_TASK_HANDLER_MODULES"))
+ handlers = get_task_handlers()
+
+ engine = create_async_engine(_async_database_url(database_url))
+ session_factory = async_sessionmaker(engine, expire_on_commit=False)
+ retry_policy = TaskRetryPolicy(
+ max_attempts=args.max_attempts,
+ base_delay_seconds=args.base_delay_seconds,
+ max_delay_seconds=args.max_delay_seconds,
+ )
+
+ try:
+ while True:
+ async with session_factory() as session:
+ task = await run_one_local_task(
+ session,
+ handlers=handlers,
+ retry_policy=retry_policy,
+ )
+ await session.commit()
+ if args.once:
+ return 0
+ if task is None:
+ await asyncio.sleep(args.poll_seconds)
+ finally:
+ await engine.dispose()
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description="Run the CivicCore local task worker.")
+ parser.add_argument("--backend", choices=["postgres"], default="postgres")
+ parser.add_argument("--once", action="store_true")
+ parser.add_argument("--poll-seconds", type=float, default=2.0)
+ parser.add_argument("--max-attempts", type=int, default=3)
+ parser.add_argument("--base-delay-seconds", type=int, default=30)
+ parser.add_argument("--max-delay-seconds", type=int, default=3600)
+ args = parser.parse_args(argv)
+ return asyncio.run(_run_worker(args))
+
+
+if __name__ == "__main__": # pragma: no cover - exercised through CLI smoke
+ raise SystemExit(main())
diff --git a/docs/audits/audit-lite-windows-local-platform-contracts-2026-06-13.md b/docs/audits/audit-lite-windows-local-platform-contracts-2026-06-13.md
new file mode 100644
index 0000000..1952b79
--- /dev/null
+++ b/docs/audits/audit-lite-windows-local-platform-contracts-2026-06-13.md
@@ -0,0 +1,33 @@
+# Audit Lite: Windows-Local Platform Contracts
+
+Date: 2026-06-13
+Repo: CivicSuite/civiccore
+Branch: work/windows-local-platform-contracts
+Scope: `civiccore.platform` module manifest, health, task, backup/restore, runtime contracts, and PostgreSQL-backed local task queue.
+
+## Verdict
+
+Unresolved findings: 0 Blocker / 0 Critical / 0 Major / 0 Minor / 0 Nit
+
+This slice is acceptable to push. It adds real importable CivicCore contracts and a launchable local task worker for the Windows-local desktop path without claiming downstream module wiring is complete.
+
+## Evidence
+
+- `python -m pip install -e .[dev]` passed after installing the repo-declared dev extras.
+- `python -m pytest` passed: 302 passed.
+- `python -m ruff check civiccore tests` passed.
+- `python -m build` passed and included `civiccore/platform` in the sdist and wheel.
+- `git diff --check` passed.
+- Focused platform/public API smoke passed: `tests/test_platform_contracts.py`, `tests/test_public_api_v03.py`, and `tests/test_smoke.py`.
+
+## Five-Lens Review
+
+- Engineering: Pass. New contracts validate the Windows-local profile against required Docker, WSL, Linux shell, and terminal requirements. The task queue now has a real migration, ORM row, async queue helpers, handler runner, and worker CLI.
+- UX: Pass. Health and runtime result contracts require plain-English messages and next actions for clerk/admin surfaces.
+- Tests: Pass. Tests cover positive registry resolution, blocked dependency state, forbidden runtime requirements, local task retry/exhaustion, PostgreSQL-backed enqueue/claim/complete/fail/worker behavior, backup checksum validation, restore overwrite blocking, and local-first runtime profile defaults.
+- Docs: Pass. README and package metadata now describe the shipped platform contracts and distinguish contracts from worker/runtime execution.
+- QA: Pass. Public package root exports were updated and covered by the public API smoke tests; packaging confirmed the new package is included.
+
+## Notes
+
+The configured `audit-lite` skill file was not present at `C:\Users\scott\.codex\skills\audit-lite\SKILL.md` in this session, so this report follows the in-repo five-lens self-audit format as the fallback. Downstream CivicRecords, CivicClerk, CivicCode, and desktop-shell adoption of these contracts and task handlers remains in subsequent slices.
diff --git a/docs/diagrams/civiccore-extraction-map.mmd b/docs/diagrams/civiccore-extraction-map.mmd
index 40ad2a2..608e5f5 100644
--- a/docs/diagrams/civiccore-extraction-map.mmd
+++ b/docs/diagrams/civiccore-extraction-map.mmd
@@ -1,6 +1,6 @@
graph TB
- subgraph SHIPPED["civiccore v0.2.0 — SHIPPING TODAY"]
- Mig["civiccore.migrations
runner + idempotent guards
civiccore_0001_baseline_v1
civiccore_0002_llm"]
+ subgraph SHIPPED["civiccore v1.2.0 - shipped core surface"]
+ Mig["civiccore.migrations
runner + idempotent guards
civiccore_0001_baseline_v1
civiccore_0002_llm
civiccore_0003_local_task_queue"]
DB["civiccore.db.Base
shared SQLAlchemy
declarative base"]
LLM["civiccore.llm
providers + templates +
registry + context +
structured output"]
end
diff --git a/docs/diagrams/civiccore-extraction-map.png b/docs/diagrams/civiccore-extraction-map.png
index 51f8cbf..55cd6ae 100644
Binary files a/docs/diagrams/civiccore-extraction-map.png and b/docs/diagrams/civiccore-extraction-map.png differ
diff --git a/docs/diagrams/civiccore-extraction-map.svg b/docs/diagrams/civiccore-extraction-map.svg
index d4ba924..a5c10c9 100644
--- a/docs/diagrams/civiccore-extraction-map.svg
+++ b/docs/diagrams/civiccore-extraction-map.svg
@@ -1,125 +1 @@
-
+
\ No newline at end of file
diff --git a/docs/diagrams/migration-order.mmd b/docs/diagrams/migration-order.mmd
index 1a2fbe3..8f6b951 100644
--- a/docs/diagrams/migration-order.mmd
+++ b/docs/diagrams/migration-order.mmd
@@ -7,7 +7,8 @@ sequenceDiagram
Consumer->>Civic: upgrade_to_head() [subprocess]
Civic->>Civic: civiccore_0001_baseline_v1
Civic->>Civic: civiccore_0002_llm (ALTER prompt_templates)
- Civic->>CivicVer: stamp 'civiccore_0002_llm'
+ Civic->>Civic: civiccore_0003_local_task_queue
+ Civic->>CivicVer: stamp 'civiccore_0003_local_task_queue'
Civic-->>Consumer: done
Consumer->>Consumer: run consumer-side alembic chain
Consumer->>ConsumerVer: stamp '020_phase2_consumer_app_backfill'
diff --git a/docs/diagrams/migration-order.png b/docs/diagrams/migration-order.png
index 6dfb8ce..7b937c7 100644
Binary files a/docs/diagrams/migration-order.png and b/docs/diagrams/migration-order.png differ
diff --git a/docs/diagrams/migration-order.svg b/docs/diagrams/migration-order.svg
index 2c26aee..b0be701 100644
--- a/docs/diagrams/migration-order.svg
+++ b/docs/diagrams/migration-order.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/docs/index.html b/docs/index.html
index 539d577..0f5cdd6 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -131,7 +131,7 @@
civiccore.migrations - migration runner + idempotent guards + civiccore_0001_baseline_v1 + civiccore_0002_llm.civiccore.migrations - migration runner + idempotent guards + civiccore_0001_baseline_v1 + civiccore_0002_llm + civiccore_0003_local_task_queue.civiccore.db - shared SQLAlchemy declarative Base.civiccore.llm - providers, templates, model registry, context utilities, and structured output.civiccore.audit - hash-chained audit primitives plus legacy-compatible persisted audit-log verification helpers.