feat(core): add provider-neutral records filing contract
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"])
|
||||
|
||||
@@ -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()
|
||||
Generated
+21
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user