feat(views): add governed DSAR coverage

This commit is contained in:
2026-08-21 04:02:21 +02:00
parent 24dca43e2e
commit 266f5dae9b
3 changed files with 1444 additions and 3 deletions
+834
View File
@@ -0,0 +1,834 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_views.backend.db.models import (
ViewAssignment,
ViewDefinition,
ViewPreference,
ViewRevision,
)
VIEWS_DSAR_CAPABILITY = dsar_capability_name("views")
_MAX_RECORDS = 5_000
_MAX_REVISIONS_PER_DEFINITION = 1_000
_MAX_PERSONAL_REVISIONS = 5_000
_CONFLICT = object()
_ATTRIBUTION_TYPES = frozenset(
{
"view_definition_attribution",
"view_revision_attribution",
"view_assignment_attribution",
}
)
_PERSONAL_TYPES = frozenset(
{
"personal_view_assignment",
"personal_view_preference",
"personal_view_definition",
}
)
@dataclass(frozen=True, slots=True)
class _SubjectSelectors:
account_id: str
definition_id: str | None
assignment_id: str | None
preference_id: str | None
class ViewsDsarProvider:
provider_id = "views"
module_id = "views"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
return ()
records: list[DsarRecordRef] = []
personal_revision_count = 0
if selectors.preference_id is None and selectors.assignment_id is None:
definitions = db.query(ViewDefinition).filter(
ViewDefinition.tenant_id == tenant_id,
ViewDefinition.scope_type == "user",
ViewDefinition.scope_id == selectors.account_id,
)
if selectors.definition_id:
definitions = definitions.filter(
ViewDefinition.id == selectors.definition_id
)
for definition in _limited(definitions, label="personal definitions"):
record = _personal_definition_record(db, definition)
revisions = record.data["revisions"]
personal_revision_count += len(revisions) # type: ignore[arg-type]
if personal_revision_count > _MAX_PERSONAL_REVISIONS:
raise ValueError(
"Views DSAR personal revision limit exceeded; narrow the selectors."
)
records.append(record)
if selectors.preference_id is None:
assignments = db.query(ViewAssignment).filter(
ViewAssignment.tenant_id == tenant_id,
ViewAssignment.scope_type == "user",
ViewAssignment.scope_id == selectors.account_id,
)
if selectors.assignment_id:
assignments = assignments.filter(
ViewAssignment.id == selectors.assignment_id
)
if selectors.definition_id:
assignments = assignments.filter(
ViewAssignment.definition_id == selectors.definition_id
)
for assignment in _limited(assignments, label="personal assignments"):
records.append(_personal_assignment_record(assignment))
if selectors.assignment_id is None:
preferences = db.query(ViewPreference).filter(
ViewPreference.tenant_id == tenant_id,
ViewPreference.account_id == selectors.account_id,
)
if selectors.preference_id:
preferences = preferences.filter(
ViewPreference.id == selectors.preference_id
)
if selectors.definition_id:
preferences = preferences.filter(
ViewPreference.view_id == selectors.definition_id
)
for preference in _limited(preferences, label="personal preferences"):
records.append(_personal_preference_record(preference))
if selectors.preference_id is None and selectors.assignment_id is None:
definitions = db.query(ViewDefinition).filter(
ViewDefinition.tenant_id == tenant_id,
or_(
ViewDefinition.scope_type != "user",
ViewDefinition.scope_id != selectors.account_id,
),
or_(
ViewDefinition.created_by == selectors.account_id,
ViewDefinition.updated_by == selectors.account_id,
),
)
if selectors.definition_id:
definitions = definitions.filter(
ViewDefinition.id == selectors.definition_id
)
for definition in _limited(
definitions,
label="definition attribution",
):
records.append(
_definition_attribution_record(definition, selectors.account_id)
)
revisions = (
db.query(ViewRevision)
.join(ViewDefinition, ViewRevision.definition_id == ViewDefinition.id)
.filter(
ViewRevision.tenant_id == tenant_id,
ViewRevision.created_by == selectors.account_id,
or_(
ViewDefinition.scope_type != "user",
ViewDefinition.scope_id != selectors.account_id,
),
)
)
if selectors.definition_id:
revisions = revisions.filter(
ViewRevision.definition_id == selectors.definition_id
)
for revision in _limited(revisions, label="revision attribution"):
records.append(_revision_attribution_record(revision))
if selectors.preference_id is None:
assignments = db.query(ViewAssignment).filter(
ViewAssignment.tenant_id == tenant_id,
or_(
ViewAssignment.scope_type != "user",
ViewAssignment.scope_id != selectors.account_id,
),
or_(
ViewAssignment.created_by == selectors.account_id,
ViewAssignment.updated_by == selectors.account_id,
),
)
if selectors.assignment_id:
assignments = assignments.filter(
ViewAssignment.id == selectors.assignment_id
)
if selectors.definition_id:
assignments = assignments.filter(
ViewAssignment.definition_id == selectors.definition_id
)
for assignment in _limited(
assignments,
label="assignment attribution",
):
records.append(
_assignment_attribution_record(assignment, selectors.account_id)
)
if len(records) > _MAX_RECORDS:
raise ValueError("Views DSAR result limit exceeded; narrow the selectors.")
order = {
"personal_view_assignment": 10,
"personal_view_preference": 20,
"personal_view_definition": 30,
"view_assignment_attribution": 40,
"view_definition_attribution": 50,
"view_revision_attribution": 60,
}
return tuple(
sorted(
records,
key=lambda item: (order[item.resource_type], item.resource_id),
)
)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
raise ValueError("Views DSAR requires one corroborated account.")
actions: list[DsarErasureActionRef] = []
for record in records:
_validate_record(record)
if record.resource_type in _ATTRIBUTION_TYPES:
actions.append(_retain_action(record))
continue
row = _personal_row(
db,
tenant_id=tenant_id,
account_id=selectors.account_id,
resource_type=record.resource_type,
resource_id=record.resource_id,
)
if row is None or not _row_matches_selectors(row, selectors):
actions.append(_manual_action(record, "ownership or selector mismatch"))
continue
if isinstance(row, ViewDefinition) and _has_foreign_dependents(
db,
row,
account_id=selectors.account_id,
):
actions.append(
_manual_action(
record,
"the personal definition has assignments or selections owned by another account",
)
)
continue
version = _row_version(row)
actions.append(
DsarErasureActionRef(
action_id=(
f"views:delete:{record.resource_type}:{record.resource_id}:"
f"{version['token']}"
),
provider_id=self.provider_id,
module_id=self.module_id,
kind="delete",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"Delete {record.title}",
rationale=(
"The record is an exact account-owned presentation "
"preference; deleting it does not change domain data."
),
executable=True,
irreversible=True,
metadata={"account_id": selectors.account_id, **version},
)
)
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
raise ValueError("Views DSAR requires one corroborated account.")
results: list[DsarExecutionResultRef] = []
for action in actions:
_validate_action(action)
if not action.executable or action.kind != "delete":
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"Institutional View configuration attribution remains "
"accountability evidence."
),
)
)
continue
if action.resource_type not in _PERSONAL_TYPES:
raise ValueError("Unsupported executable Views DSAR action.")
row = _personal_row(
db,
tenant_id=tenant_id,
account_id=selectors.account_id,
resource_type=action.resource_type,
resource_id=action.resource_id,
)
if row is None:
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="unchanged",
summary="The personal View record was already absent.",
evidence={"request_id": request_id},
)
)
continue
if str(
action.metadata.get("account_id") or ""
) != selectors.account_id or not _row_matches_selectors(row, selectors):
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"The View record does not match the corroborated "
"account and resource selectors."
),
)
)
continue
if not _version_matches(row, action.metadata):
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"The View record changed after the erasure plan; "
"create a new plan before deleting it."
),
)
)
continue
if isinstance(row, ViewDefinition) and _has_foreign_dependents(
db,
row,
account_id=selectors.account_id,
):
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"The personal View now affects another account; "
"review its assignments and selections manually."
),
)
)
continue
resource_id = str(row.id)
db.delete(row)
db.flush()
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="executed",
summary=(
"Deleted the personal View record without changing any "
"institutional View or domain data."
),
evidence={
"request_id": request_id,
"resource_type": action.resource_type,
"resource_id": resource_id,
},
)
)
return tuple(results)
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
references = subject.external_references
values = {
"account_id": _coalesce(
subject.account_id,
references.get("views.account"),
references.get("access.account"),
),
"definition_id": _coalesce(
references.get("views.definition"),
references.get("views.view"),
),
"assignment_id": _coalesce(
references.get("views.assignment"),
references.get("views.assignment_id"),
),
"preference_id": _coalesce(
references.get("views.preference"),
references.get("views.preference_id"),
),
}
if any(value is _CONFLICT for value in values.values()):
return None
account_id = _optional_string(values["account_id"])
if account_id is None:
return None
return _SubjectSelectors(
account_id=account_id,
definition_id=_optional_string(values["definition_id"]),
assignment_id=_optional_string(values["assignment_id"]),
preference_id=_optional_string(values["preference_id"]),
)
def _coalesce(*values: str | None) -> str | None | object:
normalized = {str(value).strip() for value in values if str(value or "").strip()}
if len(normalized) > 1:
return _CONFLICT
return next(iter(normalized), None)
def _optional_string(value: object) -> str | None:
return value if isinstance(value, str) and value else None
def _limited(query, *, label: str):
rows = (
query.order_by(query.column_descriptions[0]["entity"].id)
.limit(_MAX_RECORDS + 1)
.all()
)
if len(rows) > _MAX_RECORDS:
raise ValueError(f"Views DSAR {label} limit exceeded; narrow the selectors.")
return rows
def _personal_definition_record(
session: Session,
definition: ViewDefinition,
) -> DsarRecordRef:
revisions = (
session.query(ViewRevision)
.filter(ViewRevision.definition_id == definition.id)
.order_by(ViewRevision.revision, ViewRevision.id)
.limit(_MAX_REVISIONS_PER_DEFINITION + 1)
.all()
)
if len(revisions) > _MAX_REVISIONS_PER_DEFINITION:
raise ValueError(
"Views DSAR personal definition revision limit exceeded; narrow the selectors."
)
return DsarRecordRef(
provider_id="views",
module_id="views",
resource_type="personal_view_definition",
resource_id=definition.id,
category="personal_interface_preference",
title=f"Personal View: {definition.name[:200]}",
data={
"definition_key": definition.definition_key,
"name": definition.name[:200],
"description": (definition.description or "")[:2_000] or None,
"status": definition.status,
"current_revision": definition.current_revision,
"published_revision_id": definition.published_revision_id,
"deleted_at": _iso(definition.deleted_at),
"revisions": [
{
"id": revision.id,
"revision": revision.revision,
"surface_contract_version": revision.surface_contract_version,
"visible_surface_ids": _string_list(
revision.visible_surface_ids,
limit=1_000,
),
"presentation": _presentation_projection(revision.presentation),
"created_at": _iso(revision.created_at),
}
for revision in revisions
],
},
observed_at=_aware(definition.updated_at),
)
def _personal_assignment_record(assignment: ViewAssignment) -> DsarRecordRef:
return DsarRecordRef(
provider_id="views",
module_id="views",
resource_type="personal_view_assignment",
resource_id=assignment.id,
category="personal_interface_preference",
title="Personal View assignment",
data={
"definition_id": assignment.definition_id,
"revision_id": assignment.revision_id,
"mode": assignment.mode,
"priority": assignment.priority,
"is_active": assignment.is_active,
},
observed_at=_aware(assignment.updated_at),
)
def _personal_preference_record(preference: ViewPreference) -> DsarRecordRef:
return DsarRecordRef(
provider_id="views",
module_id="views",
resource_type="personal_view_preference",
resource_id=preference.id,
category="personal_interface_preference",
title="Personal active-View selection",
data={
"selection_kind": preference.selection_kind,
"view_id": preference.view_id,
},
observed_at=_aware(preference.updated_at),
)
def _definition_attribution_record(
definition: ViewDefinition,
account_id: str,
) -> DsarRecordRef:
activities = []
if definition.created_by == account_id:
activities.append("created_view_definition")
if definition.updated_by == account_id:
activities.append("updated_view_definition")
return _attribution_record(
"view_definition_attribution",
definition.id,
"View-definition attribution",
{
"activities": activities,
"scope_type": definition.scope_type,
"current_revision": definition.current_revision,
"status": definition.status,
},
observed_at=definition.updated_at,
)
def _revision_attribution_record(revision: ViewRevision) -> DsarRecordRef:
return _attribution_record(
"view_revision_attribution",
revision.id,
"View-revision attribution",
{
"activity": "created_view_revision",
"definition_id": revision.definition_id,
"revision": revision.revision,
"surface_contract_version": revision.surface_contract_version,
},
observed_at=revision.created_at,
)
def _assignment_attribution_record(
assignment: ViewAssignment,
account_id: str,
) -> DsarRecordRef:
activities = []
if assignment.created_by == account_id:
activities.append("created_view_assignment")
if assignment.updated_by == account_id:
activities.append("updated_view_assignment")
return _attribution_record(
"view_assignment_attribution",
assignment.id,
"View-assignment attribution",
{
"activities": activities,
"scope_type": assignment.scope_type,
"definition_id": assignment.definition_id,
"mode": assignment.mode,
"is_active": assignment.is_active,
},
observed_at=assignment.updated_at,
)
def _attribution_record(
resource_type: str,
resource_id: str,
title: str,
data: Mapping[str, object],
*,
observed_at: datetime | None,
) -> DsarRecordRef:
return DsarRecordRef(
provider_id="views",
module_id="views",
resource_type=resource_type,
resource_id=resource_id,
category="operator_accountability_evidence",
title=title,
data=data,
observed_at=_aware(observed_at),
immutable_evidence=True,
retention_reason=(
"Institutional View configuration attribution is retained for "
"accountability; definition, presentation, and metadata payloads are excluded."
),
)
def _personal_row(
session: Session,
*,
tenant_id: str,
account_id: str,
resource_type: str,
resource_id: str,
) -> ViewAssignment | ViewPreference | ViewDefinition | None:
if resource_type == "personal_view_assignment":
return (
session.query(ViewAssignment)
.filter(
ViewAssignment.id == resource_id,
ViewAssignment.tenant_id == tenant_id,
ViewAssignment.scope_type == "user",
ViewAssignment.scope_id == account_id,
)
.one_or_none()
)
if resource_type == "personal_view_preference":
return (
session.query(ViewPreference)
.filter(
ViewPreference.id == resource_id,
ViewPreference.tenant_id == tenant_id,
ViewPreference.account_id == account_id,
)
.one_or_none()
)
if resource_type == "personal_view_definition":
return (
session.query(ViewDefinition)
.filter(
ViewDefinition.id == resource_id,
ViewDefinition.tenant_id == tenant_id,
ViewDefinition.scope_type == "user",
ViewDefinition.scope_id == account_id,
)
.one_or_none()
)
raise ValueError("Unsupported personal Views DSAR record type.")
def _row_matches_selectors(
row: ViewAssignment | ViewPreference | ViewDefinition,
selectors: _SubjectSelectors,
) -> bool:
if selectors.assignment_id and (
not isinstance(row, ViewAssignment) or row.id != selectors.assignment_id
):
return False
if selectors.preference_id and (
not isinstance(row, ViewPreference) or row.id != selectors.preference_id
):
return False
if selectors.definition_id:
if isinstance(row, ViewDefinition):
return row.id == selectors.definition_id
return (
row.view_id == selectors.definition_id
if isinstance(row, ViewPreference)
else row.definition_id == selectors.definition_id
)
return True
def _has_foreign_dependents(
session: Session,
definition: ViewDefinition,
*,
account_id: str,
) -> bool:
foreign_assignment = (
session.query(ViewAssignment.id)
.filter(
ViewAssignment.definition_id == definition.id,
or_(
ViewAssignment.tenant_id != definition.tenant_id,
ViewAssignment.scope_type != "user",
ViewAssignment.scope_id != account_id,
),
)
.first()
)
foreign_preference = (
session.query(ViewPreference.id)
.filter(
ViewPreference.view_id == definition.id,
or_(
ViewPreference.tenant_id != definition.tenant_id,
ViewPreference.account_id != account_id,
),
)
.first()
)
return foreign_assignment is not None or foreign_preference is not None
def _row_version(
row: ViewAssignment | ViewPreference | ViewDefinition,
) -> dict[str, object]:
updated_at = _iso(row.updated_at)
if isinstance(row, ViewDefinition):
return {
"token": f"r{row.current_revision}",
"current_revision": row.current_revision,
"updated_at": updated_at,
}
return {"token": "timestamp", "updated_at": updated_at}
def _version_matches(
row: ViewAssignment | ViewPreference | ViewDefinition,
metadata: Mapping[str, object],
) -> bool:
if metadata.get("updated_at") != _iso(row.updated_at):
return False
if isinstance(row, ViewDefinition):
return metadata.get("current_revision") == row.current_revision
return True
def _presentation_projection(value: object) -> dict[str, object]:
if not isinstance(value, Mapping):
raise ValueError("View presentation must be an object.")
projected: dict[str, object] = {}
mode = value.get("navigation_mode")
if isinstance(mode, str):
projected["navigation_mode"] = mode[:20]
for key in (
"product_area_order",
"quick_access_recommended_tool_ids",
"quick_access_focused_tool_ids",
):
raw = value.get(key)
if raw is not None:
projected[key] = _string_list(raw, limit=100)
labels = value.get("product_area_labels")
if labels is not None:
if not isinstance(labels, Mapping) or len(labels) > 100:
raise ValueError("View presentation labels exceed the DSAR bound.")
projected["product_area_labels"] = {
str(key)[:80]: str(label)[:200] for key, label in labels.items()
}
return projected
def _string_list(value: object, *, limit: int) -> list[str]:
if not isinstance(value, list) or len(value) > limit:
raise ValueError("View list payload exceeds the DSAR bound.")
return [str(item)[:255] for item in value]
def _retain_action(record: DsarRecordRef) -> DsarErasureActionRef:
return DsarErasureActionRef(
action_id=f"views:retain:{record.resource_type}:{record.resource_id}",
provider_id="views",
module_id="views",
kind="retain",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"Retain {record.title}",
rationale=record.retention_reason
or "Institutional View attribution is accountability evidence.",
executable=False,
)
def _manual_action(record: DsarRecordRef, reason: str) -> DsarErasureActionRef:
return DsarErasureActionRef(
action_id=f"views:manual_review:{record.resource_type}:{record.resource_id}",
provider_id="views",
module_id="views",
kind="manual_review",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"Review {record.title}",
rationale=f"Automatic deletion stopped because of {reason}.",
executable=False,
)
def _iso(value: datetime | None) -> str | None:
aware = _aware(value)
return aware.isoformat() if aware else None
def _aware(value: datetime | None) -> datetime | None:
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Views DSAR requires a SQLAlchemy Session.")
return value
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "views" or record.module_id != "views":
raise ValueError("Views DSAR cannot plan a foreign provider record.")
if record.resource_type not in _PERSONAL_TYPES | _ATTRIBUTION_TYPES:
raise ValueError("Views DSAR record type is invalid.")
if not record.resource_id:
raise ValueError("Views DSAR record identity is incomplete.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "views" or action.module_id != "views":
raise ValueError("Views DSAR cannot execute a foreign provider action.")
if not action.action_id.startswith("views:"):
raise ValueError("Views DSAR action identity is invalid.")
__all__ = ["VIEWS_DSAR_CAPABILITY", "ViewsDsarProvider"]
+88 -3
View File
@@ -11,6 +11,7 @@ from govoplan_core.core.module_guards import (
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationLink,
DocumentationTopic,
FrontendModule,
@@ -26,6 +27,10 @@ from govoplan_core.core.policy import CAPABILITY_POLICY_IMPACT_SUBJECT_PREFIX
from govoplan_core.core.views import CAPABILITY_VIEWS_RESOLVER, ViewSurface
from govoplan_core.db.base import Base
from govoplan_views.backend.db import models as view_models
from govoplan_views.backend.dsar_provider import (
VIEWS_DSAR_CAPABILITY,
ViewsDsarProvider,
)
MODULE_ID = "views"
@@ -216,6 +221,10 @@ def _policy_impact_subjects(context: ModuleContext):
return ViewsPolicyImpactSubjectProvider(context.registry)
def _dsar_provider(_context: ModuleContext) -> ViewsDsarProvider:
return ViewsDsarProvider()
manifest = ModuleManifest(
id=MODULE_ID,
name=MODULE_NAME,
@@ -228,6 +237,7 @@ manifest = ModuleManifest(
provides_interfaces=(
ModuleInterfaceProvider(name="views.surface_contract", version="1.0.0"),
ModuleInterfaceProvider(name="views.resolver", version="0.1.0"),
ModuleInterfaceProvider(name=VIEWS_DSAR_CAPABILITY, version="0.1.0"),
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
@@ -298,8 +308,71 @@ manifest = ModuleManifest(
capability_factories={
CAPABILITY_VIEWS_RESOLVER: _resolver,
f"{CAPABILITY_POLICY_IMPACT_SUBJECT_PREFIX}views": _policy_impact_subjects,
VIEWS_DSAR_CAPABILITY: _dsar_provider,
},
capability_documentation={
VIEWS_DSAR_CAPABILITY: CapabilityDocumentation(
label="Views data-subject request provider",
summary=(
"Exports and deletes account-owned View preferences while "
"retaining minimized institutional configuration attribution."
),
contract_version="0.1.0",
),
},
documentation=(
DocumentationTopic(
id="views.data-subject-requests",
title="Views data-subject requests",
summary=(
"Export or delete personal Views, assignments, and selections "
"without changing institutional projections or domain data."
),
body=(
"Views correlates one exact account in the active tenant and can "
"narrow the request to a definition, assignment, or preference. "
"The access package includes bounded personal definition and "
"revision presentation, user assignments, and the active-View "
"selection. Arbitrary assignment metadata is excluded and View "
"surfaces are identifiers only; the provider never traverses them "
"into feature data. Tenant and group configuration authored or "
"updated by the subject contributes minimized attribution only and "
"is retained as institutional accountability evidence. System-wide "
"Views are outside tenant-scoped requests. Erasure can delete an "
"exact personal selection, user assignment, or user-owned definition "
"after timestamp, revision, ownership, and dependent-record checks. "
"A personal definition that affects another account is sent to "
"manual review. Deleting a selection or assignment never deletes "
"the referenced institutional View. Repeated execution is unchanged."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "tenant_admin", "operator", "auditor"),
related_modules=("core", "access", "quick_access"),
metadata={
"help_contexts": [
"views.selector",
"views.settings.personal",
"views.admin.tenant",
"privacy.data-subject-requests",
],
"consequence_classes": {
"delete_selection": (
"Removes the account selection; effective defaults apply again."
),
"delete_assignment": (
"Removes only the personal assignment, not its View definition."
),
"delete_personal_definition": (
"Removes the personal definition and revisions only when no "
"other account depends on it."
),
"retain_attribution": (
"Preserves minimized tenant/group configuration accountability."
),
},
},
),
DocumentationTopic(
id="views.interface-projections",
title="Task-focused Views",
@@ -443,9 +516,21 @@ manifest = ModuleManifest(
maturity="vertical_slice",
documentation_ref="README.md",
test_ref="tests/test_views.py",
known_limits=("A View filters presentation only; modules still vary in the granularity of announced surfaces.",),
owned_concepts=("view definition", "view revision", "view assignment", "view selection"),
non_owned_concepts=("authorization", "module navigation", "workflow definition", "dashboard layout"),
known_limits=(
"A View filters presentation only; modules still vary in the granularity of announced surfaces.",
),
owned_concepts=(
"view definition",
"view revision",
"view assignment",
"view selection",
),
non_owned_concepts=(
"authorization",
"module navigation",
"workflow definition",
"dashboard layout",
),
recovery_docs=("README.md",),
security_docs=("README.md",),
),