From 5d1287735e0d2bdef9a52a19eeeb44aabcb0a7d7 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 6 Aug 2026 01:43:08 +0200 Subject: [PATCH] feat(core): add provider-neutral records filing contract --- docs/DOCUMENTATION_MAP.md | 1 + docs/RECORDS_FILING_CONTRACT.md | 61 +++++++ src/govoplan_core/core/records.py | 201 +++++++++++++++++++++ tests/test_module_system.py | 2 +- tests/test_records_contract.py | 89 +++++++++ webui/package-lock.json | 21 +++ webui/package.json | 1 + webui/scripts/test-module-permutations.mjs | 5 +- webui/vite.config.ts | 1 + 9 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 docs/RECORDS_FILING_CONTRACT.md create mode 100644 src/govoplan_core/core/records.py create mode 100644 tests/test_records_contract.py diff --git a/docs/DOCUMENTATION_MAP.md b/docs/DOCUMENTATION_MAP.md index bcf447a..62d3247 100644 --- a/docs/DOCUMENTATION_MAP.md +++ b/docs/DOCUMENTATION_MAP.md @@ -17,6 +17,7 @@ operator, and roadmap pages. | Action/effect automation layer | `ACTION_EFFECT_AUTOMATION_LAYER.md` | Action/effect contracts, consequence preview, runner semantics, and module boundary for automation. | | External references and integration maturity | `EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md` | Stable external identity and cumulative connector maturity; configured source authority is defined by the meta target architecture. | | Institutional context and governed references | `INSTITUTIONAL_CONTEXT_CONTRACT.md` | Shared temporal, actor/representation, institution, mandate, service, party, decision, evidence, legal-basis, information-governance, presentation, and geo DTO/provider contracts. | +| Provider-neutral record filing | `RECORDS_FILING_CONTRACT.md` | Exact source-revision identity, current source authorization, idempotent filing, capability discovery, and ownership boundary. | | Temporal data read context | `TEMPORAL_DATA_CONTEXT.md` | Valid-time and recorded-time titlebar selection, HTTP/cache contract, security boundary, and module-adoption rule. | | Cross-module information governance adoption | `INFORMATION_GOVERNANCE_ADOPTION.md` | Manifest evidence and enforcement rules for temporal browsing, purpose-aware access, retention, and institutional context. | | Context-sensitive F1 help | `CONTEXTUAL_HELP_CONTRACT.md` | Focus, route, module-manifest documentation contexts, Docs projection, and hosted fallback. | diff --git a/docs/RECORDS_FILING_CONTRACT.md b/docs/RECORDS_FILING_CONTRACT.md new file mode 100644 index 0000000..378119d --- /dev/null +++ b/docs/RECORDS_FILING_CONTRACT.md @@ -0,0 +1,61 @@ +# Records Filing Contract + +Core exposes a small provider-neutral contract for filing exact source +revisions into an institutional record. Core does not own records semantics, +source-object authorization, or source bytes. `govoplan-records` owns filing +orchestration and chronology; each source module owns resolution of its exact +revision. + +## Capability Names + +- `records.filing` is supplied by the enabled Records module. +- `records.source.` is supplied by an enabled source module, for + example `records.source.files` or `records.source.cases`. + +Callers discover capabilities through the module registry. They must not +import optional source-module internals. + +## Exact Source Identity + +`RecordSourceLocator` identifies one tenant, source module, resource type, +resource ID, and immutable source revision. A source provider must: + +1. reject cross-tenant resolution; +2. require a non-empty purpose; +3. re-evaluate the caller's current module and object authorization; +4. resolve exactly the requested revision, never a mutable "current" alias; +5. return safe display/provenance metadata and a SHA-256 digest when the source + has stable bytes or a canonical snapshot; +6. fail closed when the revision is missing, quarantined, corrupt, or no longer + authorized. + +Historical Records browsing never revives historical access rights. The +source's current authorization decision remains authoritative when filing. + +## Filing Semantics + +`RecordFilingRequest` binds the exact source to a record, purpose, filing +reason, relationship, institutional context, and idempotency key. Records must +persist source identity and resolution evidence together with the filing actor, +represented capacity, valid time, recorded time, and immutable chronology. + +An idempotency key may replay only an identical request. A conflicting reuse +must fail. Filing does not transfer ownership of source content and must not +silently copy mutable source state. + +## Versioning + +The Python DTOs and protocols live in `govoplan_core.core.records`. The +manifest interface `records.filing` starts at `1.0.0`. Incompatible DTO or +behavior changes require a new interface version and release impact analysis; +additional optional metadata remains backward compatible. + +## Initial Providers + +- Files resolves an exact managed `FileVersion`, verifies current Files access + and blob integrity, and returns its stored content digest. +- Cases resolves an exact immutable case revision after current case access and + returns a digest of the canonical revision snapshot. + +Provider-specific selection UI belongs to the source module. The generic +Records dialog remains a diagnostic/manual fallback for exact identifiers. diff --git a/src/govoplan_core/core/records.py b/src/govoplan_core/core/records.py new file mode 100644 index 0000000..43d4210 --- /dev/null +++ b/src/govoplan_core/core/records.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from datetime import datetime +from typing import Literal, Protocol, runtime_checkable + + +CAPABILITY_RECORDS_FILING = "records.filing" +CAPABILITY_RECORD_SOURCE_PREFIX = "records.source." + +RecordSourceAuthority = Literal[ + "native_authoritative", + "external_authoritative", + "external_mirror", + "governed_sync", + "governance_overlay", + "linked_reference", +] + + +class RecordContractError(ValueError): + """Stable error for provider-neutral record filing operations.""" + + +@dataclass(frozen=True, slots=True) +class RecordSourceLocator: + """Exact source revision requested for filing into a record.""" + + tenant_id: str + source_module: str + resource_type: str + resource_id: str + source_revision: str + metadata: Mapping[str, object] = field(default_factory=dict) + + def __post_init__(self) -> None: + _require_text_fields( + self, + "tenant_id", + "source_module", + "resource_type", + "resource_id", + "source_revision", + ) + if len(self.resource_id) > 500 or len(self.source_revision) > 255: + raise RecordContractError("Record source identity is too long.") + + +@dataclass(frozen=True, slots=True) +class RecordSourceReference: + """Provider-resolved immutable source metadata safe to preserve in Records.""" + + locator: RecordSourceLocator + label: str + authority_mode: RecordSourceAuthority = "linked_reference" + content_sha256: str | None = None + content_type: str | None = None + size_bytes: int | None = None + valid_from: datetime | None = None + valid_to: datetime | None = None + recorded_at: datetime | None = None + launch_url: str | None = None + metadata: Mapping[str, object] = field(default_factory=dict) + + def __post_init__(self) -> None: + if not self.label.strip(): + raise RecordContractError("Record source references require a label.") + if len(self.label) > 500: + raise RecordContractError( + "Record source labels are limited to 500 characters." + ) + if self.size_bytes is not None and self.size_bytes < 0: + raise RecordContractError("Record source sizes cannot be negative.") + if self.content_sha256 is not None: + digest = self.content_sha256.removeprefix("sha256:") + if len(digest) != 64 or any( + character not in "0123456789abcdefABCDEF" for character in digest + ): + raise RecordContractError( + "Record source SHA-256 digests must be hexadecimal." + ) + if self.valid_from and self.valid_to and self.valid_to <= self.valid_from: + raise RecordContractError( + "Record source valid_to must be after valid_from." + ) + + +@dataclass(frozen=True, slots=True) +class RecordFilingRequest: + tenant_id: str + record_id: str + source: RecordSourceLocator + purpose: str + filing_reason: str + idempotency_key: str + volume_id: str | None = None + relationship: str = "contains" + institutional_context: Mapping[str, object] = field(default_factory=dict) + metadata: Mapping[str, object] = field(default_factory=dict) + + def __post_init__(self) -> None: + _require_text_fields( + self, + "tenant_id", + "record_id", + "purpose", + "filing_reason", + "idempotency_key", + "relationship", + ) + if self.source.tenant_id != self.tenant_id: + raise RecordContractError("Record filing cannot cross tenants.") + if len(self.purpose) > 255 or len(self.filing_reason) > 2_000: + raise RecordContractError("Record filing purpose or reason is too long.") + if len(self.idempotency_key) > 255: + raise RecordContractError( + "Record filing idempotency keys are limited to 255 characters." + ) + + +@dataclass(frozen=True, slots=True) +class RecordFilingResult: + record_id: str + item_id: str + sequence: int + source: RecordSourceReference + filed_at: datetime + replayed: bool = False + + +@runtime_checkable +class RecordSourceProvider(Protocol): + provider_id: str + + def resource_types(self) -> Sequence[str]: ... + + def resolve( + self, + session: object, + principal: object, + *, + locator: RecordSourceLocator, + purpose: str, + ) -> RecordSourceReference: + """Resolve one currently authorized, exact source revision.""" + + +@runtime_checkable +class RecordFilingService(Protocol): + def file( + self, + session: object, + principal: object, + *, + request: RecordFilingRequest, + ) -> RecordFilingResult: ... + + +def record_source_capability(source_module: str) -> str: + normalized = source_module.strip().lower() + if not normalized or any( + character not in "abcdefghijklmnopqrstuvwxyz0123456789_-" + for character in normalized + ): + raise RecordContractError("Record source module identifiers are invalid.") + return f"{CAPABILITY_RECORD_SOURCE_PREFIX}{normalized}" + + +def record_source_capabilities(registry: object | None) -> tuple[str, ...]: + if registry is None or not hasattr(registry, "capability_names"): + return () + return tuple( + name + for name in registry.capability_names() + if str(name).startswith(CAPABILITY_RECORD_SOURCE_PREFIX) + ) + + +def _require_text_fields(value: object, *field_names: str) -> None: + for field_name in field_names: + if not str(getattr(value, field_name, "") or "").strip(): + raise RecordContractError( + f"Record contract field {field_name} is required." + ) + + +__all__ = [ + "CAPABILITY_RECORDS_FILING", + "CAPABILITY_RECORD_SOURCE_PREFIX", + "RecordContractError", + "RecordFilingRequest", + "RecordFilingResult", + "RecordFilingService", + "RecordSourceAuthority", + "RecordSourceLocator", + "RecordSourceProvider", + "RecordSourceReference", + "record_source_capabilities", + "record_source_capability", +] diff --git a/tests/test_module_system.py b/tests/test_module_system.py index f4a655c..2759df3 100644 --- a/tests/test_module_system.py +++ b/tests/test_module_system.py @@ -3612,7 +3612,7 @@ finally: "version_max_exclusive": "0.2.0", }, modules["files"]["requires_interfaces"]) self.assertEqual( - ["campaigns", "encryption", "search"], + ["campaigns", "encryption", "records", "search"], modules["files"]["optional_dependencies"], ) self.assertIn({"name": "mail.campaign_delivery", "version": "0.2.0"}, modules["mail"]["provides_interfaces"]) diff --git a/tests/test_records_contract.py b/tests/test_records_contract.py new file mode 100644 index 0000000..bc8be0e --- /dev/null +++ b/tests/test_records_contract.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from datetime import UTC, datetime +import unittest + +from govoplan_core.core.records import ( + RecordContractError, + RecordFilingRequest, + RecordSourceLocator, + RecordSourceReference, + record_source_capabilities, + record_source_capability, +) + + +class _Registry: + def capability_names(self) -> tuple[str, ...]: + return ("mail.delivery", "records.source.cases", "records.source.files") + + +class RecordsContractTests(unittest.TestCase): + def test_exact_source_and_filing_request_are_tenant_bound(self) -> None: + locator = RecordSourceLocator( + tenant_id="tenant-1", + source_module="files", + resource_type="file_version", + resource_id="asset-1", + source_revision="version-7", + ) + reference = RecordSourceReference( + locator=locator, + label="Evidence.pdf", + content_sha256="a" * 64, + size_bytes=12, + recorded_at=datetime(2026, 8, 6, tzinfo=UTC), + ) + request = RecordFilingRequest( + tenant_id="tenant-1", + record_id="record-1", + source=locator, + purpose="process application", + filing_reason="Evidence received with the application.", + idempotency_key="filing-1", + ) + + self.assertEqual("version-7", reference.locator.source_revision) + self.assertEqual("record-1", request.record_id) + + def test_cross_tenant_filing_and_inexact_sources_are_rejected(self) -> None: + with self.assertRaisesRegex(RecordContractError, "source_revision"): + RecordSourceLocator( + tenant_id="tenant-1", + source_module="files", + resource_type="file_version", + resource_id="asset-1", + source_revision="", + ) + + locator = RecordSourceLocator( + tenant_id="tenant-1", + source_module="cases", + resource_type="case_revision", + resource_id="case-1", + source_revision="4", + ) + with self.assertRaisesRegex(RecordContractError, "cross tenants"): + RecordFilingRequest( + tenant_id="tenant-2", + record_id="record-1", + source=locator, + purpose="audit", + filing_reason="Preserve the decision basis.", + idempotency_key="filing-2", + ) + + def test_source_capabilities_are_discoverable_without_module_imports(self) -> None: + self.assertEqual("records.source.files", record_source_capability("files")) + self.assertEqual( + "records.source.forms_runtime", + record_source_capability("forms_runtime"), + ) + self.assertEqual( + ("records.source.cases", "records.source.files"), + record_source_capabilities(_Registry()), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package-lock.json b/webui/package-lock.json index b5d2b90..8b9530a 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -36,6 +36,7 @@ "@govoplan/portal-webui": "file:../../govoplan-portal/webui", "@govoplan/postbox-webui": "file:../../govoplan-postbox/webui", "@govoplan/projects-webui": "file:../../govoplan-projects/webui", + "@govoplan/records-webui": "file:../../govoplan-records/webui", "@govoplan/reporting-webui": "file:../../govoplan-reporting/webui", "@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui", "@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui", @@ -555,6 +556,22 @@ } } }, + "../../govoplan-records/webui": { + "name": "@govoplan/records-webui", + "version": "0.1.18", + "peerDependencies": { + "@govoplan/core-webui": "^0.1.18", + "lucide-react": "^1.23.0", + "react": ">=19.2.7 <20", + "react-dom": ">=19.2.7 <20", + "react-router": ">=8.3.0 <9" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } + }, "../../govoplan-reporting/webui": { "name": "@govoplan/reporting-webui", "version": "0.1.18", @@ -1567,6 +1584,10 @@ "resolved": "../../govoplan-projects/webui", "link": true }, + "node_modules/@govoplan/records-webui": { + "resolved": "../../govoplan-records/webui", + "link": true + }, "node_modules/@govoplan/reporting-webui": { "resolved": "../../govoplan-reporting/webui", "link": true diff --git a/webui/package.json b/webui/package.json index e4cac60..d2b98a6 100644 --- a/webui/package.json +++ b/webui/package.json @@ -81,6 +81,7 @@ "@govoplan/postbox-webui": "file:../../govoplan-postbox/webui", "@govoplan/projects-webui": "file:../../govoplan-projects/webui", "@govoplan/reporting-webui": "file:../../govoplan-reporting/webui", + "@govoplan/records-webui": "file:../../govoplan-records/webui", "@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui", "@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui", "@govoplan/search-webui": "file:../../govoplan-search/webui", diff --git a/webui/scripts/test-module-permutations.mjs b/webui/scripts/test-module-permutations.mjs index 587dec9..65e0da1 100644 --- a/webui/scripts/test-module-permutations.mjs +++ b/webui/scripts/test-module-permutations.mjs @@ -31,6 +31,7 @@ const packageByModule = { postbox: "@govoplan/postbox-webui", projects: "@govoplan/projects-webui", reporting: "@govoplan/reporting-webui", + records: "@govoplan/records-webui", risk_compliance: "@govoplan/risk-compliance-webui", scheduling: "@govoplan/scheduling-webui", search: "@govoplan/search-webui", @@ -80,6 +81,8 @@ const cases = [ { name: "portal-only", modules: ["portal"] }, { name: "projects-only", modules: ["projects"] }, { name: "reporting-only", modules: ["reporting"] }, + { name: "records-only", modules: ["access", "records"] }, + { name: "records-with-sources", modules: ["access", "cases", "files", "records"] }, { name: "campaign-only", modules: ["campaigns"] }, { name: "campaign-with-postbox", modules: ["campaigns", "postbox"] }, { name: "campaign-with-files-no-mail", modules: ["campaigns", "files"] }, @@ -91,7 +94,7 @@ const cases = [ { name: "docs-and-ops", modules: ["access", "docs", "ops"] }, { name: "approvals-only", modules: ["access", "approvals"] }, { name: "voting-only", modules: ["access", "voting"] }, - { name: "full-product", modules: ["access", "tenancy", "admin", "addresses", "approvals", "policy", "audit", "dashboard", "datasources", "dataflow", "dist_lists", "templates", "workflow", "views", "organizations", "idm", "identity_trust", "encryption", "cases", "committee", "campaigns", "files", "forms", "forms_runtime", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "portal", "postbox", "projects", "reporting", "risk_compliance", "search", "voting"] } + { name: "full-product", modules: ["access", "tenancy", "admin", "addresses", "approvals", "policy", "audit", "dashboard", "datasources", "dataflow", "dist_lists", "templates", "workflow", "views", "organizations", "idm", "identity_trust", "encryption", "cases", "committee", "campaigns", "files", "forms", "forms_runtime", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "portal", "postbox", "projects", "reporting", "records", "risk_compliance", "search", "voting"] } ]; const npmExec = process.env.npm_execpath; diff --git a/webui/vite.config.ts b/webui/vite.config.ts index 4187c53..0d2735e 100644 --- a/webui/vite.config.ts +++ b/webui/vite.config.ts @@ -42,6 +42,7 @@ const defaultWebModulePackages = [ "@govoplan/postbox-webui", "@govoplan/projects-webui", "@govoplan/reporting-webui", + "@govoplan/records-webui", "@govoplan/risk-compliance-webui", "@govoplan/scheduling-webui", "@govoplan/search-webui",