fix(core): preserve data integrity and bound shared UI and response work
Module Package Release / publish-packages (push) Successful in 13s

Release v0.1.46. Coordinated integrity review: GovOPlaN/govoplan-core#298.
This commit is contained in:
2026-09-08 12:30:38 +02:00
parent dc1f244f17
commit 6591aaa3fd
33 changed files with 1889 additions and 114 deletions
+28 -4
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import copy
import hashlib
import heapq
import json
from dataclasses import dataclass, field
from typing import Any, Callable, Iterable, Mapping, Sequence
@@ -436,10 +437,33 @@ def _merge_list(
result_by_id[identity] = merged.value
order_source = local_order if local_reordered and not current_reordered else current_order
merged_order = [identity for identity in order_source if identity in result_by_id]
for identity in identities:
if identity in result_by_id and identity not in merged_order:
merged_order.append(identity)
secondary_order = current_order if order_source is local_order else local_order
# Keep the chosen side's order and both sides' insertion anchors. Appending
# missing IDs would silently relocate an insertion during a disjoint edit.
# An incompatible reorder/insertion cycle is an explicit conflict.
edges: dict[str, set[str]] = {identity: set() for identity in result_by_id}
incoming = dict.fromkeys(result_by_id, 0)
for order, insertions_only in ((order_source, False), (secondary_order, True)):
selected = [identity for identity in order if identity in result_by_id]
for left, right in zip(selected, selected[1:]):
if insertions_only and left in base_by_id and right in base_by_id:
continue
if right not in edges[left]:
edges[left].add(right)
incoming[right] += 1
priority = {identity: index for index, identity in enumerate(identities)}
ready = [(priority[identity], identity) for identity, count in incoming.items() if count == 0]
heapq.heapify(ready)
merged_order: list[str] = []
while ready:
_, identity = heapq.heappop(ready)
merged_order.append(identity)
for following in edges[identity]:
incoming[following] -= 1
if incoming[following] == 0:
heapq.heappush(ready, (priority[following], following))
if len(merged_order) != len(result_by_id):
return _conflict(path, "collection_reorder", base, local, current)
return ThreeWayMergeResult(
value=[result_by_id[identity] for identity in merged_order],
conflicts=conflicts,
+2
View File
@@ -14,6 +14,7 @@ from govoplan_core.core.tabular_sources import (
DEFAULT_PREVIEW_BYTES,
DEFAULT_PREVIEW_TIMEOUT_MS,
TabularPreviewDiagnostic,
TabularCsvSource,
TabularPushdown,
TabularSourceHealth,
TabularSourceMode,
@@ -369,6 +370,7 @@ class DatasourceStageInput:
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
governance: DatasourceGovernance | None = None
csv_source: TabularCsvSource | None = None
@dataclass(frozen=True, slots=True)
+19
View File
@@ -409,6 +409,25 @@ class DocumentationTopic:
metadata: Mapping[str, Any] = field(default_factory=dict)
def localize_documentation_topics(
topics: Iterable[DocumentationTopic],
*,
locale: str,
translations: Mapping[str, Mapping[str, str]],
) -> tuple[DocumentationTopic, ...]:
"""Merge owner-supplied text translations without moving feature content."""
localized: list[DocumentationTopic] = []
for topic in topics:
translated = translations.get(topic.id)
if translated is None:
localized.append(topic)
continue
values = {name: dict(value) for name, value in topic.translations.items()}
values[locale] = {**values.get(locale, {}), **translated}
localized.append(replace(topic, translations=values))
return tuple(localized)
def localizable_documentation_metadata_keys(
topic: DocumentationTopic,
) -> tuple[str, ...]:
+37
View File
@@ -0,0 +1,37 @@
"""Pure principal attribution mechanics, not authorization or tenant resolution.
The two existing contracts intentionally differ in precedence and whitespace.
Callers retain their own service-account, scope and resource-access decisions.
"""
def principal_actor_ids(principal: object) -> tuple[str, ...]:
"""Account-first legacy IDs, unique in encounter order; retain nonblank text."""
user = getattr(principal, "user", None)
return tuple(
dict.fromkeys(
str(value)
for value in (
getattr(principal, "account_id", None),
getattr(principal, "identity_id", None),
getattr(principal, "membership_id", None),
getattr(user, "id", None),
)
if str(value or "").strip()
)
)
def principal_user_first_actor(principal: object) -> str | None:
"""First nonblank user/account/identity/membership ID, with trimmed text."""
user = getattr(principal, "user", None)
for value in (
getattr(user, "id", None),
getattr(principal, "account_id", None),
getattr(principal, "identity_id", None),
getattr(principal, "membership_id", None),
):
candidate = str(value or "").strip()
if candidate:
return candidate
return None
+157 -8
View File
@@ -1,11 +1,15 @@
from __future__ import annotations
import csv
import hashlib
import io
import json
import math
import re
from collections.abc import Mapping, Sequence
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from decimal import Decimal
from typing import Literal, Protocol, runtime_checkable
@@ -17,6 +21,77 @@ DEFAULT_PREVIEW_TIMEOUT_MS = 2_000
TabularSourceMode = Literal["live", "cached", "file_backed", "static"]
TabularHealthStatus = Literal["healthy", "warning", "error", "unknown"]
TabularDiagnosticSeverity = Literal["info", "warning", "error"]
CsvValueMode = Literal["legacy_typed", "text"]
@dataclass(frozen=True, slots=True)
class TabularCsvSource:
"""Original upload text, retained only with an explicitly durable import.
This is never catalogue metadata or transient preview retention. Owners
enforce access, size limits, lifecycle and export authorization separately.
"""
text: str
delimiter: str = ","
value_mode: CsvValueMode = "legacy_typed"
parser_profile: str = "core.csv.v1"
def csv_source_payload(source: TabularCsvSource, *, max_bytes: int = 5_000_000) -> dict[str, object]:
encoded = _csv_utf8_bytes(source.text)
if len(encoded) > max_bytes:
raise TabularSourceValidationError(f"Original CSV input is limited to {max_bytes:,} UTF-8 bytes.")
if source.value_mode not in {"text", "legacy_typed"} or len(source.delimiter) != 1:
raise TabularSourceValidationError("Invalid CSV source parsing options.")
return {
"text": source.text,
"delimiter": source.delimiter,
"value_mode": source.value_mode,
"parser_profile": source.parser_profile,
"sha256": hashlib.sha256(encoded).hexdigest(),
"byte_count": len(encoded),
}
def csv_source_summary(payload: Mapping[str, object]) -> dict[str, object]:
"""Allowlist the small, non-content evidence safe for catalogue DTOs."""
result = {key: payload[key] for key in ("delimiter", "value_mode", "parser_profile", "sha256", "byte_count")}
if "governance_history" in payload:
result["governance_sha256"] = hashlib.sha256(json.dumps(payload["governance_history"], sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")).hexdigest()
return result
def verified_csv_source_text(payload: Mapping[str, object], *, expected_summary: Mapping[str, object] | None = None) -> str:
text = payload.get("text")
if not isinstance(text, str):
raise TabularSourceUnavailableError("Original CSV source text is unavailable.")
try:
encoded = text.encode("utf-8")
except UnicodeError as exc:
raise TabularSourceUnavailableError("Original CSV source encoding is invalid.") from exc
if len(encoded) != payload.get("byte_count") or hashlib.sha256(encoded).hexdigest() != payload.get("sha256"):
raise TabularSourceUnavailableError("Original CSV source integrity verification failed.")
if expected_summary is not None:
try:
actual = json.dumps(csv_source_summary(payload), sort_keys=True, separators=(",", ":"), allow_nan=False)
expected = json.dumps(expected_summary, sort_keys=True, separators=(",", ":"), allow_nan=False)
except (KeyError, TypeError, ValueError) as exc:
raise TabularSourceUnavailableError("Original CSV source evidence is invalid.") from exc
if actual != expected:
raise TabularSourceUnavailableError("Original CSV source no longer matches its recorded evidence.")
return text
def csv_projection_matches(expected: Sequence[Mapping[str, object]], actual: Sequence[Mapping[str, object]]) -> bool:
"""CSV cells are scalar: booleans, integers and floats are not interchangeable."""
return len(expected) == len(actual) and all(
left.keys() == right.keys() and all(
type(value) is type(right[name]) and value == right[name]
for name, value in left.items()
)
for left, right in zip(expected, actual, strict=True)
)
class TabularSourceError(ValueError):
@@ -39,29 +114,44 @@ class TabularSourceUnavailableError(TabularSourceError):
pass
def _csv_utf8_bytes(text: str) -> bytes:
try:
return text.encode("utf-8")
except UnicodeError as exc:
raise TabularSourceValidationError("CSV input must be valid Unicode encodable as UTF-8.") from exc
def parse_tabular_csv(
csv_text: str,
*,
delimiter: str = ",",
max_rows: int = 10_000,
max_bytes: int = 5_000_000,
value_mode: CsvValueMode = "legacy_typed",
) -> tuple[Mapping[str, object], ...]:
"""Parse a bounded CSV document into JSON-compatible tabular rows."""
"""Parse CSV with an explicit lexical-text or backward-compatible typed mode."""
if len(delimiter) != 1:
raise TabularSourceValidationError("CSV delimiter must be one character.")
if value_mode not in {"legacy_typed", "text"}:
raise TabularSourceValidationError("Unsupported CSV value mode.")
if len(_csv_utf8_bytes(csv_text)) > max_bytes:
raise TabularSourceValidationError(f"CSV input is limited to {max_bytes:,} UTF-8 bytes.")
try:
reader = csv.DictReader(io.StringIO(csv_text), delimiter=delimiter)
reader = csv.DictReader(io.StringIO(csv_text), delimiter=delimiter, strict=value_mode == "text")
original_headers, normalized_headers = _csv_headers(reader.fieldnames)
rows: list[dict[str, object]] = []
for row in reader:
_validate_csv_row_shape(row)
if _csv_row_is_empty(row, original_headers):
if value_mode == "text" and (None in row or any(row.get(header) is None for header in original_headers)):
raise TabularSourceValidationError("CSV text rows must have exactly the number of values defined by the header.")
if value_mode == "legacy_typed" and _csv_row_is_empty(row, original_headers):
continue
if len(rows) >= max_rows:
raise TabularSourceValidationError(
f"CSV snapshots are limited to {max_rows:,} rows."
)
rows.append(_csv_row(row, original_headers, normalized_headers))
rows.append(_csv_row(row, original_headers, normalized_headers, value_mode=value_mode))
return tuple(rows)
except csv.Error as exc:
raise TabularSourceValidationError(f"CSV input could not be parsed: {exc}") from exc
@@ -106,9 +196,11 @@ def _csv_row(
row: Mapping[str | None, str | list[str] | None],
original_headers: Sequence[str],
normalized_headers: Sequence[str],
*,
value_mode: CsvValueMode = "legacy_typed",
) -> dict[str, object]:
return {
normalized: _csv_scalar(value if isinstance(value, str) else None)
normalized: (value if value_mode == "text" else _csv_scalar(value if isinstance(value, str) else None))
for original, normalized in zip(
original_headers,
normalized_headers,
@@ -128,9 +220,15 @@ def _csv_scalar(value: str | None) -> object:
if lowered in {"true", "false"}:
return lowered == "true"
if re.fullmatch(r"-?(?:0|[1-9][0-9]*)", text):
return int(text)
try:
return int(text)
except ValueError as exc:
raise TabularSourceValidationError("CSV integer exceeds the conversion limit; use text mode to preserve it.") from exc
if re.fullmatch(r"-?(?:0|[1-9][0-9]*)\.[0-9]+", text):
return float(text)
value = float(text)
if not math.isfinite(value):
raise TabularSourceValidationError("CSV numeric value exceeds the finite number range; use text mode to preserve it.")
return value
return text
@@ -141,6 +239,48 @@ class TabularColumn:
nullable: bool = True
def tabular_type_name(value: object, *, casefold_unknown: bool = False) -> str:
if isinstance(value, bool):
return "boolean"
if isinstance(value, int):
return "integer"
if isinstance(value, (float, Decimal)):
return "number"
if isinstance(value, str):
return "string"
if isinstance(value, list):
return "array"
if isinstance(value, dict):
return "object"
name = type(value).__name__
return name.casefold() if casefold_unknown else name.lower()
def infer_tabular_schema(
rows: Sequence[Mapping[str, object]],
*,
type_name: Callable[[object], str] = tabular_type_name,
) -> tuple[TabularColumn, ...]:
"""Infer first-seen columns in one pass without retaining column values.
The classifier is explicit so legacy providers can preserve their exact
type naming. Missing keys and explicit None both make a column nullable.
"""
states: dict[str, tuple[str | None, int]] = {}
for row in rows:
for name, value in row.items():
kind, concrete = states.get(name, (None, 0))
if value is not None:
value_kind = type_name(value)
kind = value_kind if kind is None else kind if kind == value_kind else "mixed"
concrete += 1
states[name] = (kind, concrete)
return tuple(
TabularColumn(name=name, data_type=kind if kind is not None else "unknown", nullable=concrete != len(rows))
for name, (kind, concrete) in states.items()
)
@dataclass(frozen=True, slots=True)
class TabularPushdown:
projections: bool = False
@@ -221,6 +361,7 @@ class TabularSnapshotInput:
rows: tuple[Mapping[str, object], ...]
description: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
csv_source: TabularCsvSource | None = None
@runtime_checkable
@@ -296,6 +437,10 @@ __all__ = [
"CAPABILITY_CONNECTORS_TABULAR_SOURCES",
"DEFAULT_PREVIEW_BYTES",
"DEFAULT_PREVIEW_TIMEOUT_MS",
"CsvValueMode",
"TabularCsvSource",
"tabular_type_name",
"infer_tabular_schema",
"TabularColumn",
"TabularPreviewDiagnostic",
"TabularPushdown",
@@ -313,6 +458,10 @@ __all__ = [
"TabularSourceUnavailableError",
"TabularSourceValidationError",
"parse_tabular_csv",
"csv_source_payload",
"csv_source_summary",
"verified_csv_source_text",
"csv_projection_matches",
"tabular_snapshot_writer",
"tabular_source_provider",
]
+147
View File
@@ -0,0 +1,147 @@
"""Exact, bound-parameter JSON permission predicates for SQLite/PostgreSQL.
These primitives only match strings (never coerced numbers/booleans) and
objects inside actual arrays. Owners still define tenant, subject, permission,
purpose and current-state policy. Unsupported dialects fail at compilation.
"""
from __future__ import annotations
import re
from collections.abc import Mapping
from sqlalchemy import Boolean, literal
from sqlalchemy.exc import CompileError
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.sql.functions import FunctionElement
class _ArrayString(FunctionElement):
type = Boolean()
inherit_cache = True
class _ObjectStrings(FunctionElement):
type = Boolean()
inherit_cache = True
class _ArrayObjectStrings(FunctionElement):
type = Boolean()
inherit_cache = True
def json_array_contains_string(column, value: str):
if type(value) is not str:
raise TypeError("JSON string membership requires a string value.")
return _ArrayString(column, literal(value))
def _field_arguments(fields: Mapping[str, str]):
if not isinstance(fields, Mapping) or not 1 <= len(fields) <= 16:
raise ValueError("JSON object matching requires between 1 and 16 string fields.")
arguments = []
for key, value in sorted(fields.items()):
if type(key) is not str or re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,127}", key) is None:
raise ValueError("JSON object field names must be simple identifiers.")
if type(value) is not str:
raise TypeError("JSON object matching requires string values.")
arguments.extend((literal(key), literal(value)))
return arguments
def json_object_matches_strings(column, fields: Mapping[str, str]):
return _ObjectStrings(column, *_field_arguments(fields))
def json_array_contains_object_strings(column, fields: Mapping[str, str]):
return _ArrayObjectStrings(column, *_field_arguments(fields))
@compiles(_ArrayString)
@compiles(_ObjectStrings)
@compiles(_ArrayObjectStrings)
def _unsupported(element, compiler, **kwargs):
raise CompileError("Exact JSON permission predicates support only SQLite and PostgreSQL.")
def _parts(element, compiler, kwargs):
return [compiler.process(item, **kwargs) for item in element.clauses]
def _sqlite_array(value):
return f"CASE WHEN json_type({value}) = 'array' THEN {value} ELSE '[]' END"
def _postgres_array(value):
value = f"CAST({value} AS JSON)"
return f"CASE WHEN json_typeof({value}) = 'array' THEN {value} ELSE '[]'::json END"
def _sqlite_fields(value, fields):
terms = []
for index in range(0, len(fields), 2):
key, expected = fields[index:index + 2]
path = f"('$.' || {key})"
terms.extend((f"json_type({value}, {path}) = 'text'", f"json_extract({value}, {path}) = {expected}"))
return " AND ".join(terms)
def _postgres_fields(value, fields):
terms = []
for index in range(0, len(fields), 2):
key, expected = fields[index:index + 2]
terms.extend((f"json_typeof(({value}) -> {key}) = 'string'", f"(({value}) ->> {key}) = {expected}"))
return " AND ".join(terms)
@compiles(_ArrayString, "sqlite")
def _array_string_sqlite(element, compiler, **kwargs):
value, expected = _parts(element, compiler, kwargs)
return (
f"EXISTS (SELECT 1 FROM json_each({_sqlite_array(value)}) AS gp_json_string "
f"WHERE gp_json_string.type = 'text' AND gp_json_string.value = {expected})"
)
@compiles(_ArrayString, "postgresql")
def _array_string_postgres(element, compiler, **kwargs):
value, expected = _parts(element, compiler, kwargs)
return (
f"EXISTS (SELECT 1 FROM json_array_elements({_postgres_array(value)}) AS gp_json_string(value) "
f"WHERE json_typeof(gp_json_string.value) = 'string' "
f"AND (gp_json_string.value #>> '{{}}') = {expected})"
)
@compiles(_ObjectStrings, "sqlite")
def _object_sqlite(element, compiler, **kwargs):
value, *fields = _parts(element, compiler, kwargs)
value = f"CASE WHEN json_type({value}) = 'object' THEN {value} ELSE '{{}}' END"
return f"({_sqlite_fields(value, fields)})"
@compiles(_ObjectStrings, "postgresql")
def _object_postgres(element, compiler, **kwargs):
value, *fields = _parts(element, compiler, kwargs)
value = f"CAST({value} AS JSON)"
return f"(json_typeof({value}) = 'object' AND {_postgres_fields(value, fields)})"
@compiles(_ArrayObjectStrings, "sqlite")
def _array_object_sqlite(element, compiler, **kwargs):
value, *fields = _parts(element, compiler, kwargs)
item = "CASE WHEN gp_json_object.type = 'object' THEN gp_json_object.value ELSE '{}' END"
return (
f"EXISTS (SELECT 1 FROM json_each({_sqlite_array(value)}) AS gp_json_object "
f"WHERE {_sqlite_fields(item, fields)})"
)
@compiles(_ArrayObjectStrings, "postgresql")
def _array_object_postgres(element, compiler, **kwargs):
value, *fields = _parts(element, compiler, kwargs)
return (
f"EXISTS (SELECT 1 FROM json_array_elements({_postgres_array(value)}) AS gp_json_object(value) "
f"WHERE json_typeof(gp_json_object.value) = 'object' "
f"AND {_postgres_fields('gp_json_object.value', fields)})"
)
@@ -1,12 +1,15 @@
from __future__ import annotations
import hashlib
from collections.abc import Awaitable, Callable
from collections.abc import AsyncIterator, Awaitable, Callable
from fastapi import Request
from starlette.responses import Response
JSON_CACHE_CONTROL = "private, no-cache"
# This bounds middleware-owned buffering, not the size of a route response.
# Larger responses retain their streaming iterator and are never truncated.
MAX_CONDITIONAL_JSON_BYTES = 1_048_576
JSON_ETAG_VARY_HEADERS = (
"Authorization",
"Cookie",
@@ -26,15 +29,34 @@ async def conditional_json_get_middleware(
The middleware deliberately works after route handling. That keeps the
contract platform-wide without requiring every module router to learn about
conditional requests, while still limiting buffering to successful JSON GET
responses.
conditional requests. Only small successful JSON responses are buffered;
larger responses stream unchanged. Authorization still runs on every GET.
"""
response = await call_next(request)
if not _eligible_for_conditional_json_get(request, response):
return response
body = b"".join([chunk async for chunk in response.body_iterator])
response.headers["cache-control"] = _conditional_cache_control(response.headers.get("cache-control"))
response.headers["vary"] = _merge_vary(response.headers.get("vary"), JSON_ETAG_VARY_HEADERS)
content_length = response.headers.get("content-length", "")
if content_length.isascii() and content_length.isdecimal() and int(content_length) > MAX_CONDITIONAL_JSON_BYTES:
return response
chunks: list[bytes] = []
size = 0
iterator = response.body_iterator
async for chunk in iterator:
if not chunk:
continue
chunks.append(chunk)
size += len(chunk)
if size > MAX_CONDITIONAL_JSON_BYTES:
# Include the crossing chunk exactly once, without draining the
# rest of the producer or copying a potentially large chunk.
response.body_iterator = _replay_prefix(chunks, iterator)
return response
body = b"".join(chunks)
etag = response.headers.get("etag") or json_response_etag(body)
headers = dict(response.headers)
headers["etag"] = etag
@@ -48,6 +70,14 @@ async def conditional_json_get_middleware(
return Response(content=body, status_code=response.status_code, headers=headers, background=response.background)
async def _replay_prefix(chunks: list[bytes], iterator: AsyncIterator[bytes]) -> AsyncIterator[bytes]:
for chunk in chunks:
yield chunk
chunks.clear()
async for chunk in iterator:
yield chunk
def json_response_etag(body: bytes) -> str:
digest = hashlib.sha256(body).hexdigest()
return f'W/"sha256-{digest}"'