intermittent commit

This commit is contained in:
2026-07-14 13:22:11 +02:00
parent ec748e3fcf
commit f2ade1d624
4 changed files with 217 additions and 25 deletions

View File

@@ -210,7 +210,50 @@ The UI should make the current context explicit enough to avoid confusion, but i
## Governance
Durable context belongs in repository docs and synced wiki pages. Active work belongs in Gitea issues. Runtime documentation should link both where helpful, but it should distinguish stable explanation from changing backlog state.
### Source Ownership
Documentation ownership follows behavior ownership:
- runtime documentation topics belong to the module that owns the route,
policy, workflow, capability, or data model being explained
- repository docs belong to the repository that owns the implementation or
durable architecture decision
- synced Gitea wiki pages are a publication surface for durable context, not a
separate source of truth
- active Gitea issues are the source of truth for current work state,
acceptance criteria, blockers, and triage decisions
The docs module renders and classifies documentation. It should not become the
owner of feature-module explanations, and it should not copy backlog state into
runtime documentation as if it were stable product behavior. When runtime docs
link to an issue, they must present it as changing work state. When runtime docs
link to repository docs or wiki pages, they may present the linked material as
durable context if the owning repository treats it that way.
Durable context belongs in repository docs and synced wiki pages. Active work
belongs in Gitea issues. Runtime documentation should link both where helpful,
but it should distinguish stable explanation from changing backlog state.
### Privacy And Permission Boundaries
Documentation is still a governed interface. Role-aware documentation must never
use help text as a side channel for data, configuration, or capability details
that the actor could not otherwise see.
User-facing topics may explain that a feature is unavailable and identify the
kind of blocker, such as missing permission, disabled module, locked policy, or
administrator configuration. They should not expose internal module ids, raw
scope names, policy payloads, hostnames, tenant identifiers, profile ids, or
other operational details unless the actor is already allowed to inspect that
information.
Admin-facing topics may expose technical provenance, route ids, API paths,
capabilities, configuration keys, policy source chains, and migration notes when
the actor has the relevant administrative permission. Even then, runtime
providers must summarize secrets and sensitive values as posture, counts, or
source provenance. Credentials, tokens, private keys, raw payloads, and
person-specific data stay out of documentation responses unless a dedicated
audited administration route explicitly provides them.
Documentation sources should be auditable when they affect compliance, operator procedure, or policy explanation. Configuration-derived documentation should identify the source configuration package or policy source where possible.

View File

@@ -68,3 +68,32 @@ should contribute conditional topics such as:
The docs module remains the renderer. Feature modules own their subject matter
and describe unlocks through manifest metadata.
## Ownership And Disclosure Rules
The ownership rule is the same for all documentation layers: the module or
repository that owns the behavior owns the durable explanation. The docs module
owns classification, filtering, search, route contribution, and rendering. It
does not own feature-module business rules, policy semantics, or current issue
state.
Use these sources for these purposes:
| Source | Purpose |
| --- | --- |
| Runtime docs providers | Effective, actor-aware explanation of the configured system. |
| Repository docs | Durable architecture, module contracts, runbooks, and governance decisions. |
| Synced Gitea wiki pages | Published copy of durable documentation for browsing and linking. |
| Gitea issues | Active backlog state, acceptance criteria, blockers, and closure evidence. |
Runtime docs may link to issues when an unavailable feature is planned or a
known limitation is relevant, but the UI must label that link as active work.
It must not treat open issues as shipped behavior.
Safe disclosure is evaluated before a topic is returned. Missing permission,
missing module, and missing capability explanations should be useful but
minimal. User docs explain the blocker and who can resolve it. Admin docs can
include route ids, scopes, capability names, configuration keys, and policy
provenance only when the actor has permission to inspect those details. Secrets,
tokens, private keys, credentials, raw policy payloads, and unrelated personal
data are never returned as documentation content.

View File

@@ -356,42 +356,117 @@ def _condition_visibility(
registry: PlatformRegistry,
principal: ApiPrincipal,
) -> tuple[bool, str, dict[str, list[str]]]:
blockers = {"modules": [], "capabilities": [], "scopes": []}
missing_required_modules = [module_id for module_id in condition.required_modules if module_id not in installed]
if missing_required_modules:
blockers["modules"].extend(missing_required_modules)
if condition.any_modules and not any(module_id in installed for module_id in condition.any_modules):
blockers["modules"].extend(module_id for module_id in condition.any_modules if module_id not in blockers["modules"])
conflicting_modules = [module_id for module_id in condition.missing_modules if module_id in installed]
if conflicting_modules:
blockers["modules"].extend(conflicting_modules)
missing_capabilities = [name for name in condition.required_capabilities if not registry.has_capability(name)]
if missing_capabilities:
blockers["capabilities"].extend(missing_capabilities)
missing_scopes = [scope for scope in condition.required_scopes if not has_scope(principal, scope)]
missing_any_scopes = list(condition.any_scopes) if condition.any_scopes and not any(has_scope(principal, scope) for scope in condition.any_scopes) else []
if missing_scopes:
blockers["scopes"].extend(missing_scopes)
if missing_any_scopes:
blockers["scopes"].extend(scope for scope in missing_any_scopes if scope not in blockers["scopes"])
missing_required_modules = _missing_required_modules(condition, installed)
unsatisfied_any_modules = _unsatisfied_any_modules(condition, installed)
conflicting_modules = _conflicting_modules(condition, installed)
missing_capabilities = _missing_required_capabilities(condition, registry)
missing_scopes = _missing_required_scopes(condition, principal)
unsatisfied_any_scopes = _unsatisfied_any_scopes(condition, principal)
blockers = _condition_blockers(
missing_required_modules=missing_required_modules,
unsatisfied_any_modules=unsatisfied_any_modules,
conflicting_modules=conflicting_modules,
missing_capabilities=missing_capabilities,
missing_scopes=missing_scopes,
unsatisfied_any_scopes=unsatisfied_any_scopes,
)
if not blockers["modules"] and not blockers["capabilities"] and not blockers["scopes"]:
if _condition_has_no_blockers(blockers):
return True, "conditions satisfied", blockers
reason = _condition_blocker_reason(
missing_required_modules=missing_required_modules,
unsatisfied_any_modules=unsatisfied_any_modules,
conflicting_modules=conflicting_modules,
missing_capabilities=missing_capabilities,
missing_scopes=missing_scopes,
unsatisfied_any_scopes=unsatisfied_any_scopes,
)
return False, reason, blockers
def _missing_required_modules(condition: DocumentationCondition, installed: set[str]) -> list[str]:
return [module_id for module_id in condition.required_modules if module_id not in installed]
def _unsatisfied_any_modules(condition: DocumentationCondition, installed: set[str]) -> list[str]:
if not condition.any_modules:
return []
if any(module_id in installed for module_id in condition.any_modules):
return []
return list(condition.any_modules)
def _conflicting_modules(condition: DocumentationCondition, installed: set[str]) -> list[str]:
return [module_id for module_id in condition.missing_modules if module_id in installed]
def _missing_required_capabilities(condition: DocumentationCondition, registry: PlatformRegistry) -> list[str]:
return [name for name in condition.required_capabilities if not registry.has_capability(name)]
def _missing_required_scopes(condition: DocumentationCondition, principal: ApiPrincipal) -> list[str]:
return [scope for scope in condition.required_scopes if not has_scope(principal, scope)]
def _unsatisfied_any_scopes(condition: DocumentationCondition, principal: ApiPrincipal) -> list[str]:
if not condition.any_scopes:
return []
if any(has_scope(principal, scope) for scope in condition.any_scopes):
return []
return list(condition.any_scopes)
def _condition_blockers(
*,
missing_required_modules: list[str],
unsatisfied_any_modules: list[str],
conflicting_modules: list[str],
missing_capabilities: list[str],
missing_scopes: list[str],
unsatisfied_any_scopes: list[str],
) -> dict[str, list[str]]:
blockers = {"modules": [], "capabilities": [], "scopes": []}
_extend_unique(blockers["modules"], missing_required_modules)
_extend_unique(blockers["modules"], unsatisfied_any_modules)
_extend_unique(blockers["modules"], conflicting_modules)
_extend_unique(blockers["capabilities"], missing_capabilities)
_extend_unique(blockers["scopes"], missing_scopes)
_extend_unique(blockers["scopes"], unsatisfied_any_scopes)
return blockers
def _extend_unique(target: list[str], values: list[str]) -> None:
target.extend(value for value in values if value not in target)
def _condition_has_no_blockers(blockers: dict[str, list[str]]) -> bool:
return not blockers["modules"] and not blockers["capabilities"] and not blockers["scopes"]
def _condition_blocker_reason(
*,
missing_required_modules: list[str],
unsatisfied_any_modules: list[str],
conflicting_modules: list[str],
missing_capabilities: list[str],
missing_scopes: list[str],
unsatisfied_any_scopes: list[str],
) -> str:
parts: list[str] = []
if missing_required_modules:
parts.append("missing modules: " + ", ".join(missing_required_modules))
if condition.any_modules and not any(module_id in installed for module_id in condition.any_modules):
parts.append("requires one installed module from: " + ", ".join(condition.any_modules))
if unsatisfied_any_modules:
parts.append("requires one installed module from: " + ", ".join(unsatisfied_any_modules))
if conflicting_modules:
parts.append("not active when installed: " + ", ".join(conflicting_modules))
if missing_capabilities:
parts.append("missing capabilities: " + ", ".join(missing_capabilities))
if missing_scopes:
parts.append("missing scopes: " + ", ".join(missing_scopes))
if missing_any_scopes:
parts.append("requires one scope from: " + ", ".join(missing_any_scopes))
return False, "; ".join(parts), blockers
if unsatisfied_any_scopes:
parts.append("requires one scope from: " + ", ".join(unsatisfied_any_scopes))
return "; ".join(parts)
def _documentation_target_layer(topic: DocumentationTopic, active: bool, blockers: dict[str, list[str]]) -> str:

View File

@@ -4,10 +4,12 @@ import unittest
from types import SimpleNamespace
from govoplan_access.backend.manifest import get_manifest as get_access_manifest
from govoplan_core.core.modules import DocumentationCondition
from govoplan_core.core.registry import PlatformRegistry
from govoplan_tenancy.backend.manifest import get_manifest as get_tenancy_manifest
from govoplan_docs.backend.api.v1.routes import (
_classify_documentation,
_condition_visibility,
_documentation_topic_anchor,
_documentation_topic_groups,
)
@@ -86,6 +88,49 @@ class DocsContextTests(unittest.TestCase):
"docs-topic-access-access-workflow-grant-user-access",
)
def test_condition_visibility_reports_module_capability_and_scope_blockers(self) -> None:
registry = PlatformRegistry()
condition = DocumentationCondition(
required_modules=("mail",),
any_modules=("files", "campaigns"),
missing_modules=("legacy",),
required_capabilities=("mail.delivery",),
required_scopes=("docs:documentation:read",),
any_scopes=("mail:profile:read", "admin:policies:read"),
)
principal = FakePrincipal({"docs:documentation:read"})
active, reason, blockers = _condition_visibility(condition, {"legacy"}, registry, principal)
self.assertFalse(active)
self.assertEqual(
reason,
"missing modules: mail; "
"requires one installed module from: files, campaigns; "
"not active when installed: legacy; "
"missing capabilities: mail.delivery; "
"requires one scope from: mail:profile:read, admin:policies:read",
)
self.assertEqual(blockers["modules"], ["mail", "files", "campaigns", "legacy"])
self.assertEqual(blockers["capabilities"], ["mail.delivery"])
self.assertEqual(blockers["scopes"], ["mail:profile:read", "admin:policies:read"])
def test_condition_visibility_accepts_any_module_scope_and_capability(self) -> None:
registry = PlatformRegistry()
registry.register_capability_factory("mail", "mail.delivery", lambda context: object())
condition = DocumentationCondition(
any_modules=("files", "campaigns"),
required_capabilities=("mail.delivery",),
any_scopes=("mail:profile:read", "admin:policies:read"),
)
principal = FakePrincipal({"admin:policies:read"})
active, reason, blockers = _condition_visibility(condition, {"campaigns"}, registry, principal)
self.assertTrue(active)
self.assertEqual(reason, "conditions satisfied")
self.assertEqual(blockers, {"modules": [], "capabilities": [], "scopes": []})
if __name__ == "__main__":
unittest.main()