diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02B-project-locked-policy-api.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02B-project-locked-policy-api.md index 8dbf7bc90..c1f8f419b 100644 --- a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02B-project-locked-policy-api.md +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02B-project-locked-policy-api.md @@ -1,5 +1,8 @@ # Chunk Contract: WS-ARCH-001-02B — PROJECT Locked Policy Public API +Implementation state: in review; on merge, this chunk is complete and +WS-ARCH-001-02C becomes the next eligible implementation boundary. + ## Parent initiative WS-ARCH-001 — Modular Monolith Boundaries @@ -8,7 +11,9 @@ WS-ARCH-001 — Modular Monolith Boundaries Expose dependency-free immutable PROJECT facts and ports for the locked guide, source snapshot, effective submission-artifact policy, and pre-submit policy -lineage referenced by one TASK context. +lineage referenced by one TASK context. This chunk installs the PROJECT-owned +capability; live caller composition and cutover remain in the later chunk that +owns those caller paths. ## Why this chunk exists @@ -16,6 +21,10 @@ TASK's private pre-submit loader currently reads PROJECT ORM rows. Preparation must obtain server-owned locked-policy facts from PROJECTS without transferring PROJECT persistence or policy ownership to TASKS or ART. +Locked-context resolution is distinct from current-context selection. Exact +immutable rows that were validly locked by a task remain usable after they +become `superseded`; the port must never substitute their current successors. + ## Approved plan reference - INTENT: `.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/INTENT.md` @@ -38,16 +47,20 @@ WS-ARCH-001-02A is merged and its exact TASK fact manifest is recorded. ```text backend/app/modules/projects/api/** +backend/app/modules/projects/locked_policy_repository.py backend/app/modules/projects/repository.py backend/app/modules/projects/service.py -backend/app/modules/tasks/pre_submit_context.py backend/tests/architecture/test_module_boundaries.py +backend/tests/test_ci_test_lanes.py backend/tests/test_projects.py -backend/tests/test_tasks.py +backend/tests/projects/test_locked_policy_context.py +backend/scripts/behavior_ownership.py +backend/scripts/run_test_lanes.py .ci/module-boundaries/private-edge-debt.v1.json .ci/behavior-ownership/** .agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02B-project-locked-policy-api.md .agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02B-project-manifest.md +.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-02B-external-review-response.md docs/architecture_lockdown.md ``` @@ -55,25 +68,35 @@ docs/architecture_lockdown.md Policy recompilation or approval changes; AUTH/ART activation; CHECKER plan ownership; ORM/session/repository or mutable policy bodies in `projects.api`; -new private edges. +new private edges; live TASK/ART/CHECKER caller cutover or composition wiring. ## Acceptance criteria -- [ ] PROJECTS validates exact project/guide/snapshot/policy lineage under its +- [x] PROJECTS validates exact project/guide/snapshot/policy lineage under its own locks and returns immutable canonical facts/digests. -- [ ] Cross-project, replaced, draft, and hash-mismatched context fails closed. -- [ ] TASK consumes `projects.api` only; no PROJECT model/repository import - remains in the touched pre-submit path. -- [ ] Public API dependency/leak/cycle and protected-ledger checks pass. -- [ ] Record the exact PROJECT fact/port manifest in +- [x] Cross-project, successor-substituted, draft/pending/incomplete, + non-canonical, missing, and hash-mismatched context fails closed. +- [x] Exact task-locked guide, effective-policy, and compiled pre-submit-policy + rows remain valid after later `superseded` transitions; active/current + selection semantics are not used by this port. +- [x] The public port exposes canonical JSON policy bodies only as deeply + immutable values. PROJECT owns their validation; later CHECKER consumers + may interpret them, while TASK and ART may only pass them through. +- [x] No live consumer is cut over in this chunk. The exact PROJECT capability + is composed into callers only in a later contract that permits the + application/composition paths; no session or concrete PROJECT factory is + exported to work around that boundary. +- [x] Public API dependency/leak/cycle and protected-ledger checks pass. +- [x] Record the exact PROJECT fact/port manifest in `.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02B-project-manifest.md`. ## Verification commands ```bash -(cd backend && .venv/bin/python -m ruff check app/modules/projects app/modules/tasks/pre_submit_context.py tests/architecture/test_module_boundaries.py) -(cd backend && PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/python -m pytest -q -p pytest_asyncio.plugin -p pytest_cov.plugin tests/architecture/test_module_boundaries.py tests/test_projects.py tests/test_tasks.py tests/test_submission_bundle_admission.py --cov=app.modules.projects.api --cov-report=term-missing --cov-fail-under=90) +(cd backend && .venv/bin/python -m ruff check app/modules/projects tests/architecture/test_module_boundaries.py tests/projects/test_locked_policy_context.py) +(cd backend && export WORKSTREAM_TEST_DATABASE_URL="${WORKSTREAM_TEST_DATABASE_URL:?set WORKSTREAM_TEST_DATABASE_URL}" && PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/python -m pytest -q -p pytest_asyncio.plugin -p pytest_cov.plugin tests/architecture/test_module_boundaries.py tests/projects/test_locked_policy_context.py --cov=app.modules.projects.api --cov=app.modules.projects.locked_policy_repository --cov-report=term-missing --cov-fail-under=90) (cd backend && .venv/bin/python -m scripts.module_boundaries validate --protected-base origin/main) +(cd backend && .venv/bin/python -m scripts.behavior_ownership validate) python3 scripts/check_markdown_links.py git diff --check ``` @@ -85,8 +108,8 @@ docs, reuse/dedup, and test delta. ## Human review focus -Exact locked-policy lineage, immutable output, and absence of PROJECT behavior -inside TASK or ART. +Exact historical locked-policy lineage, deep immutable output, successor +non-substitution, and absence of PROJECT behavior inside TASK or ART. ## Stop conditions diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02B-project-manifest.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02B-project-manifest.md new file mode 100644 index 000000000..15fe47d26 --- /dev/null +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02B-project-manifest.md @@ -0,0 +1,78 @@ +# WS-ARCH-001-02B PROJECT Public Capability Manifest + +## Public package + +`backend/app/modules/projects/api` exposes only dependency-safe immutable +contracts: + +- `ProjectLockedPolicyContextRequest` carries exact TASK-stamped project, + guide-version, source-snapshot, effective-policy, and pre-submit-policy + selectors and hashes; +- `CanonicalJsonObject` copies a JSON mapping into canonical immutable text and + exposes no parsed mutable projection; +- `ProjectLockedPolicyContextFacts` carries the exact guide ID/status, + snapshot identity, effective-policy identity/status/body, pre-submit-policy + identity/status/compiler, and compiled bundle; +- `ProjectLockedPolicyContextPort` defines the transaction-bound owner + capability; +- the three lifecycle aliases and `ProjectLockedPolicyContextUnavailable` + expose closed historical states and one stable failure code. + +The public package imports no ORM model, repository, SQLAlchemy session, TASK, +ART, CHECKER, AUTH, mutable mapping field, guide content, source manifest, +source-item metadata, actor provenance, or audit evidence. + +## Owner-local implementation + +`ProjectLockedPolicyRepository.lock_locked_policy_context(...)` locks rows in +this order: + +1. exact project; +2. exact project guide by project and version; +3. exact guide-source snapshot ID; +4. exact effective-policy ID; +5. exact pre-submit-policy ID. + +It verifies every project, guide, version, snapshot, effective-policy, and +pre-submit-policy cross-link; recomputes the snapshot, effective-policy, and +compiled-bundle hashes; requires complete compiled fields; and returns only +canonical immutable facts. + +Locked-context resolution never uses current/latest selectors. Exact rows that +were validly locked remain valid in their canonical `superseded` state. +Current successor substitution, draft/pending/incomplete rows, missing rows, +cross-project lineage, or stored-body/hash drift fail closed. + +## Boundary disposition + +This chunk changes no route, authorization availability, project lifecycle, +policy compilation, task preparation, ART behavior, or CHECKER behavior. Live +caller composition is deliberately deferred to a later contract with the +necessary application/composition paths. No session, repository, or concrete +PROJECT factory crosses the public boundary. + +The eligible PROJECT implementation is registered as lifecycle behavior in the +existing ownership partition and exact additive-transition allowlist. + +## Deterministic proof + +- public canonical JSON copies nested caller data and exposes immutable text + only; +- current and exact superseded locked rows resolve to identical canonical + lineage identities; +- draft, pending, cross-project, successor-substituted, and hash-drifted + contexts fail closed; +- PostgreSQL state-matrix tests exercise exact historical resolution, a + two-session test proves pre-submit-row contention, and unit SQL assertions + prove all five selected queries use `FOR UPDATE`; +- focused public API and locked-policy repository coverage is 100 percent; +- protected-base module-boundary and behavior-ownership validation pass; +- the focused capability tests live in a bounded PROJECT test module, and the + frozen test-structure ledger records no new or grown structural debt; +- that bounded module is registered in the existing `project_lifecycle` CI + lane; +- Ruff, Markdown-link, stale-wording, and diff checks pass. + +The PostgreSQL cases run in hosted Backend/Agent Gates because this worktree +has no `WORKSTREAM_TEST_DATABASE_URL`; the chunk command fails fast when that +required database URL is absent. diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-02B-external-review-response.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-02B-external-review-response.md new file mode 100644 index 000000000..2702f37cc --- /dev/null +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-02B-external-review-response.md @@ -0,0 +1,33 @@ +# WS-ARCH-001-02B External Review Response + +## Comments addressed + +- Replaced the stale AUTH-only transition-validator wording with the exact + module public-API foundation terminology. +- Extracted exact historical-lineage resolution from the legacy 1,300-line + PROJECT repository into `ProjectLockedPolicyRepository` and added that + bounded module to the chunk's 90 percent coverage command. +- Replaced iteration-count PostgreSQL lock polling with a wall-clock deadline + and a bounded positive polling interval. + +## Comments deferred + +None. + +## Human decisions needed + +None beyond the existing PR review and merge decision. + +## Commands rerun + +- Ruff formatting and lint for the affected PROJECT and CI files. +- Focused PROJECT boundary and locked-policy tests with coverage for + `app.modules.projects.api` and + `app.modules.projects.locked_policy_repository`. +- Module-boundary, behavior-ownership, and test-structure validators. +- Markdown-link and whitespace checks. + +## Remaining risks + +PostgreSQL-backed cases remain delegated to the hosted `project_lifecycle` +lane because no local test database URL is configured. diff --git a/.ci/behavior-ownership/partition.v1.json b/.ci/behavior-ownership/partition.v1.json index 31b1e19b0..96aabd65d 100644 --- a/.ci/behavior-ownership/partition.v1.json +++ b/.ci/behavior-ownership/partition.v1.json @@ -524,6 +524,10 @@ "group": "shared", "target": "backend/app/modules/outbox/service.py" }, + { + "group": "lifecycle", + "target": "backend/app/modules/projects/api/locked_policy.py" + }, { "group": "lifecycle", "target": "backend/app/modules/projects/authorization_reads.py" @@ -576,6 +580,10 @@ "group": "lifecycle", "target": "backend/app/modules/projects/guide_setup_continuation.py" }, + { + "group": "lifecycle", + "target": "backend/app/modules/projects/locked_policy_repository.py" + }, { "group": "lifecycle", "target": "backend/app/modules/projects/models.py" @@ -789,7 +797,7 @@ "target": "backend/scripts/week2_api_e2e.py" } ], - "authority_digest": "a26548020ee449fb68a72c86e17bd1f98a6d47804e55d09a5f1a9f3b4c5fb095", + "authority_digest": "6025c45a4c93c4fbe7039f2730f1f97176b985ebf0700d15d15ad82a9f095a78", "protected_base_commit": "7676ce4347db0c9694962a9b587a20765e16eac6", "schema": "workstream.behavior-ownership-partition.v1" } diff --git a/backend/app/modules/projects/api/__init__.py b/backend/app/modules/projects/api/__init__.py new file mode 100644 index 000000000..60e98952f --- /dev/null +++ b/backend/app/modules/projects/api/__init__.py @@ -0,0 +1,25 @@ +"""Dependency-safe public API for the PROJECTS business module.""" + +from app.modules.projects.api.locked_policy import ( + CanonicalJsonObject, + ProjectLockedPolicyContextFacts, + ProjectLockedPolicyContextPort, + ProjectLockedPolicyContextRequest, + ProjectLockedPolicyContextUnavailable, + ProjectLockedPolicyEffectiveStatus, + ProjectLockedPolicyFailure, + ProjectLockedPolicyGuideStatus, + ProjectLockedPolicyPreSubmitStatus, +) + +__all__ = ( + "CanonicalJsonObject", + "ProjectLockedPolicyContextFacts", + "ProjectLockedPolicyContextPort", + "ProjectLockedPolicyContextRequest", + "ProjectLockedPolicyContextUnavailable", + "ProjectLockedPolicyEffectiveStatus", + "ProjectLockedPolicyFailure", + "ProjectLockedPolicyGuideStatus", + "ProjectLockedPolicyPreSubmitStatus", +) diff --git a/backend/app/modules/projects/api/locked_policy.py b/backend/app/modules/projects/api/locked_policy.py new file mode 100644 index 000000000..5950fcde2 --- /dev/null +++ b/backend/app/modules/projects/api/locked_policy.py @@ -0,0 +1,149 @@ +"""Public PROJECT contracts for exact task-locked policy lineage.""" + +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import json +from typing import Literal, Mapping, Protocol, get_args +from uuid import UUID + +ProjectLockedPolicyGuideStatus = Literal["active", "superseded"] +ProjectLockedPolicyEffectiveStatus = Literal["approved", "superseded"] +ProjectLockedPolicyPreSubmitStatus = Literal["compiled", "superseded"] +ProjectLockedPolicyFailure = Literal["project_locked_policy_context_changed"] + + +def _is_sha256_digest(value: str) -> bool: + """Return whether one value is a canonical Workstream SHA-256 token.""" + return ( + len(value) == 71 + and value.startswith("sha256:") + and all(character in "0123456789abcdef" for character in value[7:]) + ) + + +def _canonical_json(value: Mapping[str, object]) -> str: + """Return the Workstream canonical JSON representation of one object.""" + if not isinstance(value, Mapping): + raise ValueError("canonical JSON object is invalid") + return json.dumps( + dict(value), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ) + + +@dataclass(frozen=True, slots=True) +class CanonicalJsonObject: + """Deeply immutable canonical JSON object encoded as text.""" + + value: str + + def __post_init__(self) -> None: + """Reject invalid or non-canonical JSON object text.""" + try: + decoded = json.loads(self.value) + except (TypeError, ValueError) as exc: + raise ValueError("canonical JSON object is invalid") from exc + if not isinstance(decoded, dict) or _canonical_json(decoded) != self.value: + raise ValueError("canonical JSON object is invalid") + + @classmethod + def from_mapping(cls, value: Mapping[str, object]) -> CanonicalJsonObject: + """Copy a mapping into an immutable canonical representation.""" + return cls(_canonical_json(value)) + + @property + def sha256(self) -> str: + """Return the SHA-256 identity of the canonical UTF-8 bytes.""" + return f"sha256:{hashlib.sha256(self.value.encode('utf-8')).hexdigest()}" + + +class ProjectLockedPolicyContextUnavailable(RuntimeError): + """Report a stable failure without exposing PROJECT persistence.""" + + def __init__(self, code: ProjectLockedPolicyFailure) -> None: + """Validate and retain one failure from the public closed set.""" + if code not in get_args(ProjectLockedPolicyFailure): + raise ValueError("project locked policy failure code is invalid") + super().__init__(code) + self.code = code + + +@dataclass(frozen=True, slots=True) +class ProjectLockedPolicyContextRequest: + """Exact TASK-stamped PROJECT selectors for locked-context resolution.""" + + project_id: UUID + guide_version: str + source_snapshot_id: UUID + source_snapshot_hash: str + effective_policy_id: UUID + effective_policy_hash: str + pre_submit_policy_id: UUID + pre_submit_policy_bundle_hash: str + + def __post_init__(self) -> None: + """Reject empty versions and malformed SHA-256 selectors.""" + if not self.guide_version.strip(): + raise ValueError("project locked policy guide version is empty") + for value in ( + self.source_snapshot_hash, + self.effective_policy_hash, + self.pre_submit_policy_bundle_hash, + ): + if not _is_sha256_digest(value): + raise ValueError("project locked policy hash is invalid") + + +@dataclass(frozen=True, slots=True) +class ProjectLockedPolicyContextFacts: + """Canonical PROJECT lineage resolved from exact historical locked rows.""" + + project_id: UUID + guide_id: UUID + guide_version: str + guide_status: ProjectLockedPolicyGuideStatus + source_snapshot_id: UUID + source_snapshot_hash: str + effective_policy_id: UUID + effective_policy_hash: str + effective_policy_status: ProjectLockedPolicyEffectiveStatus + effective_policy: CanonicalJsonObject + pre_submit_policy_id: UUID + pre_submit_policy_bundle_hash: str + pre_submit_policy_status: ProjectLockedPolicyPreSubmitStatus + pre_submit_compiler_version: str + compiled_pre_submit_bundle: CanonicalJsonObject + + def __post_init__(self) -> None: + """Reject lifecycle values outside the closed historical sets.""" + if ( + not self.guide_version.strip() + or not all( + _is_sha256_digest(value) + for value in ( + self.source_snapshot_hash, + self.effective_policy_hash, + self.pre_submit_policy_bundle_hash, + ) + ) + or self.guide_status not in get_args(ProjectLockedPolicyGuideStatus) + or self.effective_policy_status not in get_args(ProjectLockedPolicyEffectiveStatus) + or self.pre_submit_policy_status not in get_args(ProjectLockedPolicyPreSubmitStatus) + or not self.pre_submit_compiler_version.strip() + ): + raise ValueError("project locked policy facts are invalid") + + +class ProjectLockedPolicyContextPort(Protocol): + """Transaction-bound PROJECT capability for exact locked policy facts.""" + + async def lock_locked_policy_context( + self, + request: ProjectLockedPolicyContextRequest, + ) -> ProjectLockedPolicyContextFacts: + """Lock, validate, and return exact historical PROJECT facts.""" diff --git a/backend/app/modules/projects/locked_policy_repository.py b/backend/app/modules/projects/locked_policy_repository.py new file mode 100644 index 000000000..53b2e81c1 --- /dev/null +++ b/backend/app/modules/projects/locked_policy_repository.py @@ -0,0 +1,143 @@ +"""PROJECT persistence for exact task-locked policy lineage.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.projects.api import ( + CanonicalJsonObject, + ProjectLockedPolicyContextFacts, + ProjectLockedPolicyContextRequest, + ProjectLockedPolicyContextUnavailable, +) +from app.modules.projects.models import ( + EffectiveProjectSubmissionArtifactPolicy, + GuideSourceSnapshot, + PreSubmitCheckerPolicy, + Project, + ProjectGuide, +) + + +class ProjectLockedPolicyRepository: + """Resolve one exact historical locked-policy context under PROJECT locks.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def lock_locked_policy_context( + self, request: ProjectLockedPolicyContextRequest + ) -> ProjectLockedPolicyContextFacts: + """Lock, validate, and return the exact selected policy lineage.""" + + async def lock_by_id(model: Any, identifier: UUID) -> Any: + return await self._session.scalar( + select(model) + .where(model.id == str(identifier)) + .with_for_update() + .execution_options(populate_existing=True) + ) + + project = await lock_by_id(Project, request.project_id) + guide = await self._session.scalar( + select(ProjectGuide) + .where( + ProjectGuide.project_id == str(request.project_id), + ProjectGuide.version == request.guide_version, + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + snapshot = await lock_by_id(GuideSourceSnapshot, request.source_snapshot_id) + effective_policy = await lock_by_id( + EffectiveProjectSubmissionArtifactPolicy, request.effective_policy_id + ) + pre_submit_policy = await lock_by_id(PreSubmitCheckerPolicy, request.pre_submit_policy_id) + if ( + project is None + or project.status != "active" + or guide is None + or guide.status not in {"active", "superseded"} + or snapshot is None + or effective_policy is None + or effective_policy.lifecycle_status not in {"approved", "superseded"} + or pre_submit_policy is None + or pre_submit_policy.lifecycle_status not in {"compiled", "superseded"} + or pre_submit_policy.compiler_version is None + or pre_submit_policy.compiled_bundle is None + or pre_submit_policy.compiled_bundle_hash is None + ): + raise ProjectLockedPolicyContextUnavailable("project_locked_policy_context_changed") + values = ( + snapshot.manifest_json, + effective_policy.effective_policy, + pre_submit_policy.compiled_bundle, + ) + if not all(isinstance(value, Mapping) for value in values): + raise ProjectLockedPolicyContextUnavailable("project_locked_policy_context_changed") + try: + canonical_snapshot = CanonicalJsonObject.from_mapping(snapshot.manifest_json) + canonical_effective = CanonicalJsonObject.from_mapping( + effective_policy.effective_policy + ) + canonical_pre_submit = CanonicalJsonObject.from_mapping( + pre_submit_policy.compiled_bundle + ) + except (TypeError, ValueError) as exc: + raise ProjectLockedPolicyContextUnavailable( + "project_locked_policy_context_changed" + ) from exc + expected = ( + guide.project_id == str(request.project_id), + guide.version == request.guide_version, + snapshot.project_id == str(request.project_id), + snapshot.guide_id == guide.id, + snapshot.guide_version == request.guide_version, + snapshot.bundle_hash == request.source_snapshot_hash, + effective_policy.project_id == str(request.project_id), + effective_policy.guide_id == guide.id, + effective_policy.guide_version == request.guide_version, + effective_policy.source_snapshot_id == str(request.source_snapshot_id), + effective_policy.source_snapshot_hash == request.source_snapshot_hash, + effective_policy.effective_policy_hash == request.effective_policy_hash, + pre_submit_policy.project_id == str(request.project_id), + pre_submit_policy.guide_id == guide.id, + pre_submit_policy.guide_version == request.guide_version, + pre_submit_policy.source_snapshot_id == str(request.source_snapshot_id), + pre_submit_policy.source_snapshot_hash == request.source_snapshot_hash, + pre_submit_policy.effective_policy_id == str(request.effective_policy_id), + pre_submit_policy.effective_policy_hash == request.effective_policy_hash, + pre_submit_policy.compiled_bundle_hash == request.pre_submit_policy_bundle_hash, + canonical_snapshot.sha256 == request.source_snapshot_hash, + canonical_effective.sha256 == request.effective_policy_hash, + canonical_pre_submit.sha256 == request.pre_submit_policy_bundle_hash, + ) + if not all(expected): + raise ProjectLockedPolicyContextUnavailable("project_locked_policy_context_changed") + try: + return ProjectLockedPolicyContextFacts( + project_id=request.project_id, + guide_id=UUID(guide.id), + guide_version=guide.version, + guide_status=guide.status, + source_snapshot_id=request.source_snapshot_id, + source_snapshot_hash=snapshot.bundle_hash, + effective_policy_id=request.effective_policy_id, + effective_policy_hash=effective_policy.effective_policy_hash, + effective_policy_status=effective_policy.lifecycle_status, + effective_policy=canonical_effective, + pre_submit_policy_id=request.pre_submit_policy_id, + pre_submit_policy_bundle_hash=pre_submit_policy.compiled_bundle_hash, + pre_submit_policy_status=pre_submit_policy.lifecycle_status, + pre_submit_compiler_version=pre_submit_policy.compiler_version, + compiled_pre_submit_bundle=canonical_pre_submit, + ) + except (TypeError, ValueError) as exc: + raise ProjectLockedPolicyContextUnavailable( + "project_locked_policy_context_changed" + ) from exc diff --git a/backend/scripts/behavior_ownership.py b/backend/scripts/behavior_ownership.py index 9f3992db5..e35314966 100644 --- a/backend/scripts/behavior_ownership.py +++ b/backend/scripts/behavior_ownership.py @@ -74,8 +74,12 @@ MODULE_BOUNDARY_FOUNDATION_TARGETS = frozenset( {"backend/scripts/module_boundaries.py"} ) -TASK_BOUNDARY_FOUNDATION_TARGETS = frozenset( - {"backend/app/modules/tasks/api/submission_context.py"} +MODULE_PUBLIC_API_FOUNDATION_TARGETS = frozenset( + { + "backend/app/modules/projects/api/locked_policy.py", + "backend/app/modules/projects/locked_policy_repository.py", + "backend/app/modules/tasks/api/submission_context.py", + } ) POL_03A_CALLABLE_TARGETS = frozenset( { @@ -217,7 +221,7 @@ def _validate_additive_partition_transition( current: dict[str, Any], trusted: Any, ) -> None: - """Allow only the exact approved AUTH boundary-foundation additions.""" + """Allow only the exact approved module public-API foundation additions.""" keys = {"schema", "protected_base_commit", "assignments", "authority_digest"} if not isinstance(trusted, dict) or set(trusted) != keys: raise BehaviorOwnershipError("invalid_trusted_partition") @@ -260,7 +264,7 @@ def _validate_additive_partition_transition( approved_additions = ( AUTH_BOUNDARY_FOUNDATION_TARGETS | MODULE_BOUNDARY_FOUNDATION_TARGETS - | TASK_BOUNDARY_FOUNDATION_TARGETS + | MODULE_PUBLIC_API_FOUNDATION_TARGETS | POL_03A_CALLABLE_TARGETS | AUTH_12I_TARGETS | V01_BASELINE_ADDED_TARGETS diff --git a/backend/scripts/run_test_lanes.py b/backend/scripts/run_test_lanes.py index 0240df6c1..f0406fb3e 100644 --- a/backend/scripts/run_test_lanes.py +++ b/backend/scripts/run_test_lanes.py @@ -173,6 +173,7 @@ class TestLane: "tests/projects/guide_compilation/test_public_authorization.py", "tests/projects/guide_compilation/test_repository_attempts.py", "tests/projects/guide_compilation/test_repository_persistence.py", + "tests/projects/test_locked_policy_context.py", "tests/test_projects.py", ), ), diff --git a/backend/tests/architecture/test_module_boundaries.py b/backend/tests/architecture/test_module_boundaries.py index 5c9a8a177..9d93dd34b 100644 --- a/backend/tests/architecture/test_module_boundaries.py +++ b/backend/tests/architecture/test_module_boundaries.py @@ -211,6 +211,24 @@ def test_tasks_public_api_has_no_private_or_mutable_dependency() -> None: ) +def test_projects_public_api_has_no_private_or_mutable_dependency() -> None: + """PROJECT facts remain dependency-safe immutable public contracts.""" + boundary._validate_public_apis( # noqa: SLF001 - architecture proof + ROOT, boundary.load_registry(REGISTRY) + ) + api_files = list((ROOT / "backend/app/modules/projects/api").rglob("*.py")) + assert api_files + imports: set[str] = set() + for path in api_files: + imports.update(boundary.exact_source_imports(path, ROOT)) + assert imports + assert all( + not target.startswith("app.modules.") + or target.startswith("app.modules.projects.api") + for target in imports + ) + + def test_cyclic_public_dependencies_fail_closed() -> None: """Typed public facades cannot form an architectural dependency cycle.""" graph = {name: set() for name in boundary.load_registry(REGISTRY).names} diff --git a/backend/tests/projects/test_locked_policy_context.py b/backend/tests/projects/test_locked_policy_context.py new file mode 100644 index 000000000..8b18e124f --- /dev/null +++ b/backend/tests/projects/test_locked_policy_context.py @@ -0,0 +1,499 @@ +"""PROJECT public locked-policy context capability tests.""" + +# ruff: noqa: F401, F811 -- imported pytest fixtures must remain module globals. + +from __future__ import annotations + +import asyncio +from dataclasses import replace +from datetime import UTC, datetime +from types import SimpleNamespace +from typing import Any, cast +from uuid import UUID, uuid4 + +from httpx import AsyncClient +import pytest +from sqlalchemy import select, text, update +from sqlalchemy.ext.asyncio import create_async_engine + +from app.core.hashing import canonical_json_hash +from app.db import session as db_session +from app.modules.projects.api import ( + CanonicalJsonObject, + ProjectLockedPolicyContextFacts, + ProjectLockedPolicyContextRequest, + ProjectLockedPolicyContextUnavailable, +) +from app.modules.projects.models import ( + EffectiveProjectSubmissionArtifactPolicy, + PreSubmitCheckerPolicy, +) +from app.modules.projects.locked_policy_repository import ProjectLockedPolicyRepository +from test_projects import ( + activate_guide_for_downstream_test, + complete_guide_payload, + create_approved_policy_bundle, + create_guide, + create_project, + project_client, + project_database_env, +) + + +def _project_locked_policy_rows( + *, + guide_status: str = "active", + effective_status: str = "approved", + pre_submit_status: str = "compiled", +) -> tuple[ProjectLockedPolicyContextRequest, tuple[SimpleNamespace, ...]]: + """Build one internally consistent locked PROJECT lineage.""" + project_id, guide_id, snapshot_id, effective_id, pre_submit_id = (uuid4() for _ in range(5)) + manifest = {"items": [{"name": "guide.md"}], "schema_version": "v1"} + effective_body = {"allowed": ["zip"], "limits": {"max_bytes": 1024}} + compiled_bundle = {"rules": [{"primitive": "zip_safety"}]} + snapshot_hash = canonical_json_hash(manifest) + effective_hash = canonical_json_hash(effective_body) + bundle_hash = canonical_json_hash(compiled_bundle) + request = ProjectLockedPolicyContextRequest( + project_id=project_id, + guide_version="v1", + source_snapshot_id=snapshot_id, + source_snapshot_hash=snapshot_hash, + effective_policy_id=effective_id, + effective_policy_hash=effective_hash, + pre_submit_policy_id=pre_submit_id, + pre_submit_policy_bundle_hash=bundle_hash, + ) + rows = ( + SimpleNamespace(id=str(project_id), status="active"), + SimpleNamespace( + id=str(guide_id), + project_id=str(project_id), + version="v1", + status=guide_status, + ), + SimpleNamespace( + id=str(snapshot_id), + project_id=str(project_id), + guide_id=str(guide_id), + guide_version="v1", + manifest_json=manifest, + bundle_hash=snapshot_hash, + ), + SimpleNamespace( + id=str(effective_id), + project_id=str(project_id), + guide_id=str(guide_id), + guide_version="v1", + source_snapshot_id=str(snapshot_id), + source_snapshot_hash=snapshot_hash, + effective_policy=effective_body, + effective_policy_hash=effective_hash, + lifecycle_status=effective_status, + ), + SimpleNamespace( + id=str(pre_submit_id), + project_id=str(project_id), + guide_id=str(guide_id), + guide_version="v1", + source_snapshot_id=str(snapshot_id), + source_snapshot_hash=snapshot_hash, + effective_policy_id=str(effective_id), + effective_policy_hash=effective_hash, + lifecycle_status=pre_submit_status, + compiler_version="pre-submit-v1", + compiled_bundle=compiled_bundle, + compiled_bundle_hash=bundle_hash, + ), + ) + return request, rows + + +def test_project_locked_policy_public_facts_are_deeply_immutable() -> None: + """Canonical public policy values copy inputs without a mutable projection.""" + source = {"nested": {"values": [1, 2]}} + canonical = CanonicalJsonObject.from_mapping(source) + cast(dict[str, Any], source["nested"])["values"] = [3] + assert canonical.value == '{"nested":{"values":[1,2]}}' + assert canonical.sha256 == canonical_json_hash({"nested": {"values": [1, 2]}}) + assert not hasattr(canonical, "as_dict") + with pytest.raises(ValueError, match="canonical JSON object is invalid"): + CanonicalJsonObject('{"z":1,"a":2}') + with pytest.raises(ValueError, match="canonical JSON object is invalid"): + CanonicalJsonObject("not-json") + with pytest.raises(ValueError, match="canonical JSON object is invalid"): + CanonicalJsonObject.from_mapping(cast(Any, [])) + with pytest.raises(ValueError, match="failure code is invalid"): + ProjectLockedPolicyContextUnavailable(cast(Any, "unbounded")) + unavailable = ProjectLockedPolicyContextUnavailable("project_locked_policy_context_changed") + assert unavailable.code == "project_locked_policy_context_changed" + assert str(unavailable) == "project_locked_policy_context_changed" + request, rows = _project_locked_policy_rows() + with pytest.raises(ValueError, match="guide version is empty"): + replace(request, guide_version=" ") + with pytest.raises(ValueError, match="hash is invalid"): + replace(request, effective_policy_hash="sha256:invalid") + valid_facts = ProjectLockedPolicyContextFacts( + project_id=request.project_id, + guide_id=UUID(rows[1].id), + guide_version="v1", + guide_status="active", + source_snapshot_id=request.source_snapshot_id, + source_snapshot_hash=request.source_snapshot_hash, + effective_policy_id=request.effective_policy_id, + effective_policy_hash=request.effective_policy_hash, + effective_policy_status="approved", + effective_policy=CanonicalJsonObject.from_mapping(rows[3].effective_policy), + pre_submit_policy_id=request.pre_submit_policy_id, + pre_submit_policy_bundle_hash=request.pre_submit_policy_bundle_hash, + pre_submit_policy_status="compiled", + pre_submit_compiler_version="pre-submit-v1", + compiled_pre_submit_bundle=CanonicalJsonObject.from_mapping(rows[4].compiled_bundle), + ) + with pytest.raises(ValueError, match="facts are invalid"): + replace(valid_facts, guide_status=cast(Any, "draft")) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("guide_status", "effective_status", "pre_submit_status"), + (("active", "approved", "compiled"), ("superseded", "superseded", "superseded")), +) +async def test_locked_policy_repository_resolves_exact_current_and_superseded_policy( + guide_status: str, + effective_status: str, + pre_submit_status: str, +) -> None: + """Resolve exact valid historical lineage without selecting successors.""" + request, rows = _project_locked_policy_rows( + guide_status=guide_status, + effective_status=effective_status, + pre_submit_status=pre_submit_status, + ) + statements: list[Any] = [] + + class Session: + def __init__(self) -> None: + self.rows = iter(rows) + + async def scalar(self, statement: Any) -> Any: + statements.append(statement) + return next(self.rows) + + facts = await ProjectLockedPolicyRepository(cast(Any, Session())).lock_locked_policy_context( + request + ) + assert facts == ProjectLockedPolicyContextFacts( + project_id=request.project_id, + guide_id=UUID(rows[1].id), + guide_version="v1", + guide_status=cast(Any, guide_status), + source_snapshot_id=request.source_snapshot_id, + source_snapshot_hash=request.source_snapshot_hash, + effective_policy_id=request.effective_policy_id, + effective_policy_hash=request.effective_policy_hash, + effective_policy_status=cast(Any, effective_status), + effective_policy=CanonicalJsonObject.from_mapping(rows[3].effective_policy), + pre_submit_policy_id=request.pre_submit_policy_id, + pre_submit_policy_bundle_hash=request.pre_submit_policy_bundle_hash, + pre_submit_policy_status=cast(Any, pre_submit_status), + pre_submit_compiler_version="pre-submit-v1", + compiled_pre_submit_bundle=CanonicalJsonObject.from_mapping(rows[4].compiled_bundle), + ) + assert len(statements) == 5 + assert all("FOR UPDATE" in str(statement) for statement in statements) + assert all( + statement.get_execution_options().get("populate_existing") is True + for statement in statements + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "failure", + ( + "draft", + "pending", + "hash", + "cross_project", + "effective_link", + "non_canonical", + "invalid_guide_id", + "snapshot_array", + "effective_array", + "bundle_array", + ), +) +async def test_locked_policy_repository_rejects_invalid_lineage( + failure: str, +) -> None: + """Fail closed for draft, pending, drifted, or cross-project lineage.""" + request, rows = _project_locked_policy_rows() + if failure == "draft": + rows[1].status = "draft" + elif failure == "pending": + rows[4].lifecycle_status = "pending_compilation" + elif failure == "hash": + rows[3].effective_policy = {"allowed": ["tar"]} + elif failure == "cross_project": + rows[2].project_id = str(uuid4()) + elif failure == "effective_link": + rows[4].effective_policy_id = str(uuid4()) + elif failure == "non_canonical": + rows[3].effective_policy = {"invalid": float("nan")} + elif failure == "invalid_guide_id": + rows[1].id = "invalid-guide-id" + rows[2].guide_id = rows[1].id + rows[3].guide_id = rows[1].id + rows[4].guide_id = rows[1].id + elif failure == "snapshot_array": + drift_hash = canonical_json_hash(cast(Any, [])) + request = replace(request, source_snapshot_hash=drift_hash) + rows[2].manifest_json = [] + rows[2].bundle_hash = drift_hash + rows[3].source_snapshot_hash = drift_hash + rows[4].source_snapshot_hash = drift_hash + elif failure == "effective_array": + drift_hash = canonical_json_hash(cast(Any, [])) + request = replace(request, effective_policy_hash=drift_hash) + rows[3].effective_policy = [] + rows[3].effective_policy_hash = drift_hash + rows[4].effective_policy_hash = drift_hash + else: + drift_hash = canonical_json_hash(cast(Any, [])) + request = replace(request, pre_submit_policy_bundle_hash=drift_hash) + rows[4].compiled_bundle = [] + rows[4].compiled_bundle_hash = drift_hash + + class Session: + def __init__(self) -> None: + self.rows = iter(rows) + + async def scalar(self, _statement: Any) -> Any: + return next(self.rows) + + with pytest.raises( + ProjectLockedPolicyContextUnavailable, + match="project_locked_policy_context_changed", + ): + await ProjectLockedPolicyRepository(cast(Any, Session())).lock_locked_policy_context( + request + ) + + +async def create_locked_policy_context_fixture( + client: AsyncClient, +) -> ProjectLockedPolicyContextRequest: + """Create and activate one complete PROJECT policy lineage.""" + project = await create_project(client, name=f"Locked Context {uuid4()}") + guide = await create_guide(client, project["id"], complete_guide_payload()) + bundle = await create_approved_policy_bundle(client, project["id"], guide["id"]) + activation = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=guide["id"], + ) + assert activation.status_code == 200, activation.text + snapshot = bundle["source_snapshot"] + effective = bundle["effective_policy"] + pre_submit = bundle["pre_submit_checker_policy"] + assert pre_submit is not None + return ProjectLockedPolicyContextRequest( + project_id=UUID(project["id"]), + guide_version=guide["version"], + source_snapshot_id=UUID(snapshot["id"]), + source_snapshot_hash=snapshot["bundle_hash"], + effective_policy_id=UUID(effective["id"]), + effective_policy_hash=effective["effective_policy_hash"], + pre_submit_policy_id=UUID(pre_submit["id"]), + pre_submit_policy_bundle_hash=pre_submit["compiled_bundle_hash"], + ) + + +async def _wait_for_project_database_lock( + database_url: str, + application_name: str, +) -> None: + """Wait until one named PROJECT race participant blocks on PostgreSQL.""" + engine = create_async_engine(database_url) + try: + async with engine.connect() as connection: + deadline = asyncio.get_running_loop().time() + 30.0 + while asyncio.get_running_loop().time() < deadline: + waiting = await connection.scalar( + text( + "select exists(select 1 from pg_stat_activity where " + "application_name = :application_name " + "and wait_event_type = 'Lock')" + ), + {"application_name": application_name}, + ) + if waiting: + return + await asyncio.sleep(0.01) + finally: + await engine.dispose() + raise AssertionError(f"{application_name} never reached the PostgreSQL lock") + + +async def _supersede_locked_policy_context( + request: ProjectLockedPolicyContextRequest, +) -> None: + """Move one exact PROJECT policy lineage to its historical states.""" + async with db_session.get_session_factory()() as session: + superseded_at = datetime.now(UTC) + for model, identifier in ( + (EffectiveProjectSubmissionArtifactPolicy, request.effective_policy_id), + (PreSubmitCheckerPolicy, request.pre_submit_policy_id), + ): + await session.execute( + update(model) + .where(model.id == str(identifier)) + .values(lifecycle_status="superseded", superseded_at=superseded_at) + ) + await session.commit() + + +@pytest.mark.asyncio +async def test_locked_policy_repository_postgresql_state_matrix( + project_client: AsyncClient, +) -> None: + """Prove exact and superseded lineage semantics against PostgreSQL.""" + request = await create_locked_policy_context_fixture(project_client) + async with db_session.get_session_factory()() as session: + current = await ProjectLockedPolicyRepository(session).lock_locked_policy_context(request) + assert current.guide_status == "active" + assert current.effective_policy_status == "approved" + assert current.pre_submit_policy_status == "compiled" + + await _supersede_locked_policy_context(request) + async with db_session.get_session_factory()() as session: + historical = await ProjectLockedPolicyRepository(session).lock_locked_policy_context( + request + ) + assert historical.guide_status == "active" + assert historical.effective_policy_status == "superseded" + assert historical.pre_submit_policy_status == "superseded" + + wrong_successor = replace(request, effective_policy_id=uuid4()) + async with db_session.get_session_factory()() as session: + with pytest.raises( + ProjectLockedPolicyContextUnavailable, + match="project_locked_policy_context_changed", + ): + await ProjectLockedPolicyRepository(session).lock_locked_policy_context(wrong_successor) + + async with db_session.get_session_factory()() as session: + await session.execute( + update(PreSubmitCheckerPolicy) + .where(PreSubmitCheckerPolicy.id == str(request.pre_submit_policy_id)) + .values(lifecycle_status="pending_compilation", superseded_at=None) + ) + await session.commit() + async with db_session.get_session_factory()() as session: + with pytest.raises( + ProjectLockedPolicyContextUnavailable, + match="project_locked_policy_context_changed", + ): + await ProjectLockedPolicyRepository(session).lock_locked_policy_context(request) + + +@pytest.mark.asyncio +async def test_locked_policy_repository_postgresql_does_not_substitute_successors( + project_client: AsyncClient, +) -> None: + """Keep resolving exact historical IDs after current successors exist.""" + request = await create_locked_policy_context_fixture(project_client) + await _supersede_locked_policy_context(request) + async with db_session.get_session_factory()() as session: + original_effective = await session.get( + EffectiveProjectSubmissionArtifactPolicy, str(request.effective_policy_id) + ) + original_pre_submit = await session.get( + PreSubmitCheckerPolicy, str(request.pre_submit_policy_id) + ) + assert original_effective is not None + assert original_pre_submit is not None + successor_effective_id = str(uuid4()) + session.add( + EffectiveProjectSubmissionArtifactPolicy( + id=successor_effective_id, + project_id=original_effective.project_id, + guide_id=original_effective.guide_id, + guide_version=original_effective.guide_version, + source_snapshot_id=original_effective.source_snapshot_id, + source_snapshot_hash=original_effective.source_snapshot_hash, + submission_artifact_policy_id=original_effective.submission_artifact_policy_id, + submission_artifact_policy_hash=original_effective.submission_artifact_policy_hash, + lifecycle_status="approved", + merge_algorithm_version=original_effective.merge_algorithm_version, + effective_policy=original_effective.effective_policy, + effective_policy_hash=original_effective.effective_policy_hash, + created_by="locked-context-successor-test", + supersedes_effective_policy_id=original_effective.id, + ) + ) + session.add( + PreSubmitCheckerPolicy( + id=str(uuid4()), + project_id=original_pre_submit.project_id, + guide_id=original_pre_submit.guide_id, + guide_version=original_pre_submit.guide_version, + source_snapshot_id=original_pre_submit.source_snapshot_id, + source_snapshot_hash=original_pre_submit.source_snapshot_hash, + effective_policy_id=successor_effective_id, + effective_policy_hash=original_pre_submit.effective_policy_hash, + lifecycle_status="compiled", + compiler_version=original_pre_submit.compiler_version, + compiled_bundle=original_pre_submit.compiled_bundle, + compiled_bundle_hash=original_pre_submit.compiled_bundle_hash, + checker_names=original_pre_submit.checker_names, + checker_configs=original_pre_submit.checker_configs, + created_by="locked-context-successor-test", + supersedes_pre_submit_checker_policy_id=original_pre_submit.id, + ) + ) + await session.commit() + async with db_session.get_session_factory()() as session: + historical = await ProjectLockedPolicyRepository(session).lock_locked_policy_context( + request + ) + assert historical.effective_policy_id == request.effective_policy_id + assert historical.pre_submit_policy_id == request.pre_submit_policy_id + + +@pytest.mark.asyncio +async def test_locked_policy_repository_postgresql_serializes_race( + project_client: AsyncClient, + project_database_env: str, +) -> None: + """Prove exact PROJECT lineage observation holds its pre-submit row lock.""" + request = await create_locked_policy_context_fixture(project_client) + contender_name = f"project-locked-policy-{uuid4()}" + holder = db_session.get_session_factory()() + contender = db_session.get_session_factory()() + contender_call: asyncio.Task[Any] | None = None + try: + held = await ProjectLockedPolicyRepository(holder).lock_locked_policy_context(request) + await contender.execute( + text("select set_config('application_name', :application_name, true)"), + {"application_name": contender_name}, + ) + contender_call = asyncio.create_task( + contender.scalar( + select(PreSubmitCheckerPolicy) + .where(PreSubmitCheckerPolicy.id == str(request.pre_submit_policy_id)) + .with_for_update() + ) + ) + await _wait_for_project_database_lock(project_database_env, contender_name) + assert not contender_call.done() + await holder.rollback() + assert await contender_call is not None + assert held.pre_submit_policy_id == request.pre_submit_policy_id + finally: + if contender_call is not None: + contender_call.cancel() + await asyncio.gather(contender_call, return_exceptions=True) + await holder.close() + await contender.close() diff --git a/backend/tests/test_ci_test_lanes.py b/backend/tests/test_ci_test_lanes.py index a37ad675b..c1d06ef8d 100644 --- a/backend/tests/test_ci_test_lanes.py +++ b/backend/tests/test_ci_test_lanes.py @@ -54,6 +54,7 @@ def test_measured_hotspots_have_explicit_semantic_owners() -> None: "tests/projects/guide_compilation/test_public_authorization.py", "tests/projects/guide_compilation/test_repository_attempts.py", "tests/projects/guide_compilation/test_repository_persistence.py", + "tests/projects/test_locked_policy_context.py", "tests/test_projects.py", } assert modules_by_lane["task_lifecycle"] == { diff --git a/docs/architecture_lockdown.md b/docs/architecture_lockdown.md index 0a529d542..c7faec9c5 100644 --- a/docs/architecture_lockdown.md +++ b/docs/architecture_lockdown.md @@ -30,6 +30,14 @@ mutable policy body, or PROJECT/CHECKER/ACTOR persistence. Those locked references are inputs to their owning public capabilities; they do not transfer policy or identity ownership into TASKS. +The PROJECTS public boundary resolves exact task-locked guide, source-snapshot, +effective-policy, and pre-submit-policy lineage through +`app.modules.projects.api`. Historical rows remain valid after canonical +`superseded` transitions when every exact selector and recomputed hash still +matches; current successors are never substituted. Policy bodies cross this +boundary only as canonical immutable JSON, never as ORM rows, sessions, +repositories, mutable mappings, or a public mutable projection helper. + The ADR files under `docs/decision_*.md` are the decision record for this lockdown. When a locked rule changes, update or add an ADR before changing implementation specs. Repository changes use the Repository-Native Human-Agent SDLC documented in