diff --git a/docs/CONTEXTUAL_HELP_CONTRACT.md b/docs/CONTEXTUAL_HELP_CONTRACT.md new file mode 100644 index 0000000..edfde49 --- /dev/null +++ b/docs/CONTEXTUAL_HELP_CONTRACT.md @@ -0,0 +1,59 @@ +# Contextual Help Contract + +GovOPlaN exposes context-sensitive help through `F1` and the titlebar help +control. The shell resolves a stable help identity from the focused control, +its containing surface, and the current route. The Docs module then projects +the best visible user or administrator topic for that identity. + +## Resolution Order + +The WebUI resolves help in this order: + +1. an explicit `helpContextId` or `data-help-context-id` on the focused item +2. the focused shared control's `interfaceId`, `helpTopicId`, and label key +3. a containing dialog, card, administration section, or page surface +4. the current registered route, including dynamic module routes +5. a stable route-derived fallback when no explicit identity is available + +Focused field and action contexts retain the page context as +`fallback_context`. This lets Docs show a field-specific topic when one exists +and otherwise open the owning page or module documentation instead of a generic +help page. + +## Documentation Lookup + +Static `DocumentationTopic` contributions announce exact contexts through +`metadata.help_contexts`. Core publishes that catalogue with the enabled module +manifest, allowing the shell to link directly to an exact topic when possible. +Docs still performs the authoritative audience, permission, configured-state, +and documentation-type filtering. + +When there is no exact topic, Docs resolves the page fallback and then the first +visible topic owned by the module. If Docs is unavailable, the shell opens the +hosted documentation with the same context parameters. + +## Authoring Controls + +Core shared controls expose stable help metadata. Prefer these props rather +than adding custom `F1` listeners: + +- `interfaceId` identifies a durable UI surface or action. +- `helpContextId` identifies a documentation context when it differs from the + interface identity. +- `helpTopicId` links directly to a module-owned documentation topic. +- translated label keys provide deterministic field identities for ordinary + `FormField`, `ToggleSwitch`, search, date/time, email, button, dialog, and card + controls. + +Module routes, public routes, settings sections, and administration sections +may also declare `helpContextId` and `helpTopicId`. Each module must keep a +static user/admin documentation baseline and should list its important route, +workflow, setting, permission, and limitation identities in +`metadata.help_contexts`. + +## Boundary + +Help identities describe presentation context; they are not authorization +claims. Opening help never bypasses route or documentation permissions. Docs +owns documentation projection, feature modules own their content, and Core owns +focus capture, context resolution, and fallback routing. diff --git a/docs/DOCUMENTATION_MAP.md b/docs/DOCUMENTATION_MAP.md index 0f6a66a..94116af 100644 --- a/docs/DOCUMENTATION_MAP.md +++ b/docs/DOCUMENTATION_MAP.md @@ -17,6 +17,8 @@ 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. | +| Temporal data read context | `TEMPORAL_DATA_CONTEXT.md` | Valid-time and recorded-time titlebar selection, HTTP/cache contract, security boundary, and module-adoption rule. | +| Context-sensitive F1 help | `CONTEXTUAL_HELP_CONTRACT.md` | Focus, route, module-manifest documentation contexts, Docs projection, and hosted fallback. | | Postbox E2EE target architecture | `POSTBOX_E2EE_ARCHITECTURE.md` | Strategic encrypted postbox/mailbox model, key ownership, role mailbox semantics, and retraction limits. | | Shared state, runtime coordination, and recovery | `STATE_AND_RECOVERY_CONTRACT.md` | State profiles, object storage, node registration/drain, fenced leases, migration ordering, and recovery evidence. | | Module lifecycle recovery | `MODULE_LIFECYCLE_RECOVERY.md` | Installer/live-graph recovery modes, deployment fence, evidence, retry blocking, and operator reconciliation. | diff --git a/docs/INSTITUTIONAL_CONTEXT_CONTRACT.md b/docs/INSTITUTIONAL_CONTEXT_CONTRACT.md index 8214eb0..059b215 100644 --- a/docs/INSTITUTIONAL_CONTEXT_CONTRACT.md +++ b/docs/INSTITUTIONAL_CONTEXT_CONTRACT.md @@ -8,6 +8,9 @@ The shared contract lives in `govoplan_core.core.institutional`. Core owns reference shapes and provider protocols only. It does not own shared Mandate, Service, Party, Decision, evidence, or geography tables. Domain modules own persistence and authorization; optional capabilities resolve the references. +Interactive reads use the separate platform temporal-data context documented in +`TEMPORAL_DATA_CONTEXT.md`; it never changes current authorization or supplies +mutation dates. ## Envelope diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md index 2f89818..f4af96b 100644 --- a/docs/INTERFACE_PATTERN_MIGRATION.md +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -13,7 +13,7 @@ domain modules own their compositions. | Reusable credentials | Repeated administration with an adaptive create/edit dialog, optional password generator, and destructive confirmation | Secret values are write-only; generated candidates use the browser cryptographic API without a weak fallback and do not replace the field until explicitly confirmed; scope/permission blockers name the required action, responsible actor, and destination; unavailable row actions remain keyboard-explainable | `CredentialEnvelopeManager.tsx`, shared `PasswordField`, `PasswordGeneratorDialog`, `ActionBlockerHint`, `Button`, `TableActionGroup`, and `ConfirmDialog` | | Retention policy | Effective-policy editor with inherited source paths and typed, narrowing-only controls | Parent locks and missing write authority are explicit; the save action distinguishes locks, missing target, loading, clean draft, and active save | `RetentionPolicyManagement.tsx`, policy logic tests, `test-core-interface-patterns.mjs` | | Module lifecycle | Guided operator projection over durable installer-queue evidence | Preflight, handoff, progress, stale evidence, recovery, and rollback consequences remain visible | Admin module lifecycle tests and the Core installer-queue contract | -| Shared configuration primitives | Cross-module component contract | Dialog focus, blocker structure, disabled-action focus, contextual help, unsaved changes, confirmation, loading, alerts, problem lists, and policy provenance are centralized | Core component tests and module-permutation build | +| Shared configuration primitives | Cross-module component contract | Dialog focus, blocker structure, disabled-action focus, route/page/field/action F1 help, unsaved changes, confirmation, loading, alerts, problem lists, and policy provenance are centralized | Core component tests, `CONTEXTUAL_HELP_CONTRACT.md`, and module-permutation build | ## Boundary diff --git a/docs/TEMPORAL_DATA_CONTEXT.md b/docs/TEMPORAL_DATA_CONTEXT.md new file mode 100644 index 0000000..d96dee3 --- /dev/null +++ b/docs/TEMPORAL_DATA_CONTEXT.md @@ -0,0 +1,68 @@ +# Temporal Data Context + +GovOPlaN exposes one read context for data validity and system knowledge. The +calendar control in the authenticated titlebar applies that context to +supported list and detail reads for the current account and tenant. + +## Two Independent Axes + +- **Valid time** answers when a fact applied in the represented domain. +- **Recorded time** answers what the system had recorded by a particular + instant. + +The default is data valid now under the latest recorded state. `At time` +selects a valid-time instant. `All` removes the valid-time interval filter but +still uses the selected recorded state. The optional recorded-state cutoff can +be combined with any valid-time mode, which keeps correction history distinct +from changes in real-world validity. + +An interval is half open: `valid_from <= instant < valid_to`. A revision belongs +to a recorded-state snapshot when `recorded_at <= cutoff` and it was not +superseded at or before that cutoff. + +## Security And Mutation Rules + +The temporal data context is a read projection, not an authorization context. +Authentication, permissions, active delegations, tenant boundaries, module +policy, and maintenance controls are always evaluated under current security +state. A historical projection never restores an expired permission. + +The context also does not supply mutation dates. Writes continue to target the +current lifecycle revision and must carry their explicit valid/effective dates, +expected revision, reason, and evidence where the owning contract requires +them. A screen showing historical data must not silently turn a normal edit +into a historical correction. + +## HTTP Contract + +Core accepts these request headers: + +| Header | Meaning | +| --- | --- | +| `X-Govoplan-Validity-Mode` | `current`, `at`, or `all` | +| `X-Govoplan-Valid-At` | Timezone-aware ISO 8601 instant required by `at` | +| `X-Govoplan-Recorded-At` | Optional timezone-aware system-knowledge cutoff | + +Invalid or naive timestamps fail with HTTP 400. Responses expose the resolved +mode and evaluated instant. Conditional JSON responses vary by all three +request headers, and the shared WebUI API client includes them in request +deduplication and conditional-cache keys. + +## Module Adoption + +Revision-owning modules apply +`govoplan_core.db.temporal.apply_temporal_revision_filter` only to read queries +that are meant to follow the platform context. Explicit version references and +explicit resolver `effective_at` arguments take precedence. Current-row +lookups used for optimistic concurrency, authorization, routing, effects, or +other mutations must remain explicit and context-independent. + +The initial bitemporal adoption covers Decisions, Mandates, Parties, and +Services. Their immutable revisions have indexed valid, recorded, and +superseded timestamps. Modules with effective-dated security records or +recorded-only revision histories require separate display-query adoption so +the global selector cannot affect current authorization or execution. + +The WebUI selection is stored in session storage per account and tenant. A +change remounts the active module route so existing page loaders issue a fresh +request. Returning both axes to their defaults removes the stored selection. diff --git a/docs/UI_UX_DECISION_LEDGER.md b/docs/UI_UX_DECISION_LEDGER.md index ed1b1ea..d9193a5 100644 --- a/docs/UI_UX_DECISION_LEDGER.md +++ b/docs/UI_UX_DECISION_LEDGER.md @@ -51,10 +51,12 @@ contestability, responsibility, and traceability at the point of action. | UX-025 | `window.alert` and the global `alert` function are prohibited. A narrowly necessary exception requires product-owner authorization and an entry in the alert exception register before implementation. | Accepted | All WebUI code | | UX-026 | A table defines one stable ordered action set. A row-level unavailable action remains in its normal position and is disabled, preferably with `disabledReason`; structurally irrelevant actions are omitted for the entire table. Empty rows reserve the same slots so their Add action stays in the normal left-most action position. | Accepted | All structured tables | | UX-027 | The platform icon rail keeps its brand header and utility footer visible. Only the module-navigation region scrolls when installed and permitted modules exceed the available viewport height. | Accepted | Core WebUI shell | -| UX-028 | Maintenance and offline state change the titlebar surface and repeat a quiet status label behind its controls. They must not replace, cover, or intercept the centered global-search surface; an accessible status control remains in the leading titlebar area. | Accepted | Core WebUI shell | +| UX-028 | Maintenance and offline states use their established textual warning banners centered in the titlebar. They must not recolor the shell or add decorative status icons. Global search is a right-side command, so warnings do not replace its trigger or overlay. | Accepted | Core WebUI shell | | UX-029 | Recoverable page and module errors use the central compact `DismissibleAlert` presentation with an explicit recovery action where one exists. Full-height workspaces must overlay page feedback instead of allowing an alert to become a stretched workspace row. | Accepted | Core and module WebUIs | | UX-030 | At narrow widths, the titlebar uses separate context and command rows. Context selectors remain horizontally reachable, search retains its compact trigger, and language/help/notification/account commands remain fixed icon controls without overlap. Shared content padding contracts so domain workspaces retain usable width. | Accepted | Core WebUI shell and all module workspaces | | UX-031 | Public controls and extension contributions use stable, module-namespaced interface identities. Shared controls expose `interfaceId` and `helpTopicId`; generated source anchors are inventory evidence, not a substitute for an explicit ID when documentation, policy, or automation refers to the control. | Accepted | Core and module WebUIs | +| UX-032 | `F1` resolves help from the focused field or action, then its dialog/section/page and registered route. Focused contexts retain the page fallback; Docs applies audience and permission filtering and falls back to visible module documentation. | Accepted | Core shell, Docs, and all module WebUIs | +| UX-033 | Global search is the left-most titlebar command, immediately before language selection. Its icon, `F3`, and `Ctrl`/`Cmd`+`K` all open the same permission-aware search overlay; the titlebar does not reserve a persistent query field. | Accepted | Core shell and Search WebUI | ## Confirmed Implementation Decisions diff --git a/src/govoplan_core/core/temporal.py b/src/govoplan_core/core/temporal.py new file mode 100644 index 0000000..b54faec --- /dev/null +++ b/src/govoplan_core/core/temporal.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +from contextvars import ContextVar, Token +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Literal + + +TemporalValidityMode = Literal["current", "at", "all"] +VALIDITY_MODES = frozenset({"current", "at", "all"}) + +VALIDITY_MODE_HEADER = "X-Govoplan-Validity-Mode" +VALID_AT_HEADER = "X-Govoplan-Valid-At" +RECORDED_AT_HEADER = "X-Govoplan-Recorded-At" +TEMPORAL_EVALUATED_AT_HEADER = "X-Govoplan-Temporal-Evaluated-At" +TEMPORAL_VARY_HEADERS = ( + VALIDITY_MODE_HEADER, + VALID_AT_HEADER, + RECORDED_AT_HEADER, +) + + +class TemporalContextError(ValueError): + pass + + +@dataclass(frozen=True, slots=True) +class TemporalDataContext: + """Bitemporal read context. + + Valid time answers when a fact applies. Recorded time answers which version + of that fact was known to the system. Authorization remains outside this + context and is always evaluated under the current security state. + """ + + validity_mode: TemporalValidityMode = "current" + valid_at: datetime | None = None + recorded_at: datetime | None = None + evaluated_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + + def __post_init__(self) -> None: + if self.validity_mode not in VALIDITY_MODES: + raise TemporalContextError( + f"Unsupported temporal validity mode: {self.validity_mode!r}." + ) + for name in ("valid_at", "recorded_at", "evaluated_at"): + value = getattr(self, name) + if value is not None and value.tzinfo is None: + raise TemporalContextError(f"Temporal {name} must include a timezone.") + if self.validity_mode == "at" and self.valid_at is None: + raise TemporalContextError("Validity mode 'at' requires valid_at.") + if self.validity_mode != "at" and self.valid_at is not None: + raise TemporalContextError( + "valid_at is only permitted when validity mode is 'at'." + ) + + @property + def validity_instant(self) -> datetime | None: + if self.validity_mode == "all": + return None + if self.validity_mode == "at": + return self.valid_at + return self.evaluated_at + + @property + def is_default(self) -> bool: + return self.validity_mode == "current" and self.recorded_at is None + + def to_dict(self) -> dict[str, str | None]: + return { + "validity_mode": self.validity_mode, + "valid_at": _datetime_text(self.valid_at), + "recorded_at": _datetime_text(self.recorded_at), + "evaluated_at": _datetime_text(self.evaluated_at), + } + + +_temporal_context: ContextVar[TemporalDataContext | None] = ContextVar( + "govoplan_temporal_data_context", + default=None, +) + + +def parse_temporal_data_context( + *, + validity_mode: str | None = None, + valid_at: str | None = None, + recorded_at: str | None = None, + evaluated_at: datetime | None = None, +) -> TemporalDataContext: + clean_mode = (validity_mode or "current").strip().lower() + if clean_mode not in VALIDITY_MODES: + raise TemporalContextError( + "Temporal validity mode must be one of: current, at, all." + ) + return TemporalDataContext( + validity_mode=clean_mode, # type: ignore[arg-type] + valid_at=_parse_datetime(valid_at, "valid_at"), + recorded_at=_parse_datetime(recorded_at, "recorded_at"), + evaluated_at=evaluated_at or datetime.now(UTC), + ) + + +def current_temporal_data_context() -> TemporalDataContext: + return _temporal_context.get() or TemporalDataContext() + + +def bind_temporal_data_context( + context: TemporalDataContext, +) -> Token[TemporalDataContext | None]: + return _temporal_context.set(context) + + +def reset_temporal_data_context(token: Token[TemporalDataContext | None]) -> None: + _temporal_context.reset(token) + + +def temporal_revision_matches( + context: TemporalDataContext, + *, + valid_from: datetime | None = None, + valid_to: datetime | None = None, + revision_recorded_at: datetime | None = None, + superseded_at: datetime | None = None, +) -> bool: + cutoff = context.recorded_at + if cutoff is None: + if superseded_at is not None: + return False + else: + if revision_recorded_at is None or revision_recorded_at > cutoff: + return False + if superseded_at is not None and superseded_at <= cutoff: + return False + + instant = context.validity_instant + if instant is None: + return True + return (valid_from is None or valid_from <= instant) and ( + valid_to is None or valid_to > instant + ) + + +def _parse_datetime(value: str | None, name: str) -> datetime | None: + clean = str(value or "").strip() + if not clean: + return None + if len(clean) > 64: + raise TemporalContextError(f"Temporal {name} is too long.") + normalized = f"{clean[:-1]}+00:00" if clean.endswith(("Z", "z")) else clean + try: + parsed = datetime.fromisoformat(normalized) + except ValueError as exc: + raise TemporalContextError( + f"Temporal {name} must be an ISO 8601 timestamp." + ) from exc + if parsed.tzinfo is None: + raise TemporalContextError(f"Temporal {name} must include a timezone.") + return parsed.astimezone(UTC) + + +def _datetime_text(value: datetime | None) -> str | None: + if value is None: + return None + return value.astimezone(UTC).isoformat().replace("+00:00", "Z") + + +__all__ = [ + "RECORDED_AT_HEADER", + "TEMPORAL_EVALUATED_AT_HEADER", + "TEMPORAL_VARY_HEADERS", + "VALIDITY_MODE_HEADER", + "VALID_AT_HEADER", + "TemporalContextError", + "TemporalDataContext", + "TemporalValidityMode", + "bind_temporal_data_context", + "current_temporal_data_context", + "parse_temporal_data_context", + "reset_temporal_data_context", + "temporal_revision_matches", +] diff --git a/src/govoplan_core/db/temporal.py b/src/govoplan_core/db/temporal.py new file mode 100644 index 0000000..bab7cf3 --- /dev/null +++ b/src/govoplan_core/db/temporal.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from typing import Any + +from sqlalchemy import or_ + +from govoplan_core.core.temporal import ( + TemporalContextError, + TemporalDataContext, + current_temporal_data_context, +) + + +def apply_temporal_revision_filter( + query: Any, + model: type[Any], + *, + context: TemporalDataContext | None = None, + valid_from: str | None = "valid_from", + valid_to: str | None = "valid_to", + recorded_at: str | None = "recorded_at", + superseded_at: str | None = "superseded_at", +) -> Any: + """Apply latest/as-recorded and valid-time clauses to a revision query.""" + + resolved = context or current_temporal_data_context() + clauses: list[Any] = [] + + superseded_column = _optional_column(model, superseded_at) + recorded_column = _optional_column(model, recorded_at) + if resolved.recorded_at is None: + if superseded_column is not None: + clauses.append(superseded_column.is_(None)) + else: + if recorded_column is None or superseded_column is None: + raise TemporalContextError( + f"{model.__name__} does not expose recorded/superseded revision time." + ) + clauses.extend( + ( + recorded_column <= resolved.recorded_at, + or_( + superseded_column.is_(None), + superseded_column > resolved.recorded_at, + ), + ) + ) + + instant = resolved.validity_instant + if instant is not None: + valid_from_column = _optional_column(model, valid_from) + valid_to_column = _optional_column(model, valid_to) + if valid_from_column is not None: + clauses.append( + or_(valid_from_column.is_(None), valid_from_column <= instant) + ) + if valid_to_column is not None: + clauses.append(or_(valid_to_column.is_(None), valid_to_column > instant)) + + return query.filter(*clauses) if clauses else query + + +def _optional_column(model: type[Any], name: str | None) -> Any | None: + if name is None: + return None + column = getattr(model, name, None) + if column is None: + raise TemporalContextError( + f"{model.__name__} has no temporal column named {name!r}." + ) + return column + + +__all__ = ["apply_temporal_revision_filter"] diff --git a/src/govoplan_core/server/conditional_requests.py b/src/govoplan_core/server/conditional_requests.py index 06a78b7..82dec02 100644 --- a/src/govoplan_core/server/conditional_requests.py +++ b/src/govoplan_core/server/conditional_requests.py @@ -7,7 +7,15 @@ from fastapi import Request from starlette.responses import Response JSON_CACHE_CONTROL = "private, no-cache" -JSON_ETAG_VARY_HEADERS = ("Authorization", "Cookie", "X-API-Key", "Accept-Language") +JSON_ETAG_VARY_HEADERS = ( + "Authorization", + "Cookie", + "X-API-Key", + "Accept-Language", + "X-Govoplan-Validity-Mode", + "X-Govoplan-Valid-At", + "X-Govoplan-Recorded-At", +) async def conditional_json_get_middleware( diff --git a/src/govoplan_core/server/fastapi.py b/src/govoplan_core/server/fastapi.py index 467111c..a804603 100644 --- a/src/govoplan_core/server/fastapi.py +++ b/src/govoplan_core/server/fastapi.py @@ -17,6 +17,7 @@ from govoplan_core.core.registry import PlatformRegistry from govoplan_core.db.query_metrics import collect_query_metrics from govoplan_core.server.conditional_requests import conditional_json_get_middleware from govoplan_core.server.request_limits import RequestBodyLimitMiddleware +from govoplan_core.server.temporal import temporal_data_context_middleware LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]] logger = logging.getLogger("govoplan.request") @@ -169,6 +170,7 @@ def create_govoplan_app( return response app.middleware("http")(conditional_json_get_middleware) + app.middleware("http")(temporal_data_context_middleware) origins = [item.strip() for item in cors_origins if item.strip()] if origins: diff --git a/src/govoplan_core/server/platform.py b/src/govoplan_core/server/platform.py index 3f3a163..0079577 100644 --- a/src/govoplan_core/server/platform.py +++ b/src/govoplan_core/server/platform.py @@ -139,6 +139,29 @@ def _frontend_view_surfaces(manifest: ModuleManifest) -> list[dict[str, object]] ] +def _documentation_help_contexts(manifest: ModuleManifest) -> list[dict[str, object]]: + contexts: list[dict[str, object]] = [] + seen: set[str] = set() + for topic in manifest.documentation: + raw_contexts = topic.metadata.get("help_contexts", ()) + if isinstance(raw_contexts, str) or not isinstance(raw_contexts, (list, tuple, set)): + continue + for raw_context in raw_contexts: + context_id = str(raw_context).strip() + if not context_id or context_id in seen: + continue + seen.add(context_id) + contexts.append( + { + "id": context_id, + "topic_id": topic.id, + "title": topic.title, + "documentation_types": list(topic.documentation_types), + } + ) + return contexts + + def _frontend_payload(manifest: ModuleManifest) -> dict[str, object] | None: frontend = manifest.frontend if frontend is None: @@ -240,6 +263,7 @@ def create_platform_router(settings: object | None = None) -> APIRouter: for key, value in manifest_interface_catalog(manifest).items() if key != "declarations" }, + "help_contexts": _documentation_help_contexts(manifest), "nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items], "frontend": _frontend_payload(manifest), } @@ -274,6 +298,7 @@ def create_platform_router(settings: object | None = None) -> APIRouter: "id": manifest.id, "name": manifest.name, "version": manifest.version, + "help_contexts": _documentation_help_contexts(manifest), "frontend": _public_frontend_payload(manifest.frontend), } for manifest in registry.manifests() diff --git a/src/govoplan_core/server/temporal.py b/src/govoplan_core/server/temporal.py new file mode 100644 index 0000000..5f36d2e --- /dev/null +++ b/src/govoplan_core/server/temporal.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from datetime import UTC, datetime + +from fastapi import Request, Response +from fastapi.responses import JSONResponse + +from govoplan_core.core.temporal import ( + RECORDED_AT_HEADER, + TEMPORAL_EVALUATED_AT_HEADER, + TEMPORAL_VARY_HEADERS, + VALIDITY_MODE_HEADER, + VALID_AT_HEADER, + TemporalContextError, + TemporalDataContext, + bind_temporal_data_context, + current_temporal_data_context, + parse_temporal_data_context, + reset_temporal_data_context, +) + + +async def temporal_data_context_middleware( + request: Request, + call_next: Callable[[Request], Awaitable[Response]], +) -> Response: + try: + context = parse_temporal_data_context( + validity_mode=request.headers.get(VALIDITY_MODE_HEADER), + valid_at=request.headers.get(VALID_AT_HEADER), + recorded_at=request.headers.get(RECORDED_AT_HEADER), + evaluated_at=datetime.now(UTC), + ) + except TemporalContextError as exc: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + request.state.govoplan_temporal_data_context = context + token = bind_temporal_data_context(context) + try: + response = await call_next(request) + finally: + reset_temporal_data_context(token) + + response.headers[VALIDITY_MODE_HEADER] = context.validity_mode + response.headers[TEMPORAL_EVALUATED_AT_HEADER] = _timestamp( + context.evaluated_at + ) + if context.valid_at is not None: + response.headers[VALID_AT_HEADER] = _timestamp(context.valid_at) + if context.recorded_at is not None: + response.headers[RECORDED_AT_HEADER] = _timestamp(context.recorded_at) + _merge_vary(response, TEMPORAL_VARY_HEADERS) + return response + + +def get_temporal_data_context(request: Request) -> TemporalDataContext: + context = getattr(request.state, "govoplan_temporal_data_context", None) + return context if isinstance(context, TemporalDataContext) else current_temporal_data_context() + + +def _merge_vary(response: Response, names: tuple[str, ...]) -> None: + current = { + item.strip().lower(): item.strip() + for item in response.headers.get("Vary", "").split(",") + if item.strip() + } + for name in names: + current.setdefault(name.lower(), name) + response.headers["Vary"] = ", ".join(current.values()) + + +def _timestamp(value: datetime) -> str: + return value.astimezone(UTC).isoformat().replace("+00:00", "Z") + + +__all__ = ["get_temporal_data_context", "temporal_data_context_middleware"] diff --git a/tests/test_conditional_requests.py b/tests/test_conditional_requests.py index 6a47dee..22a429c 100644 --- a/tests/test_conditional_requests.py +++ b/tests/test_conditional_requests.py @@ -48,6 +48,10 @@ class ConditionalRequestTests(unittest.TestCase): self.assertIn("private", first.headers.get("cache-control", "")) self.assertIn("no-cache", first.headers.get("cache-control", "")) self.assertIn("authorization", first.headers.get("vary", "").lower()) + self.assertIn( + "x-govoplan-validity-mode", + first.headers.get("vary", "").lower(), + ) self.assertEqual("request-1", first.headers["X-Correlation-ID"]) second = client.get("/json", headers={"If-None-Match": etag or "", "X-Request-ID": "request-2"}) @@ -56,6 +60,20 @@ class ConditionalRequestTests(unittest.TestCase): self.assertEqual(etag, second.headers.get("etag")) self.assertEqual("request-2", second.headers["X-Correlation-ID"]) + historical = client.get( + "/json", + headers={ + "X-Govoplan-Validity-Mode": "at", + "X-Govoplan-Valid-At": "2025-02-03T10:30:00Z", + }, + ) + self.assertEqual(200, historical.status_code, historical.text) + self.assertEqual("at", historical.headers["X-Govoplan-Validity-Mode"]) + self.assertIn( + "x-govoplan-valid-at", + historical.headers.get("vary", "").lower(), + ) + def test_changed_json_body_does_not_match_previous_etag(self) -> None: with self._client() as client: first = client.get("/json?value=alpha") diff --git a/tests/test_module_system.py b/tests/test_module_system.py index b79bfaa..b707610 100644 --- a/tests/test_module_system.py +++ b/tests/test_module_system.py @@ -106,7 +106,7 @@ from govoplan_core.core.module_package_catalog import ( sign_module_package_catalog, validate_module_package_catalog, ) -from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult, PublicFrontendRoute +from govoplan_core.core.modules import DocumentationTopic, FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult, PublicFrontendRoute from govoplan_core.core.module_guards import drop_table_retirement_provider from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest, PermissionDefinition, RoleTemplate from govoplan_core.core.registry import PlatformRegistry, RegistryError @@ -538,6 +538,15 @@ class ModuleSystemTests(unittest.TestCase): name="Example", version="test", public_tenant_resolver=lambda _request, _session: "tenant-1", + documentation=( + DocumentationTopic( + id="example.public-help", + title="Example public help", + summary="Help for the public example route.", + documentation_types=("user",), + metadata={"help_contexts": ["example.public"]}, + ), + ), frontend=FrontendModule( module_id="example", package_name="@govoplan/example-webui", @@ -572,6 +581,15 @@ class ModuleSystemTests(unittest.TestCase): self.assertEqual(["example"], [item["id"] for item in response.json()["modules"]]) public_module = response.json()["modules"][0] self.assertNotIn("dependencies", public_module) + self.assertEqual( + [{ + "id": "example.public", + "topic_id": "example.public-help", + "title": "Example public help", + "documentation_types": ["user"], + }], + public_module["help_contexts"], + ) self.assertNotIn("nav", public_module["frontend"]) self.assertNotIn("routes", public_module["frontend"]) self.assertEqual( @@ -3591,8 +3609,11 @@ finally: ) self.assertEqual("requires_review", modules["files"]["migration_safety"]) self.assertIn("migration", modules["files"]["migration_notes"].lower()) - self.assertEqual("0.1.9", modules["files"]["version"]) - self.assertIn("@v0.1.9", modules["files"]["python_ref"]) + files_version = importlib.import_module( + "govoplan_files.backend.manifest" + ).get_manifest().version + self.assertEqual(files_version, modules["files"]["version"]) + self.assertIn(f"@v{files_version}", modules["files"]["python_ref"]) def test_module_package_catalog_validates_remote_url_and_cache_fallback(self) -> None: root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-remote-", dir=_TEST_ROOT)) diff --git a/tests/test_temporal_context.py b/tests/test_temporal_context.py new file mode 100644 index 0000000..ffecc23 --- /dev/null +++ b/tests/test_temporal_context.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +import unittest + +from fastapi import APIRouter +from fastapi.testclient import TestClient + +from govoplan_core.core.registry import PlatformRegistry +from govoplan_core.core.temporal import ( + TemporalContextError, + current_temporal_data_context, + parse_temporal_data_context, + temporal_revision_matches, +) +from govoplan_core.server.fastapi import create_govoplan_app + + +NOW = datetime(2026, 8, 4, 12, 0, tzinfo=UTC) + + +class TemporalContextTests(unittest.TestCase): + def test_valid_and_recorded_time_are_independent(self) -> None: + context = parse_temporal_data_context( + validity_mode="at", + valid_at="2025-02-03T10:30:00+01:00", + recorded_at="2025-03-01T00:00:00Z", + evaluated_at=NOW, + ) + + self.assertEqual("at", context.validity_mode) + self.assertEqual(datetime(2025, 2, 3, 9, 30, tzinfo=UTC), context.valid_at) + self.assertEqual(datetime(2025, 3, 1, tzinfo=UTC), context.recorded_at) + self.assertFalse(context.is_default) + + def test_at_requires_zoned_valid_at_and_other_modes_reject_it(self) -> None: + with self.assertRaisesRegex(TemporalContextError, "requires valid_at"): + parse_temporal_data_context(validity_mode="at", evaluated_at=NOW) + with self.assertRaisesRegex(TemporalContextError, "include a timezone"): + parse_temporal_data_context( + validity_mode="at", + valid_at="2025-02-03T10:30:00", + evaluated_at=NOW, + ) + with self.assertRaisesRegex(TemporalContextError, "only permitted"): + parse_temporal_data_context( + validity_mode="all", + valid_at="2025-02-03T10:30:00Z", + evaluated_at=NOW, + ) + + def test_revision_matching_uses_half_open_valid_and_recorded_intervals(self) -> None: + context = parse_temporal_data_context( + validity_mode="at", + valid_at="2025-02-10T00:00:00Z", + recorded_at="2025-02-15T00:00:00Z", + evaluated_at=NOW, + ) + + self.assertTrue( + temporal_revision_matches( + context, + valid_from=datetime(2025, 2, 1, tzinfo=UTC), + valid_to=datetime(2025, 3, 1, tzinfo=UTC), + revision_recorded_at=datetime(2025, 2, 5, tzinfo=UTC), + superseded_at=datetime(2025, 2, 16, tzinfo=UTC), + ) + ) + self.assertFalse( + temporal_revision_matches( + context, + valid_from=datetime(2025, 2, 1, tzinfo=UTC), + valid_to=datetime(2025, 2, 10, tzinfo=UTC), + revision_recorded_at=datetime(2025, 2, 5, tzinfo=UTC), + ) + ) + self.assertFalse( + temporal_revision_matches( + context, + valid_from=datetime(2025, 2, 1, tzinfo=UTC), + revision_recorded_at=datetime(2025, 2, 15, tzinfo=UTC) + + timedelta(microseconds=1), + ) + ) + + def test_request_headers_bind_context_and_invalid_headers_fail_closed(self) -> None: + router = APIRouter() + + @router.get("/temporal") + def temporal_payload() -> dict[str, str | None]: + return current_temporal_data_context().to_dict() + + app = create_govoplan_app( + title="temporal context test", + version="test", + registry=PlatformRegistry(), + api_router=router, + ) + with TestClient(app) as client: + response = client.get( + "/temporal", + headers={ + "X-Govoplan-Validity-Mode": "at", + "X-Govoplan-Valid-At": "2025-02-03T10:30:00Z", + "X-Govoplan-Recorded-At": "2025-03-01T00:00:00Z", + }, + ) + self.assertEqual(200, response.status_code, response.text) + self.assertEqual("at", response.json()["validity_mode"]) + self.assertEqual("2025-02-03T10:30:00Z", response.json()["valid_at"]) + self.assertEqual("at", response.headers["X-Govoplan-Validity-Mode"]) + self.assertIn( + "x-govoplan-valid-at", + response.headers.get("vary", "").lower(), + ) + + invalid = client.get( + "/temporal", + headers={"X-Govoplan-Validity-Mode": "at"}, + ) + self.assertEqual(400, invalid.status_code, invalid.text) + self.assertIn("requires valid_at", invalid.json()["detail"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/scripts/test-core-interface-patterns.mjs b/webui/scripts/test-core-interface-patterns.mjs index d28175c..b75558a 100644 --- a/webui/scripts/test-core-interface-patterns.mjs +++ b/webui/scripts/test-core-interface-patterns.mjs @@ -12,7 +12,11 @@ const credentials = read("src/components/CredentialEnvelopeManager.tsx"); const iconRail = read("src/layout/IconRail.tsx"); const moduleLoadBoundary = read("src/components/ModuleLoadBoundary.tsx"); const titlebar = read("src/layout/Titlebar.tsx"); +const temporalDataMenu = read("src/layout/TemporalDataMenu.tsx"); +const helpMenu = read("src/layout/HelpMenu.tsx"); +const helpContext = read("src/utils/helpContext.ts"); const layoutStyles = read("src/styles/layout.css"); +const authGateStyles = read("src/styles/auth-gate.css"); assert.match(settings, /contextId: "core\.settings"/, "settings expose stable contextual documentation"); assert.match(settings, /There are no unsaved profile changes\./, "profile save explains its clean state"); @@ -33,10 +37,19 @@ assert.match(layoutStyles, /\.icon-rail-scroll \{[^}]*min-height: 0;[^}]*flex: 1 assert.match(layoutStyles, /\.icon-rail-header \{[^}]*flex: 0 0 auto;/, "the rail logo remains fixed"); assert.match(layoutStyles, /\.icon-rail-bottom \{[^}]*flex: 0 0 auto;/, "the rail utility controls remain fixed"); -assert.match(titlebar, /className="titlebar-status-pattern"/, "shell state uses a titlebar background pattern"); -assert.match(titlebar, /className="titlebar-global-search"/, "global search retains its dedicated titlebar grid cell"); +assert.doesNotMatch(titlebar, /titlebar-status-pattern|TriangleAlert|WifiOff/, "shell state uses the readable text warnings rather than icon or background decoration"); +assert.doesNotMatch(titlebar, /titlebar-global-search|has-global-search/, "global search no longer reserves a centered titlebar grid cell"); +assert.match(titlebar, /[\s\S]*/, "search is the first command before language, View, and temporal selectors"); assert.match(titlebar, /className="account-pill"[\s\S]*aria-label=\{displayUserName\}[\s\S]*aria-haspopup="menu"/, "the compact account menu retains an accessible name and menu state"); -assert.doesNotMatch(layoutStyles, /\.maintenance-topbar-link[^}]*position: absolute;/, "maintenance state does not occupy the centered search position"); +assert.match(temporalDataMenu, /Calendar, CalendarOff/, "the temporal selector distinguishes bounded and all-validity modes"); +assert.match(temporalDataMenu, /useUnsavedChanges[\s\S]*requestNavigation/, "changing temporal context cannot silently discard a dirty page"); +assert.match(temporalDataMenu, /recordedAt/, "valid time and recorded time remain independently selectable"); +assert.match(layoutStyles, /\.maintenance-topbar-link,[\s\S]*\.backend-offline-topbar-alert \{[^}]*top: 50%;[^}]*transform: translate\(-50%, -50%\);/, "maintenance and offline warnings use their established centered titlebar placement"); +assert.match(authGateStyles, /\.titlebar-link,[\s\S]*\.account-pill \{[^}]*margin: 0;/, "text and account titlebar controls use the same action spacing as icon controls"); +assert.match(helpMenu, /helpContextForTarget\(routeHelpContext, event\.target, modules\)/, "F1 resolves the currently focused interface item"); +assert.match(helpContext, /function moduleRouteContext/, "context help covers contributed module routes"); +assert.match(helpContext, /function sectionContext/, "context help covers contributed administration and settings sections"); +assert.match(helpContext, /data-help-context-id/, "context help honors explicit control metadata"); assert.match(layoutStyles, /@media \(max-width: 600px\)[\s\S]*\.app-main \{[\s\S]*grid-template-rows: 104px 51px minmax\(0, 1fr\);/, "the narrow shell reserves two non-overlapping titlebar rows"); assert.match(layoutStyles, /@media \(max-width: 600px\)[\s\S]*\.titlebar-context-selectors \{[\s\S]*overflow-x: auto;/, "narrow context selectors remain reachable without covering titlebar actions"); assert.match(layoutStyles, /@media \(max-width: 600px\)[\s\S]*\.account-pill span \{[\s\S]*display: none;/, "narrow account controls retain the icon while removing collision-prone text"); diff --git a/webui/src/App.tsx b/webui/src/App.tsx index ca741f8..d193313 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -11,6 +11,8 @@ import { PermissionBoundary } from "./components/AccessBoundary"; import { firstAccessibleRoute, loadInstalledPublicWebModules, loadInstalledWebModules, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, routeContributionsForModules, uiCapability } from "./platform/modules"; import { PlatformModulesProvider } from "./platform/ModuleContext"; import { PlatformViewProvider } from "./platform/ViewContext"; +import { PlatformTemporalProvider } from "./platform/TemporalContext"; +import { PLATFORM_TEMPORAL_CONTEXT_CHANGED_EVENT } from "./platform/temporal"; import { PLATFORM_VIEW_CHANGED_EVENT, PLATFORM_WORKFLOW_VIEW_CHANGED_EVENT, @@ -53,6 +55,7 @@ export default function App() { const [reloginMessage, setReloginMessage] = useState(""); const [baseViewProjection, setBaseViewProjection] = useState(null); const [workflowViewProjection, setWorkflowViewProjection] = useState(null); + const [temporalRevision, setTemporalRevision] = useState(0); const viewProjection = workflowViewProjection ?? baseViewProjection; const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]); @@ -71,6 +74,20 @@ export default function App() { const moduleTranslations = useMemo(() => contextModules.map((module) => module.translations).filter(Boolean), [contextModules]); const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]); + useEffect(() => { + function reloadTemporalData() { + setTemporalRevision((current) => current + 1); + } + window.addEventListener( + PLATFORM_TEMPORAL_CONTEXT_CHANGED_EVENT, + reloadTemporalData + ); + return () => window.removeEventListener( + PLATFORM_TEMPORAL_CONTEXT_CHANGED_EVENT, + reloadTemporalData + ); + }, []); + useEffect(() => { if (!auth || !viewsRuntime) { setBaseViewProjection(null); @@ -518,12 +535,13 @@ export default function App() { onLanguageChange={persistLanguagePreference} moduleTranslations={moduleTranslations}> + - - + + } /> {!dashboardModuleInstalled && } />} {publicRoutes.map((route) => @@ -567,6 +585,7 @@ export default function App() { + ); diff --git a/webui/src/api/client.ts b/webui/src/api/client.ts index aa7dce7..a132289 100644 --- a/webui/src/api/client.ts +++ b/webui/src/api/client.ts @@ -1,4 +1,5 @@ import type { ApiSettings } from "../types"; +import { temporalRequestHeaders } from "../platform/temporal"; const STORAGE_KEY = "govoplan.apiSettings"; const LEGACY_STORAGE_KEYS: string[] = []; @@ -341,6 +342,9 @@ export async function apiFetch(settings: ApiSettings, path: string, init?: Re for (const [key, value] of authHeaders(settings)) { headers.set(key, value); } + for (const [key, value] of Object.entries(temporalRequestHeaders())) { + if (!headers.has(key)) headers.set(key, value); + } const csrf = csrfToken(); if (csrf && isUnsafeMethod(method) && !headers.has("X-CSRF-Token")) { @@ -433,7 +437,11 @@ export async function apiFetch(settings: ApiSettings, path: string, init?: Re export async function apiDownload(settings: ApiSettings, path: string, filename: string): Promise { - const response = await fetch(apiUrl(settings, path), { headers: authHeaders(settings), credentials: "include" }); + const headers = authHeaders(settings); + for (const [key, value] of Object.entries(temporalRequestHeaders())) { + headers.set(key, value); + } + const response = await fetch(apiUrl(settings, path), { headers, credentials: "include" }); if (!response.ok) { const text = await response.text(); if (response.status === 401 && shouldNotifyAuthRequired(path)) { diff --git a/webui/src/components/Button.tsx b/webui/src/components/Button.tsx index 63ca832..9a6ed5a 100644 --- a/webui/src/components/Button.tsx +++ b/webui/src/components/Button.tsx @@ -7,7 +7,20 @@ export type ButtonProps = ButtonHTMLAttributes & PlatformInte disabledReason?: ReactNode; }; -export default function Button({ variant = "secondary", className = "", disabledReason, disabled, interfaceId, helpTopicId, ...props }: ButtonProps) { - const button = + ); return {button}; } diff --git a/webui/src/components/Card.tsx b/webui/src/components/Card.tsx index 545975d..0f07a72 100644 --- a/webui/src/components/Card.tsx +++ b/webui/src/components/Card.tsx @@ -1,8 +1,9 @@ import { useEffect, useState, type ReactNode } from "react"; import { ChevronDown } from "lucide-react"; import { usePlatformLanguage } from "../i18n/LanguageContext"; +import type { PlatformInterfaceIdentityProps } from "../types"; -type CardProps = { +type CardProps = PlatformInterfaceIdentityProps & { title?: ReactNode; children: ReactNode; actions?: ReactNode; @@ -38,7 +39,7 @@ function writeCollapseState(storageKey: string | null, collapsed: boolean): void // localStorage may be unavailable in private or restricted contexts. }} -export default function Card({ title, children, actions, collapsible = false, collapseKey, persistCollapse = true }: CardProps) { +export default function Card({ title, children, actions, collapsible = false, collapseKey, persistCollapse = true, interfaceId, helpContextId, helpTopicId }: CardProps) { const { translateText } = usePlatformLanguage(); const storageKey = resolveCollapseStorageKey(collapsible, persistCollapse, collapseKey, title); const [collapseState, setCollapseState] = useState(() => ({ storageKey, collapsed: readCollapseState(storageKey) })); @@ -59,7 +60,14 @@ export default function Card({ title, children, actions, collapsible = false, co } return ( -
+
{hasHeader &&
{title && (typeof title === "string" ?

{translateText(title)}

:
{title}
)} @@ -85,4 +93,4 @@ export default function Card({ title, children, actions, collapsible = false, co {shouldRenderBody && (collapsible ?
{body}
: body)}
); -} \ No newline at end of file +} diff --git a/webui/src/components/DateTimeField.tsx b/webui/src/components/DateTimeField.tsx index ff67e51..8de6704 100644 --- a/webui/src/components/DateTimeField.tsx +++ b/webui/src/components/DateTimeField.tsx @@ -50,7 +50,7 @@ function combineDateTime(date: string, time: string): string { return `${date || dateString(new Date())}T${time || "00:00"}`; } -export function DateField({ value, onChange, min, max, disabled, className = "", placeholder = "i18n:govoplan-core.yyyy_mm_dd.d3f8f7b8", interfaceId, helpTopicId, ...props }: BaseProps) { +export function DateField({ value, onChange, min, max, disabled, className = "", placeholder = "i18n:govoplan-core.yyyy_mm_dd.d3f8f7b8", interfaceId, helpContextId, helpTopicId, ...props }: BaseProps) { const selectedDate = parseDate(value); const [open, setOpen] = useState(false); const [visibleMonth, setVisibleMonth] = useState(() => selectedDate ?? new Date()); @@ -94,7 +94,15 @@ export function DateField({ value, onChange, min, max, disabled, className = "", } return ( -
+
(null); useEffect(() => { const input = inputRef.current; @@ -159,7 +167,14 @@ export function TimeField({ value, onChange, min, max, className = "", placehold }, [value, min, max]); return ( -
+
+
diff --git a/webui/src/components/FormField.tsx b/webui/src/components/FormField.tsx index 834365f..fd87bc4 100644 --- a/webui/src/components/FormField.tsx +++ b/webui/src/components/FormField.tsx @@ -12,11 +12,19 @@ type FormFieldProps = PlatformInterfaceIdentityProps & { children: ReactNode; }; -export default function FormField({ label, help, documentation, children, interfaceId, helpTopicId }: FormFieldProps) { +export default function FormField({ label, help, documentation, children, interfaceId, helpContextId, helpTopicId }: FormFieldProps) { const { translateText } = usePlatformLanguage(); const renderedLabel = typeof label === "string" ? translateText(label) : label; return ( -