feat(core): add provider-neutral records filing contract

This commit is contained in:
2026-08-06 01:43:08 +02:00
parent 7ea0cb8655
commit 5d1287735e
9 changed files with 380 additions and 2 deletions
+1
View File
@@ -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. |
+61
View File
@@ -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.<module>` 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.
+201
View File
@@ -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",
]
+1 -1
View File
@@ -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"])
+89
View File
@@ -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()
+21
View File
@@ -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
+1
View File
@@ -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",
+4 -1
View File
@@ -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;
+1
View File
@@ -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",