diff --git a/.github/github.json b/.github/github.json index 9ac57ca1f..23d878b8a 100644 --- a/.github/github.json +++ b/.github/github.json @@ -22,7 +22,11 @@ "workGraphReadModel": "docs/work-graph-read-model.md", "mergeTrainPolicy": "docs/merge-train-policy.md", "mergeReadiness": "docs/merge-readiness.md", + "governanceEvidence": "docs/governance-evidence.md", "mergeTrainStructuralProvenance": "docs/merge-train-structural-provenance.md", + "productOwnerPolicy": "docs/product-owner-policy.md", + "ownerAcceptance": "docs/owner-acceptance.md", + "changeImpactPolicy": "docs/change-impact-policy.md", "agentContextBoundary": "docs/agent-context-boundary.md", "operations": "docs/operations.md", "records": "docs/records.md", diff --git a/contracts/agent-operator-contract.json b/contracts/agent-operator-contract.json index 6fa2308a5..c07ba729b 100644 --- a/contracts/agent-operator-contract.json +++ b/contracts/agent-operator-contract.json @@ -7,8 +7,9 @@ }, "governance": { "authorization_admission_and_landing_are_independent": true, - "shadow_evidence_authoritative": false, - "shadow_evidence_role": "advisory_only" + "engineering_review_advisory": true, + "github_projection_role": "routing_and_status_only", + "owner_acceptance_authoritative": true }, "product_lifecycle": { "active_automation_states": [ @@ -176,7 +177,7 @@ "503" ], "reviewed_evidence": [], - "schema_fingerprint_sha256": "810ce55fdcb429797375cb8a624d6ee36dc20ec80bf3fb17ac0e882b174e9d18", + "schema_fingerprint_sha256": "5aaa63991adeec924cd6ad00b45e79f0ad9813bcc541dfe512d2ad78a5c34911", "supported_surfaces": [ "agent_helper", "read_only_service" @@ -381,7 +382,7 @@ ], "operation_id": "read_governance_projection", "path": "/v1/governance/projection", - "purpose": "Read governance evidence without granting shadow authority.", + "purpose": "Read governance evidence without granting mutation authority.", "response_statuses": [ "200", "400", @@ -391,7 +392,7 @@ "503" ], "reviewed_evidence": [], - "schema_fingerprint_sha256": "9140bde34ab5930422c2e9f654db3e7af1ede283a69bd8289c62a93931f2d11e", + "schema_fingerprint_sha256": "28daa2b5e8010b8460a67bc7e4ea45f12cc8be3fb3d3d76e075222eef9226b8c", "supported_surfaces": [ "read_only_service", "operator_ui" @@ -422,8 +423,8 @@ }, "normalization_version": 1, "provenance": { - "source_commit_sha": "ef953b66251d8dce8f87405e54e6150d8e4b19a1" + "source_commit_sha": "3f8e22ff1762be0b81d5eeb19f237a14b6a8bd4f" }, "schema_version": 1, - "semantic_digest_sha256": "a3f2c6602376cd0cd8638283f186471fe4f8a53516f531d4ff613b9ed5203479" + "semantic_digest_sha256": "5ca368e08c9d1d094eba3ed5cf47a789b144bb81e6ff8722138440ebaff23b64" } diff --git a/control_plane/advisory_check_projection.py b/control_plane/advisory_check_projection.py index 10834b481..96fe8b4f3 100644 --- a/control_plane/advisory_check_projection.py +++ b/control_plane/advisory_check_projection.py @@ -88,7 +88,7 @@ def write_advisory_check_projection( body = { "name": projection.name, "status": "completed", - "conclusion": "neutral", + "conclusion": projection.conclusion, "external_id": projection.external_id, "details_url": projection.details_url, "output": {"title": projection.title, "summary": projection.summary}, @@ -138,7 +138,7 @@ def _matches_projection(check_run: dict[str, object], projection: AdvisoryCheckP output = check_run.get("output") return ( str(check_run.get("status") or "") == "completed" - and str(check_run.get("conclusion") or "") == "neutral" + and str(check_run.get("conclusion") or "") == projection.conclusion and str(check_run.get("external_id") or "") == projection.external_id and str(check_run.get("details_url") or "") == projection.details_url and isinstance(output, dict) @@ -175,7 +175,7 @@ def _result( != projection.external_id or _app_id(check_run.get("app")) != installation_token.app_id or str(check_run.get("status") or "") != "completed" - or str(check_run.get("conclusion") or "") != "neutral" + or str(check_run.get("conclusion") or "") != projection.conclusion or str(check_run.get("details_url") or "") != projection.details_url ): raise AdvisoryCheckProjectionError( @@ -193,6 +193,7 @@ def _result( "GitHub advisory check run response requires id.", error_type=AdvisoryCheckProjectionError, ), + conclusion=projection.conclusion, ) diff --git a/control_plane/agent_operator_contract.py b/control_plane/agent_operator_contract.py index b1b3bac1d..a8a5e74e4 100644 --- a/control_plane/agent_operator_contract.py +++ b/control_plane/agent_operator_contract.py @@ -182,7 +182,7 @@ class OperationSpec: OperationSpec( "GET", "/v1/governance/projection", - "Read governance evidence without granting shadow authority.", + "Read governance evidence without granting mutation authority.", ("read_only_service", "operator_ui"), ("read",), "none", @@ -236,8 +236,9 @@ class OperationSpec: "landing_sha_source": "terminal_controller_result_only", }, "governance": { - "shadow_evidence_authoritative": False, - "shadow_evidence_role": "advisory_only", + "owner_acceptance_authoritative": True, + "github_projection_role": "routing_and_status_only", + "engineering_review_advisory": True, "authorization_admission_and_landing_are_independent": True, }, "protected_workflow_policy": { diff --git a/control_plane/change_impact_service.py b/control_plane/change_impact_service.py index 9885c2df9..2f5cf3ae5 100644 --- a/control_plane/change_impact_service.py +++ b/control_plane/change_impact_service.py @@ -84,9 +84,6 @@ class ChangeImpactPolicyReadModel(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) schema_version: int = Field(default=1, ge=1) - mode: Literal["shadow"] = "shadow" - authoritative: Literal[False] = False - enforcement_effect: Literal["none"] = "none" repository_id: str current_policy: ChangeImpactPolicyRecord | None = None policy_history_count: int = Field(default=0, ge=0) diff --git a/control_plane/contracts/advisory_check_projection.py b/control_plane/contracts/advisory_check_projection.py index e8978145c..3ab9513df 100644 --- a/control_plane/contracts/advisory_check_projection.py +++ b/control_plane/contracts/advisory_check_projection.py @@ -19,6 +19,7 @@ ) AdvisoryCheckProjectionStatus = Literal["projected", "updated", "replayed"] +AdvisoryCheckConclusion = Literal["neutral", "success", "failure", "action_required"] class AdvisoryCheckProjection(BaseModel): @@ -33,6 +34,7 @@ class AdvisoryCheckProjection(BaseModel): details_url: str title: str = Field(min_length=1, max_length=255) summary: str = Field(min_length=1, max_length=65535) + conclusion: AdvisoryCheckConclusion = "neutral" @model_validator(mode="after") def _validate_projection(self) -> "AdvisoryCheckProjection": @@ -80,7 +82,7 @@ class AdvisoryCheckProjectionResult(BaseModel): app_id: int = Field(ge=1) installation_id: int = Field(ge=1) check_run_id: int = Field(ge=1) - conclusion: Literal["neutral"] = "neutral" + conclusion: AdvisoryCheckConclusion def is_launchplane_projected_check(name: str) -> bool: diff --git a/control_plane/contracts/change_impact.py b/control_plane/contracts/change_impact.py index f1e54f411..9006291b9 100644 --- a/control_plane/contracts/change_impact.py +++ b/control_plane/contracts/change_impact.py @@ -74,9 +74,14 @@ def _normalize_timestamp(value: str, field_name: str) -> str: raise ValueError(f"{field_name} must be an ISO-8601 timestamp") from error if parsed.tzinfo is None: raise ValueError(f"{field_name} must include a timezone") - return parsed.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace( - "+00:00", - "Z", + return ( + parsed.astimezone(timezone.utc) + .replace(microsecond=0) + .isoformat() + .replace( + "+00:00", + "Z", + ) ) @@ -375,9 +380,7 @@ class ChangeImpactEvaluationRequest(BaseModel): schema_version: int = Field(default=1, ge=1) target: ChangeImpactTargetReference - metadata: ChangeImpactEvaluationMetadata = Field( - default_factory=ChangeImpactEvaluationMetadata - ) + metadata: ChangeImpactEvaluationMetadata = Field(default_factory=ChangeImpactEvaluationMetadata) @model_validator(mode="after") def _validate_request(self) -> "ChangeImpactEvaluationRequest": @@ -506,9 +509,6 @@ class ChangeImpactEvaluation(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) schema_version: int = Field(default=1, ge=1) - mode: Literal["shadow"] = "shadow" - authoritative: Literal[False] = False - enforcement_effect: Literal["none"] = "none" status: ChangeImpactDecisionStatus reason_code: str target: ChangeImpactTarget diff --git a/control_plane/contracts/governance_projection.py b/control_plane/contracts/governance_projection.py index 38338a071..26ef3a108 100644 --- a/control_plane/contracts/governance_projection.py +++ b/control_plane/contracts/governance_projection.py @@ -38,12 +38,9 @@ class GovernanceOwnerHistoryEntry(BaseModel): human_action_semantics: OwnerAcceptanceHumanActionSemantics target_status: Literal["current", "historical"] decision_relationship: Literal["current", "historical"] - authorizes: tuple[str, ...] = () @model_validator(mode="after") def _validate_entry(self) -> "GovernanceOwnerHistoryEntry": - if self.authorizes: - raise ValueError("Historical Owner product review authorizes no effect") expected_semantics = owner_acceptance_human_action_semantics(self.record.action) if self.human_action_semantics != expected_semantics: raise ValueError("Owner history semantics must match the stored event") @@ -54,16 +51,13 @@ class GovernanceOwnerJudgmentFacet(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) level: Literal[1] = 1 - mode: Literal["historical_product_judgment"] = "historical_product_judgment" - authoritative: Literal[False] = False - authorizes: tuple[str, ...] = () + mode: Literal["owner_acceptance"] = "owner_acceptance" + authoritative: Literal[True] = True current: OwnerAcceptanceDecision history: tuple[GovernanceOwnerHistoryEntry, ...] = () @model_validator(mode="after") def _validate_facet(self) -> "GovernanceOwnerJudgmentFacet": - if self.authorizes: - raise ValueError("Level 1 Owner product judgment authorizes no effect") ordered = tuple( sorted( self.history, @@ -166,7 +160,6 @@ class GovernanceAdvisoryObservation(BaseModel): observation_scope: GovernanceAdvisoryObservationScope observation: MergeReadinessAdvisoryObservation - neutral: Literal[True] = True authoritative: Literal[False] = False authorizes: tuple[str, ...] = () diff --git a/control_plane/contracts/owner_acceptance.py b/control_plane/contracts/owner_acceptance.py index 396654f8b..c4b9ad616 100644 --- a/control_plane/contracts/owner_acceptance.py +++ b/control_plane/contracts/owner_acceptance.py @@ -748,27 +748,24 @@ def _validate_event(self) -> "OwnerAcceptanceEventRecord": def owner_acceptance_human_action_semantics( action: OwnerAcceptanceAction | None, ) -> OwnerAcceptanceHumanActionSemantics: - """Project a stored human action into machine-readable non-authority semantics. + """Project a stored human action into machine-readable review semantics. - The stored enum never changes. This projection exists so an API or UI client - cannot read L1 ``accepted`` as merge readiness, landed state, or production - authorization. + The stored enum never changes. Owner acceptance is an authoritative prerequisite + for merge admission, but remains distinct from technical readiness, landing, and + production authorization. """ if action is None: return "none" return _HUMAN_ACTION_SEMANTICS[action] -def _validate_non_authority( +def _validate_decision( *, admissible: bool, status: OwnerAcceptanceDecisionStatus, - authorizes: tuple[str, ...], current_event: "OwnerAcceptanceEventRecord | None", human_action_semantics: OwnerAcceptanceHumanActionSemantics, ) -> None: - if authorizes: - raise ValueError("Owner product review never authorizes merge, release, or production") if admissible and status != "accepted": raise ValueError("Only a currently accepted Owner product review can be admissible") expected_semantics = owner_acceptance_human_action_semantics( @@ -792,16 +789,14 @@ class OwnerAcceptanceProductDecision(BaseModel): current_event: OwnerAcceptanceEventRecord | None = None admissible: bool = False human_action_semantics: OwnerAcceptanceHumanActionSemantics = "none" - authorizes: tuple[str, ...] = () @model_validator(mode="after") def _validate_product_decision(self) -> "OwnerAcceptanceProductDecision": if self.schema_version != 1: raise ValueError("Unsupported Owner acceptance product decision schema version.") - _validate_non_authority( + _validate_decision( admissible=self.admissible, status=self.status, - authorizes=self.authorizes, current_event=self.current_event, human_action_semantics=self.human_action_semantics, ) @@ -883,16 +878,12 @@ class OwnerAcceptanceDecision(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) schema_version: int = Field(default=1, ge=1) - mode: Literal["shadow"] = "shadow" - authoritative: Literal[False] = False - enforcement_effect: Literal["none"] = "none" status: OwnerAcceptanceDecisionStatus reason_code: OwnerAcceptanceReasonCode binding: OwnerAcceptanceBinding | None = None current_event: OwnerAcceptanceEventRecord | None = None admissible: bool = False human_action_semantics: OwnerAcceptanceHumanActionSemantics = "none" - authorizes: tuple[str, ...] = () products: tuple[OwnerAcceptanceProductDecision, ...] = () evaluated_at: str @@ -900,10 +891,9 @@ class OwnerAcceptanceDecision(BaseModel): def _validate_decision(self) -> "OwnerAcceptanceDecision": if self.schema_version != 1: raise ValueError("Unsupported Owner acceptance decision schema version.") - _validate_non_authority( + _validate_decision( admissible=self.admissible, status=self.status, - authorizes=self.authorizes, current_event=self.current_event, human_action_semantics=self.human_action_semantics, ) @@ -990,6 +980,7 @@ def owner_acceptance_event_replay_digest(record: OwnerAcceptanceEventRecord) -> authorization = payload.get("authorization") if isinstance(authorization, dict): authorization.pop("authorized_at", None) + authorization.pop("owner_login", None) return _canonical_sha256(payload) diff --git a/control_plane/contracts/product_owner.py b/control_plane/contracts/product_owner.py index aecae546b..e96989c74 100644 --- a/control_plane/contracts/product_owner.py +++ b/control_plane/contracts/product_owner.py @@ -15,10 +15,9 @@ PRODUCT_OWNER_REQUIREMENT_WRITE_ACTION = "product_owner_requirement.write" PRODUCT_OWNER_ROUTING_READ_ACTION = "product_owner_routing.read" PRODUCT_OWNER_ROUTING_WRITE_ACTION = "product_owner_routing.write" -PRODUCT_OWNER_SHADOW_READ_ACTION = "product_owner_shadow.read" ProductOwnerRecordStatus = Literal["active", "superseded"] -ProductOwnerShadowDecision = Literal[ +ProductOwnerAuthorityDecision = Literal[ "authorized", "denied", "not_required", @@ -465,7 +464,6 @@ class ProductOwnerRequirementRecord(BaseModel): system: str requirement_revision: int = Field(ge=1) requirements: tuple[ProductOwnerRequirement, ...] = () - enforcement_mode: Literal["shadow"] = "shadow" effective_at: str source: str reason: str @@ -608,14 +606,11 @@ def _validate_context(self) -> "ProductOwnerActionContext": return self -class ProductOwnerShadowEvaluation(BaseModel): +class ProductOwnerAuthorityEvaluation(BaseModel): model_config = ConfigDict(extra="forbid") schema_version: int = Field(default=1, ge=1) - mode: Literal["shadow"] = "shadow" - authoritative: Literal[False] = False - enforcement_effect: Literal["none"] = "none" - decision: ProductOwnerShadowDecision + decision: ProductOwnerAuthorityDecision reason_code: str context: ProductOwnerActionContext actor_identity_id: str diff --git a/control_plane/github_app_identity.py b/control_plane/github_app_identity.py index a5bc1121b..90f03ea7a 100644 --- a/control_plane/github_app_identity.py +++ b/control_plane/github_app_identity.py @@ -147,65 +147,85 @@ def mint_repository_installation_token( "GitHub App installation token response requires token.", error_type=GitHubAppIdentityError, ) - expires_at = required_string_text( - token_payload.get("expires_at"), - "GitHub App installation token response requires expires_at.", - error_type=GitHubAppIdentityError, - ) - if _parse_github_timestamp(expires_at) <= issued_at + timedelta(minutes=1): - raise GitHubAppIdentityError( - "GitHub App installation token expiry is not safely in the future." - ) - _validate_permissions(token_payload.get("permissions"), label="installation token") - repositories = token_payload.get("repositories") - if not isinstance(repositories, list) or len(repositories) != 1: - raise GitHubAppIdentityError( - "GitHub App installation token must be scoped to exactly one repository." + try: + expires_at = required_string_text( + token_payload.get("expires_at"), + "GitHub App installation token response requires expires_at.", + error_type=GitHubAppIdentityError, ) - repository_payload = json_object( - repositories[0], - "GitHub App installation token repository", - error_type=GitHubAppIdentityError, - ) - observed_repository_id = required_positive_int( - repository_payload.get("id"), - "GitHub App installation token repository requires id.", - error_type=GitHubAppIdentityError, - ) - if observed_repository_id != int(repository_id): - raise GitHubAppIdentityError( - "GitHub App installation token repository does not match exact repository id." + if _parse_github_timestamp(expires_at) <= issued_at + timedelta(minutes=1): + raise GitHubAppIdentityError( + "GitHub App installation token expiry is not safely in the future." + ) + _validate_permissions(token_payload.get("permissions"), label="installation token") + repositories = token_payload.get("repositories") + if not isinstance(repositories, list) or len(repositories) != 1: + raise GitHubAppIdentityError( + "GitHub App installation token must be scoped to exactly one repository." + ) + repository_payload = json_object( + repositories[0], + "GitHub App installation token repository", + error_type=GitHubAppIdentityError, ) - if ( - required_string_text( - repository_payload.get("full_name"), - "GitHub App installation token repository requires full_name.", + observed_repository_id = required_positive_int( + repository_payload.get("id"), + "GitHub App installation token repository requires id.", error_type=GitHubAppIdentityError, - ).casefold() - != normalized_repository.casefold() - ): - raise GitHubAppIdentityError( - "GitHub App installation token repository does not match exact repository name." ) - return GitHubAppInstallationToken( - token=token, - app_id=identity.app_id, - installation_id=installation_id, - repository_id=observed_repository_id, - repository=normalized_repository, - expires_at=expires_at, - ) + if observed_repository_id != int(repository_id): + raise GitHubAppIdentityError( + "GitHub App installation token repository does not match exact repository id." + ) + if ( + required_string_text( + repository_payload.get("full_name"), + "GitHub App installation token repository requires full_name.", + error_type=GitHubAppIdentityError, + ).casefold() + != normalized_repository.casefold() + ): + raise GitHubAppIdentityError( + "GitHub App installation token repository does not match exact repository name." + ) + return GitHubAppInstallationToken( + token=token, + app_id=identity.app_id, + installation_id=installation_id, + repository_id=observed_repository_id, + repository=normalized_repository, + expires_at=expires_at, + ) + except Exception as validation_error: + try: + _revoke_installation_token_value(token=token, api_request=api_request) + except Exception as revocation_error: + validation_error.add_note( + f"GitHub App installation token revocation also failed: {revocation_error}" + ) + raise def revoke_installation_token( *, installation_token: GitHubAppInstallationToken, api_request: GitHubApiRequest = github_api_request, +) -> None: + _revoke_installation_token_value( + token=installation_token.token, + api_request=api_request, + ) + + +def _revoke_installation_token_value( + *, + token: str, + api_request: GitHubApiRequest, ) -> None: response = _github_api_request( api_request, path="/installation/token", - token=installation_token.token, + token=token, method="DELETE", ) if response is not None: diff --git a/control_plane/http_app.py b/control_plane/http_app.py index cf492583a..726a49348 100644 --- a/control_plane/http_app.py +++ b/control_plane/http_app.py @@ -69,6 +69,8 @@ from control_plane import live_target_runtime as control_plane_live_target_runtime from control_plane.change_impact_github import GitHubChangeImpactRepositoryEvidenceProvider from control_plane.change_impact_service import ChangeImpactRepositoryEvidenceProvider +from control_plane.contracts.change_impact import ChangeImpactTargetReference +from control_plane.contracts.owner_acceptance import OwnerAcceptanceDecisionStatus from control_plane.engineering_review_service import ( EngineeringReviewTargetResolver, resolve_engineering_review_pull_request_target, @@ -78,6 +80,10 @@ mint_repository_installation_token, resolve_advisory_github_app_identity, ) +from control_plane.owner_acceptance_projection import ( + OwnerAcceptanceProjectionService, + owner_acceptance_workbench_reference_url, +) from control_plane.http_routes import ( AcceptedEvidenceResponse as AcceptedEvidenceResponse, BackupGateEvidenceRequest as BackupGateEvidenceRequest, @@ -3714,6 +3720,19 @@ def create_launchplane_fastapi_app( token_context=_LAUNCHPLANE_SERVICE_CONTEXT, ) ) + owner_acceptance_projection_service = OwnerAcceptanceProjectionService( + repository_evidence_provider=resolved_change_impact_repository_evidence_provider, + github_app_token=lambda repository, repository_id: mint_repository_installation_token( + identity=resolve_advisory_github_app_identity( + control_plane_root=resolved_control_plane_root + ), + repository=repository, + repository_id=repository_id, + api_request=github_api_request, + ), + public_origin=(human_session_manager.public_origin if human_session_manager else None), + api_request=github_api_request, + ) resolved_engineering_review_target_resolver = ( engineering_review_target_resolver if engineering_review_target_resolver is not None @@ -17357,6 +17376,33 @@ async def apply_preview_pr_feedback( if supports_every_code_work_requests(record_store) else None ) + owner_review_status: OwnerAcceptanceDecisionStatus | None = None + owner_review_url = "" + if feedback_request.status == "ready": + owner_target = ChangeImpactTargetReference( + repository=feedback_request.repository, + pull_request_number=feedback_request.anchor_pr_number, + ) + try: + projection_outcome = await run_in_threadpool( + owner_acceptance_projection_service.reconcile_if_required, + store=record_store, + target=owner_target, + source_event_id=normalized_key, + ) + owner_review_status = projection_outcome.decision.status + if projection_outcome.result is not None and human_session_manager is not None: + owner_review_url = owner_acceptance_workbench_reference_url( + public_origin=human_session_manager.public_origin, + repository=feedback_request.repository, + pull_request_number=feedback_request.anchor_pr_number, + ) + except Exception: + owner_review_status = "unavailable" + owner_review_url = "" + _LOGGER.exception( + "Owner acceptance GitHub projection refresh failed during preview feedback." + ) try: feedback_record = build_preview_pr_feedback_record( control_plane_root=resolved_control_plane_root, @@ -17382,6 +17428,8 @@ async def apply_preview_pr_feedback( if callable(getattr(record_store, "list_preview_records", None)) else None ), + owner_review_status=owner_review_status, + owner_review_url=owner_review_url, ) except click.ClickException as error: raise _launchplane_http_error( @@ -21098,6 +21146,7 @@ def preserve_renewed_session_cookie(request: Request, response: JSONResponse) -> ), github_api=github_api_request, public_origin=(human_session_manager.public_origin if human_session_manager else None), + projection_service=owner_acceptance_projection_service, ), ) register_governance_projection_routes( diff --git a/control_plane/http_routes/__init__.py b/control_plane/http_routes/__init__.py index 01146f70b..9f6207494 100644 --- a/control_plane/http_routes/__init__.py +++ b/control_plane/http_routes/__init__.py @@ -77,7 +77,7 @@ PRODUCT_OWNER_REQUIREMENT_READ_ROUTE, PRODUCT_OWNER_ROUTING_APPLY_ROUTE, PRODUCT_OWNER_ROUTING_READ_ROUTE, - PRODUCT_OWNER_SHADOW_EVALUATION_ROUTE, + PRODUCT_OWNER_AUTHORITY_EVALUATION_ROUTE, ProductOwnerWriteRouteDependencies, register_product_owner_read_routes, register_product_owner_write_routes, @@ -153,7 +153,7 @@ "PRODUCT_OWNER_REQUIREMENT_READ_ROUTE", "PRODUCT_OWNER_ROUTING_APPLY_ROUTE", "PRODUCT_OWNER_ROUTING_READ_ROUTE", - "PRODUCT_OWNER_SHADOW_EVALUATION_ROUTE", + "PRODUCT_OWNER_AUTHORITY_EVALUATION_ROUTE", "REPOSITORY_HUMAN_ROLE_POLICY_APPLY_ROUTE", "TENANT_ADMISSION_CONTROLLER_RUN_ONCE_ROUTE", "TENANT_ADMISSION_STATUS_RECONCILE_ROUTE", diff --git a/control_plane/http_routes/owner_acceptance.py b/control_plane/http_routes/owner_acceptance.py index 62a592546..3936163b8 100644 --- a/control_plane/http_routes/owner_acceptance.py +++ b/control_plane/http_routes/owner_acceptance.py @@ -25,10 +25,7 @@ OwnerAcceptanceViewerBindingEligibility, owner_acceptance_human_action_semantics, ) -from control_plane.github_app_identity import ( - GitHubAppInstallationToken, - revoke_installation_token, -) +from control_plane.github_app_identity import GitHubAppInstallationToken from control_plane.http_routes.support import ( ApiRouteRegistrar, ReadRouteDependencies, @@ -55,7 +52,10 @@ OwnerAcceptanceCurrentItemsRepositoryFailure, build_owner_acceptance_current_items, ) -from control_plane.owner_acceptance_projection import project_owner_acceptance_decision +from control_plane.owner_acceptance_projection import ( + OwnerAcceptanceProjectionReconciliationError, + OwnerAcceptanceProjectionService, +) from control_plane.service_auth import AuthorizationTarget, GitHubHumanIdentity, LaunchplaneIdentity from control_plane.workflows.launchplane import github_api_request @@ -71,6 +71,14 @@ OWNER_ACCEPTANCE_PROJECT_ROUTE = "/v1/owner-acceptance/project" +class OwnerAcceptanceProjectionUnavailableError(RuntimeError): + pass + + +class OwnerAcceptanceProjectionReconciliationRequiredError(RuntimeError): + pass + + @dataclass(frozen=True, slots=True) class OwnerAcceptanceRouteDependencies: common: ReadRouteDependencies @@ -80,6 +88,7 @@ class OwnerAcceptanceRouteDependencies: github_app_token: Callable[[str, str], GitHubAppInstallationToken] | None = None github_api: Callable[..., object] = github_api_request public_origin: str | None = None + projection_service: OwnerAcceptanceProjectionService | None = None class OwnerAcceptanceEventEnvelope(BaseModel): @@ -147,24 +156,14 @@ class OwnerAcceptanceEvaluationResponse(BaseModel): class OwnerAcceptanceEventSemantics(BaseModel): """Machine-readable projection of a stored human product-review action. - The stored enum and every persisted digest stay unchanged. This projection - exists so no API client can read Owner product review as merge readiness, - landed state, or production authorization. + The stored enum and every persisted digest stay unchanged. Acceptance is an + authoritative merge-admission prerequisite, while technical readiness, + landing, and production authorization remain separate decisions. """ model_config = ConfigDict(extra="forbid") human_action_semantics: OwnerAcceptanceHumanActionSemantics - authorizes: tuple[str, ...] = Field( - default=(), - description="Always empty. Owner product review authorizes nothing on its own.", - ) - - @model_validator(mode="after") - def _validate_semantics(self) -> "OwnerAcceptanceEventSemantics": - if self.authorizes: - raise ValueError("Owner product review never authorizes merge, release, or production") - return self class OwnerAcceptanceEventResponse(BaseModel): @@ -192,9 +191,6 @@ class OwnerAcceptanceQueueResponse(BaseModel): status: Literal["ok"] = "ok" trace_id: str - mode: Literal["shadow"] = "shadow" - authoritative: Literal[False] = False - enforcement_effect: Literal["none"] = "none" derivation: Literal["ledger_only"] = "ledger_only" generated_at: str total: int @@ -210,9 +206,6 @@ class OwnerAcceptanceCurrentItemsResponse(BaseModel): status: Literal["ok"] = "ok" trace_id: str - mode: Literal["shadow"] = "shadow" - authoritative: Literal[False] = False - enforcement_effect: Literal["none"] = "none" derivation: Literal["active_change_impact_open_pull_requests"] = ( "active_change_impact_open_pull_requests" ) @@ -256,46 +249,6 @@ def _event_semantics(record: OwnerAcceptanceEventRecord) -> OwnerAcceptanceEvent ) -def _validate_projection_target( - decision: OwnerAcceptanceDecision, - target: ChangeImpactTarget, -) -> None: - bindings = tuple( - product.binding for product in decision.products if product.binding is not None - ) - if decision.binding is not None: - bindings = (decision.binding, *bindings) - for binding in bindings: - if ( - binding.repository.casefold() != target.repository.casefold() - or binding.repository_id != target.repository_id - or binding.repository_owner_id != target.repository_owner_id - or binding.pull_request_number != target.pull_request_number - or binding.head_sha != target.head_sha - or binding.tree_sha != target.tree_sha - ): - raise ValueError("Owner acceptance projection target changed during evaluation.") - - -def _resolve_owner_acceptance_projection_state( - *, - store: object, - target: ChangeImpactTargetReference, - repository_evidence_provider: ChangeImpactRepositoryEvidenceProvider, -) -> tuple[OwnerAcceptanceDecision, ChangeImpactTarget]: - initial_evidence = repository_evidence_provider.resolve(target) - decision = evaluate_owner_acceptance( - store=store, - target=target, - repository_evidence_provider=repository_evidence_provider, - ) - evidence = repository_evidence_provider.resolve(target) - if initial_evidence.target != evidence.target: - raise ValueError("Owner acceptance projection target changed during evaluation.") - _validate_projection_target(decision, evidence.target) - return decision, evidence.target - - def register_owner_acceptance_routes( app: ApiRouteRegistrar, *, @@ -303,6 +256,12 @@ def register_owner_acceptance_routes( ) -> None: common = dependencies.common projection_identity = dependencies.read_write_identity or common.read_identity + projection_service = dependencies.projection_service or OwnerAcceptanceProjectionService( + repository_evidence_provider=dependencies.repository_evidence_provider, + github_app_token=dependencies.github_app_token, + public_origin=dependencies.public_origin, + api_request=dependencies.github_api, + ) def viewer_capabilities( *, @@ -331,54 +290,6 @@ def viewer_capabilities( bindings=bindings, ) - def project_current_decision( - *, - target: ChangeImpactTargetReference, - record_store: object, - ) -> tuple[OwnerAcceptanceDecision, AdvisoryCheckProjectionResult]: - if dependencies.github_app_token is None: - raise ValueError("Owner acceptance projection identity is unavailable.") - if dependencies.public_origin is None: - raise ValueError("Owner acceptance projection public origin is unavailable.") - decision, resolved_target = _resolve_owner_acceptance_projection_state( - store=record_store, - target=target, - repository_evidence_provider=dependencies.repository_evidence_provider, - ) - installation_token = dependencies.github_app_token( - resolved_target.repository, - resolved_target.repository_id, - ) - try: - result = project_owner_acceptance_decision( - decision=decision, - target=resolved_target, - public_origin=dependencies.public_origin, - installation_token=installation_token, - api_request=dependencies.github_api, - ) - finally: - revoke_installation_token( - installation_token=installation_token, - api_request=dependencies.github_api, - ) - return decision, result - - def project_current_decision_best_effort( - *, - target: ChangeImpactTargetReference, - record_store: object, - ) -> None: - if dependencies.github_app_token is None or dependencies.public_origin is None: - return - try: - project_current_decision(target=target, record_store=record_store) - except Exception: - logger.warning( - "Owner acceptance advisory projection failed after event write.", - exc_info=True, - ) - def evaluate( repository: Annotated[ str, @@ -474,18 +385,93 @@ def write_event( message="Caller cannot write Owner acceptance events.", ) try: - result: OwnerAcceptanceWriteResult = record_owner_acceptance_event( + projection_service.resolve_current( store=record_store, - repository_evidence_provider=dependencies.repository_evidence_provider, target=envelope.target, - identity=identity, - action=envelope.action, - expected_binding_sha256=envelope.expected_binding_sha256, - source_event_kind="browser_api", - source_event_id=idempotency_key, - reason=envelope.reason, - resolution=envelope.resolution, ) + except (OwnerAcceptanceEvaluationUnavailableError, TypeError, ValueError) as error: + raise common.http_error( + status_code=503, + trace_id=trace_id, + code="owner_acceptance_projection_unavailable", + message=str(error), + ) from error + + try: + with projection_service.lock_current( + store=record_store, + target=envelope.target, + ) as locked_projection_target: + + def project_conservative_check(record: OwnerAcceptanceEventRecord) -> None: + binding = record.binding + record_target = ChangeImpactTarget( + repository_id=binding.repository_id, + repository_owner_id=binding.repository_owner_id, + repository=binding.repository, + pull_request_number=binding.pull_request_number, + head_sha=binding.head_sha, + tree_sha=binding.tree_sha, + ) + try: + projection_service.project_conservative_locked( + lock_target=locked_projection_target, + exact_target=record_target, + source_event_id=idempotency_key, + ) + except Exception as error: + raise OwnerAcceptanceProjectionUnavailableError(str(error)) from error + + result: OwnerAcceptanceWriteResult = record_owner_acceptance_event( + store=record_store, + repository_evidence_provider=dependencies.repository_evidence_provider, + target=envelope.target, + identity=identity, + action=envelope.action, + expected_binding_sha256=envelope.expected_binding_sha256, + source_event_kind="browser_api", + source_event_id=idempotency_key, + reason=envelope.reason, + resolution=envelope.resolution, + before_write=project_conservative_check, + ) + try: + projection_outcome = projection_service.reconcile_locked( + store=record_store, + target=envelope.target, + lock_target=locked_projection_target, + source_event_id=idempotency_key, + ) + final_decision = projection_outcome.decision + except OwnerAcceptanceProjectionReconciliationError as error: + raise OwnerAcceptanceProjectionReconciliationRequiredError( + str(error) + ) from error + except OwnerAcceptanceProjectionUnavailableError as error: + raise common.http_error( + status_code=503, + trace_id=trace_id, + code="owner_acceptance_projection_unavailable", + message=( + "Owner acceptance GitHub status could not be made fail-closed; " + "no event was persisted." + ), + ) from error + except OwnerAcceptanceProjectionReconciliationRequiredError as error: + logger.error( + "Owner acceptance event persisted but final GitHub projection failed.", + exc_info=True, + ) + raise common.http_error( + status_code=503, + trace_id=trace_id, + code="owner_acceptance_projection_reconciliation_required", + message=( + "Owner acceptance event was persisted, but the final GitHub status " + "projection requires reconciliation. Retry with the same Idempotency-Key " + "or use the Owner acceptance projection endpoint." + ), + ) from error except OwnerAcceptanceSelfReviewDeniedError as error: raise common.http_error( status_code=403, @@ -535,16 +521,12 @@ def write_event( code="database_storage_required", message=str(error), ) from error - project_current_decision_best_effort( - target=envelope.target, - record_store=record_store, - ) return OwnerAcceptanceEventResponse( trace_id=trace_id, write_status=result.status, record=result.record, semantics=_event_semantics(result.record), - decision=result.decision, + decision=final_decision, ) def read_event( @@ -706,7 +688,7 @@ def read_current_items( repository_failures=result.repository_failures, ) - async def project( + def project( request: OwnerAcceptanceProjectionRequest, identity: Annotated[LaunchplaneIdentity, Depends(projection_identity)], record_store: Annotated[object, Depends(common.get_record_store)], @@ -726,21 +708,29 @@ async def project( message="Caller cannot project Owner acceptance decisions.", ) try: - decision, result = project_current_decision( + projection_outcome = projection_service.reconcile( + store=record_store, target=request.target, - record_store=record_store, + source_event_id="manual-projection-reconciliation", ) - except (OwnerAcceptanceEvaluationUnavailableError, TypeError, ValueError) as error: + except ( + OwnerAcceptanceEvaluationUnavailableError, + OwnerAcceptanceProjectionReconciliationError, + TypeError, + ValueError, + ) as error: raise common.http_error( status_code=503, trace_id=trace_id, code="owner_acceptance_projection_unavailable", message=str(error), ) from error + if projection_outcome.result is None: + raise RuntimeError("Owner acceptance projection did not produce a GitHub result.") return OwnerAcceptanceProjectionResponse( trace_id=trace_id, - decision=decision, - result=result, + decision=projection_outcome.decision, + result=projection_outcome.result, ) errors = { diff --git a/control_plane/http_routes/product_owner.py b/control_plane/http_routes/product_owner.py index 64781a6a3..0acc8f687 100644 --- a/control_plane/http_routes/product_owner.py +++ b/control_plane/http_routes/product_owner.py @@ -12,14 +12,14 @@ PRODUCT_OWNER_REQUIREMENT_WRITE_ACTION, PRODUCT_OWNER_ROUTING_READ_ACTION, PRODUCT_OWNER_ROUTING_WRITE_ACTION, - PRODUCT_OWNER_SHADOW_READ_ACTION, ProductOwnerActionContext, ProductOwnerActorIdentity, ProductOwnerPolicyRecord, ProductOwnerRequirementRecord, ProductOwnerRoutingRecord, - ProductOwnerShadowEvaluation, + ProductOwnerAuthorityEvaluation, ) +from control_plane.contracts.owner_acceptance import OWNER_ACCEPTANCE_READ_ACTION from control_plane.http_routes.support import ( ApiRouteRegistrar, AuthorizationAllows, @@ -40,7 +40,7 @@ apply_product_owner_policy, apply_product_owner_requirement, apply_product_owner_routing, - evaluate_product_owner_shadow_authority, + evaluate_product_owner_authority, get_product_owner_read_model, require_product_owner_policy_read_store, require_product_owner_policy_store, @@ -55,7 +55,7 @@ PRODUCT_OWNER_POLICY_READ_ROUTE = "/v1/product-owner/policy" PRODUCT_OWNER_REQUIREMENT_READ_ROUTE = "/v1/product-owner/requirement" PRODUCT_OWNER_ROUTING_READ_ROUTE = "/v1/product-owner/routing" -PRODUCT_OWNER_SHADOW_EVALUATION_ROUTE = "/v1/product-owner/shadow-evaluation" +PRODUCT_OWNER_AUTHORITY_EVALUATION_ROUTE = "/v1/product-owner/evaluation" PRODUCT_OWNER_POLICY_APPLY_ROUTE = "/v1/product-owner/policies/apply" PRODUCT_OWNER_REQUIREMENT_APPLY_ROUTE = "/v1/product-owner/requirements/apply" PRODUCT_OWNER_ROUTING_APPLY_ROUTE = "/v1/product-owner/routing/apply" @@ -79,12 +79,12 @@ class ProductOwnerReadResponse(BaseModel): read_model: ProductOwnerReadModel -class ProductOwnerShadowEvaluationResponse(BaseModel): +class ProductOwnerAuthorityEvaluationResponse(BaseModel): model_config = ConfigDict(extra="forbid") status: Literal["ok"] = "ok" trace_id: str - evaluation: ProductOwnerShadowEvaluation + evaluation: ProductOwnerAuthorityEvaluation class ProductOwnerPolicyApplyEnvelope(BaseModel): @@ -236,7 +236,7 @@ def read_routing( record_store, ) - def read_shadow_evaluation( + def read_authority_evaluation( product: Annotated[str, Query(...)], system: Annotated[str, Query(...)], repository_id: Annotated[str, Query(...)], @@ -248,7 +248,7 @@ def read_shadow_evaluation( claimed_policy_digest: Annotated[str, Query()] = "", claimed_requirement_revision: Annotated[int | None, Query(ge=1)] = None, claimed_requirement_digest: Annotated[str, Query()] = "", - ) -> ProductOwnerShadowEvaluationResponse: + ) -> ProductOwnerAuthorityEvaluationResponse: trace_id = dependencies.next_trace_id() try: context = ProductOwnerActionContext( @@ -267,7 +267,7 @@ def read_shadow_evaluation( ) from error if not dependencies.authorization_allows( identity=identity, - action=PRODUCT_OWNER_SHADOW_READ_ACTION, + action=OWNER_ACCEPTANCE_READ_ACTION, product=context.product, context=context.system, target=AuthorizationTarget(scope="context"), @@ -276,7 +276,7 @@ def read_shadow_evaluation( status_code=403, trace_id=trace_id, code="authorization_denied", - message="Caller cannot read product Owner shadow evaluation.", + message="Caller cannot read product Owner authority evaluation.", ) try: actor = _actor_identity(identity) @@ -285,7 +285,7 @@ def read_shadow_evaluation( status_code=403, trace_id=trace_id, code="product_owner_actor_identity_required", - message="Product Owner shadow evaluation requires a human GitHub identity.", + message="Product Owner authority evaluation requires a human GitHub identity.", ) from error try: policies = require_product_owner_policy_read_store( @@ -311,9 +311,9 @@ def read_shadow_evaluation( status_code=503, trace_id=trace_id, code="database_storage_required", - message="Product Owner shadow storage is unavailable.", + message="Product Owner authority storage is unavailable.", ) from error - evaluation = evaluate_product_owner_shadow_authority( + evaluation = evaluate_product_owner_authority( context=context, actor=actor, policies=policies, @@ -324,7 +324,7 @@ def read_shadow_evaluation( claimed_requirement_revision=claimed_requirement_revision, claimed_requirement_digest=claimed_requirement_digest, ) - return ProductOwnerShadowEvaluationResponse(trace_id=trace_id, evaluation=evaluation) + return ProductOwnerAuthorityEvaluationResponse(trace_id=trace_id, evaluation=evaluation) errors = { 400: {"model": dependencies.error_response_model}, @@ -337,13 +337,13 @@ def read_shadow_evaluation( PRODUCT_OWNER_POLICY_READ_ROUTE, read_policy, "read_product_owner_policy", - "Read the shadow product Owner policy bundle", + "Read the product Owner policy bundle", ), ( PRODUCT_OWNER_REQUIREMENT_READ_ROUTE, read_requirement, "read_product_owner_requirement", - "Read the shadow product Owner requirement bundle", + "Read the product Owner requirement bundle", ), ( PRODUCT_OWNER_ROUTING_READ_ROUTE, @@ -362,12 +362,12 @@ def read_shadow_evaluation( responses=errors, ) app.add_api_route( - PRODUCT_OWNER_SHADOW_EVALUATION_ROUTE, - read_shadow_evaluation, + PRODUCT_OWNER_AUTHORITY_EVALUATION_ROUTE, + read_authority_evaluation, methods=["GET"], - response_model=ProductOwnerShadowEvaluationResponse, - operation_id="read_product_owner_shadow_evaluation", - summary="Evaluate product Owner authority without changing enforcement", + response_model=ProductOwnerAuthorityEvaluationResponse, + operation_id="read_product_owner_authority_evaluation", + summary="Evaluate current product Owner authority", responses=errors, ) diff --git a/control_plane/owner_acceptance.py b/control_plane/owner_acceptance.py index d8d7a39e7..fdc4e7345 100644 --- a/control_plane/owner_acceptance.py +++ b/control_plane/owner_acceptance.py @@ -1,5 +1,7 @@ from __future__ import annotations +from collections.abc import Callable +from contextlib import AbstractContextManager from datetime import datetime, timezone from typing import NamedTuple, Protocol, cast @@ -36,8 +38,10 @@ OwnerAcceptanceSourceEventKind, OwnerAcceptanceViewerBindingEligibility, OwnerAcceptanceViewerEligibilityReason, + owner_acceptance_event_replay_digest, owner_acceptance_human_action_semantics, owner_acceptance_runtime_identity_binding, + validate_owner_acceptance_event_transition, ) from control_plane.contracts.preview_generation_record import PreviewGenerationRecord from control_plane.contracts.preview_record import PreviewRecord @@ -52,7 +56,7 @@ product_owner_scoped_policy_fingerprint, ) from control_plane.product_owner_service import ( - evaluate_product_owner_shadow_authority, + evaluate_product_owner_authority, require_product_owner_policy_read_store, require_product_owner_requirement_read_store, ) @@ -86,6 +90,10 @@ class OwnerAcceptanceReviewContextUnavailableError(OwnerAcceptanceEvaluationUnav """Raised when server-owned reviewed base/authorship context cannot be bound.""" +class OwnerAcceptanceAuthorityDeniedError(PermissionError): + """Raised when configured Owner authority explicitly excludes the target scope.""" + + _PREVIEW_ISOLATION_BY_TRANSPORT_MODE: dict[str, ProductOwnerPreviewIsolationClass] = { "none": "no_product_data", "bootstrap": "synthetic_seeded", @@ -120,6 +128,15 @@ def read_owner_acceptance_event_record( ) -> OwnerAcceptanceEventRecord: ... +class OwnerAcceptanceProjectionLockStore(Protocol): + def owner_acceptance_projection_lock( + self, + *, + repository_id: str, + pull_request_number: int, + ) -> AbstractContextManager[None]: ... + + class OwnerAcceptancePreviewReadStore(Protocol): def read_product_profile_record(self, product: str) -> LaunchplaneProductProfileRecord: ... @@ -141,6 +158,14 @@ class OwnerAcceptanceWriteResult(NamedTuple): decision: OwnerAcceptanceDecision +def require_owner_acceptance_projection_lock_store( + store: object, +) -> OwnerAcceptanceProjectionLockStore: + if callable(getattr(store, "owner_acceptance_projection_lock", None)): + return cast(OwnerAcceptanceProjectionLockStore, store) + raise TypeError("record store does not support Owner acceptance projection locking") + + class OwnerAcceptanceImpactEvidence(NamedTuple): repository_evidence: ChangeImpactRepositoryEvidence impact: ChangeImpactEvaluation @@ -215,7 +240,7 @@ def evaluate_owner_acceptance_viewer_eligibility( ) policies = policy_cache[cache_key] requirements = requirement_cache[cache_key] - authority = evaluate_product_owner_shadow_authority( + authority = evaluate_product_owner_authority( context=ProductOwnerActionContext( product=binding.product, system=binding.system, @@ -333,6 +358,7 @@ def record_owner_acceptance_event( reason: str = "", resolution: OwnerAcceptanceResolutionEvidence | None = None, occurred_at: str = "", + before_write: Callable[[OwnerAcceptanceEventRecord], None] | None = None, ) -> OwnerAcceptanceWriteResult: if action not in {"accepted", "changes_requested", "revoked"}: raise OwnerAcceptanceAuthorizationError("Human route cannot write system-only events.") @@ -471,6 +497,33 @@ def record_owner_acceptance_event( raise OwnerAcceptanceEvaluationUnavailableError( "Preview-bound Owner acceptance cannot downgrade to an exact-change-only binding." ) + existing = next( + (event for event in target_events if event.event_id == record.event_id), + None, + ) + if existing is not None: + if owner_acceptance_event_replay_digest(existing) != owner_acceptance_event_replay_digest( + record + ): + raise OwnerAcceptanceEventConflictError( + "Owner acceptance event replay changed the persisted payload." + ) + else: + previous = ( + max(prior_events, key=lambda event: event.subject_sequence) if prior_events else None + ) + validate_owner_acceptance_event_transition( + previous=previous, + proposed=record.model_copy( + update={ + "subject_sequence": ( + previous.subject_sequence + 1 if previous is not None else 1 + ) + } + ), + ) + if before_write is not None: + before_write(record) write_status = event_store.write_owner_acceptance_event_record(record) record = event_store.read_owner_acceptance_event_record(record.event_id) folded_events = ( @@ -705,6 +758,15 @@ def _evaluate_owner_acceptance_product( evaluated_at=evaluated_at, ) return _product_decision(affected_product=affected_product, decision=decision) + except OwnerAcceptanceAuthorityDeniedError: + return _product_decision( + affected_product=affected_product, + decision=_decision( + status="unavailable", + reason_code="owner_authority_denied", + evaluated_at=evaluated_at, + ), + ) if subject is None: return _product_decision( affected_product=affected_product, @@ -860,8 +922,9 @@ def _binding_from_impact( for owner in active_policies[0].owners ) authority = None + authority_denied = False for owner_actor in owner_actors: - candidate = evaluate_product_owner_shadow_authority( + candidate = evaluate_product_owner_authority( context=context, actor=owner_actor, policies=policies, @@ -876,11 +939,17 @@ def _binding_from_impact( if candidate.decision == "authorized": authority = candidate break + if candidate.decision == "denied" or candidate.reason_code == "policy_scope_not_covered": + authority_denied = True if authority is None: if expected_binding_sha256: raise OwnerAcceptanceBindingConflictError( "Owner acceptance binding changed; evaluate the exact change again before recording." ) + if authority_denied: + raise OwnerAcceptanceAuthorityDeniedError( + "Configured product Owner authority does not cover this exact scope." + ) return None if ( not authority.policy_record_id @@ -940,7 +1009,7 @@ def _binding_from_impact( "Owner acceptance binding changed; evaluate the exact change again before recording." ) if actor is not None: - actor_authority = evaluate_product_owner_shadow_authority( + actor_authority = evaluate_product_owner_authority( context=context, actor=actor, policies=policies, diff --git a/control_plane/owner_acceptance_projection.py b/control_plane/owner_acceptance_projection.py index 180629b33..be68bf3f9 100644 --- a/control_plane/owner_acceptance_projection.py +++ b/control_plane/owner_acceptance_projection.py @@ -1,24 +1,299 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from dataclasses import dataclass import hashlib import json +import logging from urllib.parse import quote, urlsplit from control_plane.advisory_check_projection import write_advisory_check_projection +from control_plane.change_impact_service import ChangeImpactRepositoryEvidenceProvider from control_plane.contracts.advisory_check_projection import ( AdvisoryCheckProjection, + AdvisoryCheckConclusion, AdvisoryCheckProjectionResult, OWNER_ACCEPTANCE_CHECK_NAME, ) -from control_plane.contracts.change_impact import ChangeImpactTarget +from control_plane.contracts.change_impact import ChangeImpactTarget, ChangeImpactTargetReference from control_plane.contracts.owner_acceptance import OwnerAcceptanceDecision -from control_plane.github_app_identity import GitHubAppInstallationToken +from control_plane.github_app_identity import ( + GitHubAppInstallationToken, + revoke_installation_token, +) +from control_plane.owner_acceptance import ( + evaluate_owner_acceptance, + require_owner_acceptance_projection_lock_store, +) from control_plane.workflows.launchplane import github_api_request GitHubApiRequest = Callable[..., object] +GitHubAppTokenProvider = Callable[[str, str], GitHubAppInstallationToken] OWNER_ACCEPTANCE_WORKBENCH_PATH = "/ui/engineering/owner-acceptance" +logger = logging.getLogger(__name__) + + +class OwnerAcceptanceProjectionReconciliationError(RuntimeError): + pass + + +@dataclass(frozen=True, slots=True) +class OwnerAcceptanceProjectionOutcome: + decision: OwnerAcceptanceDecision + target: ChangeImpactTarget + result: AdvisoryCheckProjectionResult | None + + +@dataclass(frozen=True, slots=True) +class OwnerAcceptanceProjectionService: + repository_evidence_provider: ChangeImpactRepositoryEvidenceProvider + github_app_token: GitHubAppTokenProvider | None + public_origin: str | None + api_request: GitHubApiRequest = github_api_request + + @contextmanager + def lock_current( + self, + *, + store: object, + target: ChangeImpactTargetReference, + ) -> Iterator[ChangeImpactTarget]: + projection_lock_store = require_owner_acceptance_projection_lock_store(store) + for attempt in range(3): + _, candidate_target = self.resolve_current(store=store, target=target) + with projection_lock_store.owner_acceptance_projection_lock( + repository_id=candidate_target.repository_id, + pull_request_number=candidate_target.pull_request_number, + ): + _, confirmed_target = self.resolve_current(store=store, target=target) + if _projection_targets_share_lock(candidate_target, confirmed_target): + yield confirmed_target + return + logger.warning( + "Owner acceptance projection target identity changed while locking; retrying.", + extra={"projection_lock_attempt": attempt + 1}, + ) + raise ValueError("Owner acceptance projection target identity changed repeatedly.") + + def resolve_current( + self, + *, + store: object, + target: ChangeImpactTargetReference, + ) -> tuple[OwnerAcceptanceDecision, ChangeImpactTarget]: + initial_evidence = self.repository_evidence_provider.resolve(target) + decision = evaluate_owner_acceptance( + store=store, + target=target, + repository_evidence_provider=self.repository_evidence_provider, + ) + evidence = self.repository_evidence_provider.resolve(target) + if initial_evidence.target != evidence.target: + raise ValueError("Owner acceptance projection target changed during evaluation.") + _validate_projection_target(decision, evidence.target) + return decision, evidence.target + + def project_conservative_locked( + self, + *, + lock_target: ChangeImpactTarget, + exact_target: ChangeImpactTarget, + source_event_id: str, + ) -> AdvisoryCheckProjectionResult: + _require_projection_lock_target(lock_target, exact_target) + with self._installation_token(exact_target) as installation_token: + return project_owner_acceptance_update_in_progress( + target=exact_target, + source_event_id=source_event_id, + public_origin=self._public_origin(), + installation_token=installation_token, + api_request=self.api_request, + ) + + def reconcile_locked( + self, + *, + store: object, + target: ChangeImpactTargetReference, + lock_target: ChangeImpactTarget, + source_event_id: str, + ) -> OwnerAcceptanceProjectionOutcome: + attempted_target = lock_target + try: + for attempt in range(3): + decision, resolved_target = self.resolve_current(store=store, target=target) + _require_projection_lock_target(lock_target, resolved_target) + attempted_target = resolved_target + with self._installation_token(resolved_target) as installation_token: + result = project_owner_acceptance_decision( + decision=decision, + target=resolved_target, + public_origin=self._public_origin(), + installation_token=installation_token, + api_request=self.api_request, + ) + confirmed_decision, confirmed_target = self.resolve_current( + store=store, + target=target, + ) + if confirmed_target == resolved_target and owner_acceptance_projection_sha256( + confirmed_decision + ) == owner_acceptance_projection_sha256(decision): + return OwnerAcceptanceProjectionOutcome( + decision=confirmed_decision, + target=confirmed_target, + result=result, + ) + self.project_conservative_locked( + lock_target=lock_target, + exact_target=resolved_target, + source_event_id=source_event_id, + ) + logger.warning( + "Owner acceptance changed during GitHub projection; retrying current state.", + extra={"projection_attempt": attempt + 1}, + ) + raise ValueError("Owner acceptance changed repeatedly during GitHub projection.") + except Exception as error: + try: + self.restore_conservative_locked( + store=store, + target=target, + lock_target=lock_target, + exact_target=attempted_target, + source_event_id=source_event_id, + ) + except Exception as restoration_error: + raise OwnerAcceptanceProjectionReconciliationError( + str(error) + ) from restoration_error + raise OwnerAcceptanceProjectionReconciliationError(str(error)) from error + + def restore_conservative_locked( + self, + *, + store: object, + target: ChangeImpactTargetReference, + lock_target: ChangeImpactTarget, + exact_target: ChangeImpactTarget, + source_event_id: str, + ) -> None: + self.project_conservative_locked( + lock_target=lock_target, + exact_target=exact_target, + source_event_id=source_event_id, + ) + _, current_target = self.resolve_current(store=store, target=target) + _require_projection_lock_target(lock_target, current_target) + if current_target != exact_target: + self.project_conservative_locked( + lock_target=lock_target, + exact_target=current_target, + source_event_id=source_event_id, + ) + + def reconcile( + self, + *, + store: object, + target: ChangeImpactTargetReference, + source_event_id: str, + ) -> OwnerAcceptanceProjectionOutcome: + with self.lock_current(store=store, target=target) as lock_target: + return self.reconcile_locked( + store=store, + target=target, + lock_target=lock_target, + source_event_id=source_event_id, + ) + + def reconcile_if_required( + self, + *, + store: object, + target: ChangeImpactTargetReference, + source_event_id: str, + ) -> OwnerAcceptanceProjectionOutcome: + with self.lock_current(store=store, target=target) as lock_target: + decision, current_target = self.resolve_current(store=store, target=target) + _require_projection_lock_target(lock_target, current_target) + if decision.status == "not_required": + return OwnerAcceptanceProjectionOutcome( + decision=decision, + target=current_target, + result=None, + ) + return self.reconcile_locked( + store=store, + target=target, + lock_target=lock_target, + source_event_id=source_event_id, + ) + + @contextmanager + def _installation_token( + self, + target: ChangeImpactTarget, + ) -> Iterator[GitHubAppInstallationToken]: + if self.github_app_token is None: + raise ValueError("Owner acceptance projection identity is unavailable.") + installation_token = self.github_app_token( + target.repository, + target.repository_id, + ) + try: + yield installation_token + finally: + revoke_installation_token( + installation_token=installation_token, + api_request=self.api_request, + ) + + def _public_origin(self) -> str: + if self.public_origin is None: + raise ValueError("Owner acceptance projection public origin is unavailable.") + return self.public_origin + + +def _validate_projection_target( + decision: OwnerAcceptanceDecision, + target: ChangeImpactTarget, +) -> None: + bindings = tuple( + product.binding for product in decision.products if product.binding is not None + ) + if decision.binding is not None: + bindings = (decision.binding, *bindings) + for binding in bindings: + if ( + binding.repository.casefold() != target.repository.casefold() + or binding.repository_id != target.repository_id + or binding.repository_owner_id != target.repository_owner_id + or binding.pull_request_number != target.pull_request_number + or binding.head_sha != target.head_sha + or binding.tree_sha != target.tree_sha + ): + raise ValueError("Owner acceptance projection target changed during evaluation.") + + +def _projection_targets_share_lock( + first: ChangeImpactTarget, + second: ChangeImpactTarget, +) -> bool: + return ( + first.repository_id == second.repository_id + and first.pull_request_number == second.pull_request_number + ) + + +def _require_projection_lock_target( + lock_target: ChangeImpactTarget, + exact_target: ChangeImpactTarget, +) -> None: + if not _projection_targets_share_lock(lock_target, exact_target): + raise ValueError("Owner acceptance projection target identity changed.") def project_owner_acceptance_decision( @@ -44,6 +319,44 @@ def project_owner_acceptance_decision( details_url=details_url, title=f"Owner acceptance: {decision.status.replace('_', ' ')}", summary=_summary(decision), + conclusion=_conclusion(decision), + ), + installation_token=installation_token, + api_request=api_request, + ) + + +def project_owner_acceptance_update_in_progress( + *, + target: ChangeImpactTarget, + source_event_id: str, + public_origin: str, + installation_token: GitHubAppInstallationToken, + api_request: GitHubApiRequest = github_api_request, +) -> AdvisoryCheckProjectionResult: + details_url = owner_acceptance_workbench_url( + public_origin=public_origin, + target=target, + ) + return write_advisory_check_projection( + projection=AdvisoryCheckProjection( + name=OWNER_ACCEPTANCE_CHECK_NAME, + repository=target.repository, + repository_id=target.repository_id, + head_sha=target.head_sha, + external_id=owner_acceptance_update_projection_sha256( + target=target, + source_event_id=source_event_id, + ), + details_url=details_url, + title="Owner acceptance: updating decision", + summary=( + "Launchplane is updating the authoritative Owner-review decision. " + "GitHub success is intentionally withheld until the current decision " + "is projected. Reconcile from the Launchplane Owner-review workbench " + "if this check remains action required." + ), + conclusion="action_required", ), installation_token=installation_token, api_request=api_request, @@ -54,6 +367,19 @@ def owner_acceptance_workbench_url( *, public_origin: str, target: ChangeImpactTarget, +) -> str: + return owner_acceptance_workbench_reference_url( + public_origin=public_origin, + repository=target.repository, + pull_request_number=target.pull_request_number, + ) + + +def owner_acceptance_workbench_reference_url( + *, + public_origin: str, + repository: str, + pull_request_number: int, ) -> str: origin = public_origin.strip() try: @@ -81,13 +407,15 @@ def owner_acceptance_workbench_url( ) from error if any(character.isspace() or ord(character) < 32 for character in origin): raise ValueError("Owner acceptance projection requires a valid browser public origin.") - if target.repository.count("/") != 1 or any( - not part or part != part.strip() for part in target.repository.split("/", 1) + if repository.count("/") != 1 or any( + not part or part != part.strip() for part in repository.split("/", 1) ): raise ValueError("Owner acceptance projection requires a valid repository target.") + if pull_request_number < 1: + raise ValueError("Owner acceptance projection requires a positive pull request number.") return ( f"{origin.rstrip('/')}{OWNER_ACCEPTANCE_WORKBENCH_PATH}" - f"?repository={quote(target.repository, safe='')}&pull_request={target.pull_request_number}" + f"?repository={quote(repository, safe='')}&pull_request={pull_request_number}" ) @@ -102,10 +430,29 @@ def owner_acceptance_projection_sha256(decision: OwnerAcceptanceDecision) -> str ).hexdigest() +def owner_acceptance_update_projection_sha256( + *, + target: ChangeImpactTarget, + source_event_id: str, +) -> str: + payload = { + "kind": "owner_acceptance_update_in_progress", + "repository": target.repository, + "repository_id": target.repository_id, + "pull_request_number": target.pull_request_number, + "head_sha": target.head_sha, + "tree_sha": target.tree_sha, + "source_event_id": source_event_id, + } + return hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + + def _summary(decision: OwnerAcceptanceDecision) -> str: lines = [ - "Launchplane advisory projection; mode=shadow, authoritative=false, " - "enforcement_effect=none.", + "Launchplane is the authoritative Owner-review system. This GitHub check is a " + "routing and status projection only; record decisions in Launchplane.", "", f"Aggregate decision: **{decision.status.replace('_', ' ')}**", f"Reason code: `{decision.reason_code}`", @@ -120,6 +467,22 @@ def _summary(decision: OwnerAcceptanceDecision) -> str: f"- `{product.product}`: **{product.status.replace('_', ' ')}** " f"(`{product.reason_code}`; binding `{binding}`)" ) - else: + elif decision.status == "not_required": lines.extend(("", "No product-specific Owner decision is currently required.")) + else: + lines.extend( + ( + "", + "No product-specific Owner decision is available from the current " + "authoritative evidence.", + ) + ) return "\n".join(lines) + + +def _conclusion(decision: OwnerAcceptanceDecision) -> AdvisoryCheckConclusion: + if decision.status in {"accepted", "not_required"}: + return "success" + if decision.status == "unavailable": + return "failure" + return "action_required" diff --git a/control_plane/owner_acceptance_queue.py b/control_plane/owner_acceptance_queue.py index 79aa3ef6b..0b89fcdff 100644 --- a/control_plane/owner_acceptance_queue.py +++ b/control_plane/owner_acceptance_queue.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from datetime import datetime, timezone -from typing import Literal, Protocol, cast +from typing import Protocol, cast from pydantic import BaseModel, ConfigDict @@ -49,10 +49,7 @@ class OwnerAcceptanceQueueEntry(BaseModel): system: str action: str environment: str - mode: Literal["shadow"] = "shadow" - authoritative: Literal[False] = False - enforcement_effect: Literal["none"] = "none" - verification_required: Literal[True] = True + verification_required: bool = True ledger_status: OwnerAcceptanceDecisionStatus next_action: str latest_event: OwnerAcceptanceEventRecord diff --git a/control_plane/product_owner_service.py b/control_plane/product_owner_service.py index 495ccb5a5..7e17387a5 100644 --- a/control_plane/product_owner_service.py +++ b/control_plane/product_owner_service.py @@ -11,7 +11,7 @@ ProductOwnerPolicyRecord, ProductOwnerRequirementRecord, ProductOwnerRoutingRecord, - ProductOwnerShadowEvaluation, + ProductOwnerAuthorityEvaluation, ) @@ -134,9 +134,6 @@ class ProductOwnerReadModel(BaseModel): model_config = ConfigDict(extra="forbid") schema_version: int = Field(default=1, ge=1) - mode: Literal["shadow"] = "shadow" - authoritative: Literal[False] = False - enforcement_effect: Literal["none"] = "none" product: str system: str current_policy: ProductOwnerPolicyRecord | None = None @@ -318,7 +315,7 @@ def get_product_owner_read_model( ) -def evaluate_product_owner_shadow_authority( +def evaluate_product_owner_authority( *, context: ProductOwnerActionContext, actor: ProductOwnerActorIdentity, @@ -330,7 +327,7 @@ def evaluate_product_owner_shadow_authority( claimed_requirement_revision: int | None = None, claimed_requirement_digest: str = "", evaluated_at: str = "", -) -> ProductOwnerShadowEvaluation: +) -> ProductOwnerAuthorityEvaluation: normalized_evaluated_at = _evaluation_timestamp(evaluated_at) try: current_requirement = _current_scoped_record( @@ -712,8 +709,8 @@ def _evaluation( actor_is_preferred: bool = False, satisfying_owner_identity_ids: tuple[str, ...] = (), notify_owner_identity_ids: tuple[str, ...] = (), -) -> ProductOwnerShadowEvaluation: - return ProductOwnerShadowEvaluation( +) -> ProductOwnerAuthorityEvaluation: + return ProductOwnerAuthorityEvaluation( decision=decision, reason_code=reason_code, context=context, diff --git a/control_plane/storage/filesystem.py b/control_plane/storage/filesystem.py index 7fc3f31e4..390744c66 100644 --- a/control_plane/storage/filesystem.py +++ b/control_plane/storage/filesystem.py @@ -2745,6 +2745,20 @@ def list_manager_preview_approval_event_records( records = records[:limit] return tuple(records) + @contextmanager + def owner_acceptance_projection_lock( + self, + *, + repository_id: str, + pull_request_number: int, + ) -> Iterator[None]: + normalized_repository_id = repository_id.strip() + if not normalized_repository_id or pull_request_number < 1: + raise ValueError("Owner acceptance projection lock requires an exact pull request") + lock_id = f"{normalized_repository_id}-{pull_request_number}" + with self._exclusive_record_lock("owner_acceptance_projections", lock_id): + yield + def write_owner_acceptance_event_record( self, record: OwnerAcceptanceEventRecord ) -> OwnerAcceptanceEventWriteStatus: diff --git a/control_plane/storage/migrations/versions/f0a2c4e6b8d1_remove_owner_shadow_mode.py b/control_plane/storage/migrations/versions/f0a2c4e6b8d1_remove_owner_shadow_mode.py new file mode 100644 index 000000000..8c864dbe2 --- /dev/null +++ b/control_plane/storage/migrations/versions/f0a2c4e6b8d1_remove_owner_shadow_mode.py @@ -0,0 +1,160 @@ +"""remove Owner acceptance shadow mode + +Revision ID: f0a2c4e6b8d1 +Revises: e9b1d3f5a7c0 +""" + +from __future__ import annotations + +import hashlib +import json + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.engine import RowMapping + + +revision: str = "f0a2c4e6b8d1" +down_revision: str | None = "e9b1d3f5a7c0" +branch_labels: str | None = None +depends_on: str | None = None + +_TABLE = "launchplane_product_owner_requirements" +_ARCHIVE_TABLE = "launchplane_product_owner_requirement_authority_migrations" +_COLUMN = "enforcement_mode" +_CHECK = "launchplane_product_owner_requirement_shadow_ck" +_MIGRATION_SOURCE = "migration:owner-authority-cutover" +_MIGRATION_REASON = "Require an explicit Owner requirement revision before authority is active." + + +def _columns() -> set[str]: + inspector = sa.inspect(op.get_bind()) + return {str(column["name"]) for column in inspector.get_columns(_TABLE)} + + +def _canonical_digest(payload: dict[str, object]) -> str: + digest_payload = { + key: value + for key, value in payload.items() + if key not in {"requirement_digest", "status"} and value is not None + } + encoded = json.dumps( + digest_payload, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _revision(value: object) -> int: + if not isinstance(value, int) or value < 1: + raise ValueError("product Owner requirement revision must be positive") + return value + + +def _record_id(*, product: str, system: str, revision: int) -> str: + scope_digest = _canonical_digest({"product": product, "system": system}) + return f"product-owner-requirement-{scope_digest[:16]}-r{revision}" + + +def _archive_table() -> sa.Table: + connection = op.get_bind() + if _ARCHIVE_TABLE not in sa.inspect(connection).get_table_names(): + return op.create_table( + _ARCHIVE_TABLE, + sa.Column("record_id", sa.String(), primary_key=True), + sa.Column("product", sa.String(), nullable=False), + sa.Column("system", sa.String(), nullable=False), + sa.Column("status", sa.String(), nullable=False), + sa.Column("requirement_revision", sa.BigInteger(), nullable=False), + sa.Column("enforcement_mode", sa.String(), nullable=False), + sa.Column("effective_at", sa.String(), nullable=False), + sa.Column("source", sa.String(), nullable=False), + sa.Column("supersedes_record_id", sa.String(), nullable=True), + sa.Column("requirement_digest", sa.String(), nullable=False), + sa.Column("payload", sa.JSON(), nullable=False), + ) + return sa.Table(_ARCHIVE_TABLE, sa.MetaData(), autoload_with=connection) + + +def _archive_and_replace_requirements() -> None: + connection = op.get_bind() + table = sa.Table(_TABLE, sa.MetaData(), autoload_with=connection) + archive = _archive_table() + rows = tuple(connection.execute(sa.select(table)).mappings()) + archived_record_ids = set(connection.execute(sa.select(archive.c.record_id)).scalars()) + for row in rows: + if row["record_id"] in archived_record_ids: + continue + connection.execute( + sa.insert(archive).values( + record_id=row["record_id"], + product=row["product"], + system=row["system"], + status=row["status"], + requirement_revision=row["requirement_revision"], + enforcement_mode=row[_COLUMN], + effective_at=row["effective_at"], + source=row["source"], + supersedes_record_id=row["supersedes_record_id"], + requirement_digest=row["requirement_digest"], + payload=row["payload"], + ) + ) + + latest_by_scope: dict[tuple[str, str], RowMapping] = {} + for row in rows: + scope = str(row["product"]), str(row["system"]) + current = latest_by_scope.get(scope) + if current is None or _revision(row["requirement_revision"]) > _revision( + current["requirement_revision"] + ): + latest_by_scope[scope] = row + + connection.execute(sa.delete(table)) + for (product, system), previous in latest_by_scope.items(): + revision = 1 + payload: dict[str, object] = { + "schema_version": 1, + "record_id": _record_id(product=product, system=system, revision=revision), + "status": "active", + "product": product, + "system": system, + "requirement_revision": revision, + "requirements": [], + "effective_at": str(previous["effective_at"]), + "source": _MIGRATION_SOURCE, + "reason": _MIGRATION_REASON, + "supersedes_record_id": None, + } + digest = _canonical_digest(payload) + payload["requirement_digest"] = digest + connection.execute( + sa.insert(table).values( + record_id=payload["record_id"], + product=product, + system=system, + status="active", + requirement_revision=revision, + enforcement_mode="shadow", + effective_at=payload["effective_at"], + source=_MIGRATION_SOURCE, + supersedes_record_id=payload["supersedes_record_id"], + requirement_digest=digest, + payload=payload, + ) + ) + + +def upgrade() -> None: + if _COLUMN not in _columns(): + return + _archive_and_replace_requirements() + with op.batch_alter_table(_TABLE) as batch: + batch.drop_constraint(_CHECK, type_="check") + batch.drop_column(_COLUMN) + + +def downgrade() -> None: + pass diff --git a/control_plane/storage/postgres.py b/control_plane/storage/postgres.py index 386b49d4f..15e09f450 100644 --- a/control_plane/storage/postgres.py +++ b/control_plane/storage/postgres.py @@ -4,7 +4,11 @@ from contextlib import contextmanager from dataclasses import dataclass from datetime import datetime, timedelta, timezone +import fcntl import hashlib +from pathlib import Path +from threading import Lock +import time from typing import Any, Literal, NamedTuple, Protocol, TypeVar, cast, overload from pydantic import BaseModel @@ -31,6 +35,7 @@ from sqlalchemy.engine import Engine from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker +from sqlalchemy.pool import NullPool from control_plane.contracts.artifact_identity import ArtifactIdentityManifest from control_plane.contracts.agent_write_intent import AgentWriteIntentRecord @@ -314,6 +319,9 @@ from control_plane.storage.schema_invariants import verify_postgres_schema_invariants RecordModel = TypeVar("RecordModel", bound=BaseModel) + +_SQLITE_OWNER_ACCEPTANCE_PROJECTION_LOCKS_GUARD = Lock() +_SQLITE_OWNER_ACCEPTANCE_PROJECTION_LOCKS: dict[str, Lock] = {} ConnectionFactory = Callable[[], Any] PayloadDict = dict[str, Any] PayloadJsonType = JSON().with_variant(JSONB(), "postgresql") @@ -1286,10 +1294,6 @@ class LaunchplaneProductOwnerRequirementRow(Base): "requirement_revision >= 1", name="launchplane_product_owner_requirement_revision_ck", ), - CheckConstraint( - "enforcement_mode = 'shadow'", - name="launchplane_product_owner_requirement_shadow_ck", - ), CheckConstraint( "(requirement_revision = 1 AND supersedes_record_id IS NULL) OR " "(requirement_revision > 1 AND supersedes_record_id IS NOT NULL)", @@ -1324,7 +1328,6 @@ class LaunchplaneProductOwnerRequirementRow(Base): system: Mapped[str] = mapped_column(String, nullable=False) status: Mapped[str] = mapped_column(String, nullable=False) requirement_revision: Mapped[int] = mapped_column(BigInteger, nullable=False) - enforcement_mode: Mapped[str] = mapped_column(String, nullable=False) effective_at: Mapped[str] = mapped_column(String, nullable=False) source: Mapped[str] = mapped_column(String, nullable=False) supersedes_record_id: Mapped[str | None] = mapped_column(String, nullable=True) @@ -3460,6 +3463,14 @@ def __init__( self.database_url = database_url self._engine = _build_engine(database_url, connection_factory=connection_factory) self._session_factory = sessionmaker(self._engine, expire_on_commit=False) + lock_engine_kwargs: dict[str, Any] = {"poolclass": NullPool} + if connection_factory is not None: + lock_engine_kwargs["creator"] = connection_factory + self._owner_acceptance_projection_lock_engine = ( + create_engine(database_url, **lock_engine_kwargs) + if self._engine.url.get_backend_name() == "postgresql" + else None + ) @property def backend_name(self) -> str: @@ -3518,6 +3529,8 @@ def schema_revision(self) -> str: return revisions[0] def close(self) -> None: + if self._owner_acceptance_projection_lock_engine is not None: + self._owner_acceptance_projection_lock_engine.dispose() self._engine.dispose() def __del__(self) -> None: @@ -8613,6 +8626,77 @@ def write_manager_preview_approval_event_record( ) return "replayed" + @contextmanager + def owner_acceptance_projection_lock( + self, + *, + repository_id: str, + pull_request_number: int, + ) -> Iterator[None]: + normalized_repository_id = repository_id.strip() + if not normalized_repository_id or pull_request_number < 1: + raise ValueError("Owner acceptance projection lock requires an exact pull request") + lock_subject = f"repository-id:{normalized_repository_id}:{pull_request_number}" + if self._engine.url.get_backend_name() == "sqlite": + database = self._engine.url.database + database_identity = ( + str(Path(database).expanduser().resolve()) + if database and database != ":memory:" + else f"memory:{id(self._engine)}" + ) + lock_key = f"{database_identity}:{lock_subject}" + with _SQLITE_OWNER_ACCEPTANCE_PROJECTION_LOCKS_GUARD: + thread_lock = _SQLITE_OWNER_ACCEPTANCE_PROJECTION_LOCKS.setdefault( + lock_key, + Lock(), + ) + with thread_lock: + if not database or database == ":memory:": + yield + return + lock_digest = hashlib.sha256(lock_key.encode("utf-8")).hexdigest() + database_path = Path(database).expanduser().resolve() + lock_path = database_path.parent / ( + f".{database_path.name}.owner-acceptance-{lock_digest}.lock" + ) + with lock_path.open("a+b") as lock_file: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + return + lock_engine = self._owner_acceptance_projection_lock_engine + if lock_engine is None: + raise RuntimeError("PostgreSQL projection lock engine is unavailable") + lock_name = f"launchplane:owner-acceptance-projection:{lock_subject}" + while True: + with lock_engine.connect() as connection: + acquired = bool( + connection.scalar( + text("select pg_try_advisory_lock(hashtextextended(:lock_name, 0))"), + {"lock_name": lock_name}, + ) + ) + if acquired: + connection.commit() + try: + yield + finally: + unlocked = bool( + connection.scalar( + text("select pg_advisory_unlock(hashtextextended(:lock_name, 0))"), + {"lock_name": lock_name}, + ) + ) + connection.commit() + if not unlocked: + raise RuntimeError( + "PostgreSQL Owner acceptance projection lock cleanup failed" + ) + return + time.sleep(0.05) + def write_owner_acceptance_event_record( self, record: OwnerAcceptanceEventRecord ) -> OwnerAcceptanceEventWriteStatus: @@ -16652,7 +16736,6 @@ def _product_owner_requirement_row( system=record.system, status=record.status, requirement_revision=record.requirement_revision, - enforcement_mode=record.enforcement_mode, effective_at=record.effective_at, source=record.source, supersedes_record_id=record.supersedes_record_id, diff --git a/control_plane/storage/schema_invariants.py b/control_plane/storage/schema_invariants.py index 16f1b3f94..6c2204fb6 100644 --- a/control_plane/storage/schema_invariants.py +++ b/control_plane/storage/schema_invariants.py @@ -10,7 +10,7 @@ from sqlalchemy.exc import SQLAlchemyError AUTHZ_COMPATIBILITY_FLOOR_REVISION = "f3b5d7e9a1c2" -EXPECTED_ALEMBIC_HEAD_REVISION = "e9b1d3f5a7c0" +EXPECTED_ALEMBIC_HEAD_REVISION = "f0a2c4e6b8d1" RUNTIME_COMPATIBLE_ALEMBIC_REVISIONS = (EXPECTED_ALEMBIC_HEAD_REVISION,) _AUTHZ_POLICY_TABLE = "launchplane_authz_policies" _AUTHZ_POLICY_WRITE_FENCE_TRIGGER = "launchplane_authz_policy_write_fence" diff --git a/control_plane/workflows/preview_pr_feedback.py b/control_plane/workflows/preview_pr_feedback.py index 9f122b8ad..d5a564b93 100644 --- a/control_plane/workflows/preview_pr_feedback.py +++ b/control_plane/workflows/preview_pr_feedback.py @@ -17,6 +17,7 @@ build_preview_pr_feedback_id, ) from control_plane.contracts.preview_record import PreviewRecord +from control_plane.contracts.owner_acceptance import OwnerAcceptanceDecisionStatus from control_plane.every_code_worker import every_code_worktree_branch from control_plane.workflows.launchplane import ( create_github_issue_comment, @@ -730,6 +731,9 @@ def _render_preview_pr_feedback_markdown( revision: str, run_url: str, failure_summary: str, + repository: str = "", + owner_review_status: OwnerAcceptanceDecisionStatus | None = None, + owner_review_url: str = "", ) -> str: lines = [marker] if status == "pending": @@ -740,9 +744,15 @@ def _render_preview_pr_feedback_markdown( ] ) elif status == "ready": + owner_review_required = owner_review_status not in {None, "not_required"} lines.extend( [ - f"Launchplane preview is ready for PR #{anchor_pr_number}.", + ( + f"Launchplane preview is ready for PR #{anchor_pr_number} — " + "Owner review required before merge." + if owner_review_required + else f"Launchplane preview is ready for PR #{anchor_pr_number}." + ), "", ] ) @@ -807,6 +817,70 @@ def _render_preview_pr_feedback_markdown( [ "", "The preview passed the remote creator/public verification gate.", + ] + ) + if owner_review_status == "not_required": + lines.extend( + [ + "", + "Launchplane classified this exact revision as not requiring Owner acceptance.", + ] + ) + elif owner_review_status is not None: + lines.extend( + [ + "", + "## Owner review", + "", + f"- Current state: **{owner_review_status.replace('_', ' ')}**", + ] + ) + if owner_review_url: + lines.append( + f"- Review and record the Owner decision in Launchplane: {owner_review_url}" + ) + else: + lines.append( + "- Launchplane cannot expose an Owner action until the authoritative review " + "route is available. Do not merge this change." + ) + if repository: + lines.append( + f"- PR changes: https://github.com/{repository}/pull/{anchor_pr_number}/files" + ) + lines.extend( + [ + "", + "### What to test", + "", + "1. Open the preview and exercise the changed workflow, not only the page load.", + "2. Compare the behavior with the pull request scope and acceptance criteria.", + "3. Check the affected area for regressions at desktop and narrow/mobile widths.", + ] + ) + if owner_review_url: + lines.extend( + [ + "", + "### Record the decision in Launchplane", + "", + "- Select **Accept** when the product change is correct.", + "- Select **Request changes** and provide a specific reason when it is not.", + "- A GitHub approval, review, or comment does not record Owner acceptance.", + "- The decision is bound to this exact revision and serving preview. New commits, " + "preview generations, artifacts, runtime identity, or policy changes require " + "Launchplane to re-evaluate the decision.", + "", + "### What happens next", + "", + "Launchplane recomputes exact-head merge readiness after the Owner decision. " + "Only a current accepted decision can satisfy the Owner facet; technical checks, " + "engineering review, merge admission, landing, and production authorization " + "remain separate gates.", + ] + ) + lines.extend( + [ "", "Controls:", "- Push new commits while the `preview` label stays applied to refresh this preview.", @@ -870,6 +944,9 @@ def render_preview_pr_feedback_markdown( revision: str = "", run_url: str = "", failure_summary: str = "", + repository: str = "", + owner_review_status: OwnerAcceptanceDecisionStatus | None = None, + owner_review_url: str = "", ) -> str: return _render_preview_pr_feedback_markdown( marker=marker, @@ -881,6 +958,9 @@ def render_preview_pr_feedback_markdown( revision=revision, run_url=run_url, failure_summary=failure_summary, + repository=repository, + owner_review_status=owner_review_status, + owner_review_url=owner_review_url, ) @@ -939,6 +1019,8 @@ def build_preview_pr_feedback_record( failure_summary: str = "", every_code_record_store: EveryCodeWorkRequestReadStore | None = None, preview_record_store: PreviewPrFeedbackPreviewReadStore | None = None, + owner_review_status: OwnerAcceptanceDecisionStatus | None = None, + owner_review_url: str = "", ) -> PreviewPrFeedbackRecord: resolved_preview_url = preview_url.strip() if status in {"ready", "cleanup_failed"} and not resolved_preview_url: @@ -963,6 +1045,9 @@ def build_preview_pr_feedback_record( revision=revision.strip(), run_url=run_url.strip(), failure_summary=failure_summary.strip(), + repository=repository.strip(), + owner_review_status=owner_review_status, + owner_review_url=owner_review_url.strip(), ) delivery_status: PreviewPrFeedbackDeliveryStatus = "skipped" delivery_action = "" diff --git a/docs/README.md b/docs/README.md index f81cb98e3..3309349f0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -54,11 +54,11 @@ Use these docs as the source of truth for `launchplane`. run records, dispatch binding, credential boundary, and worker lifecycle. - [engineering-review-decisions.md](engineering-review-decisions.md) — exact-head classification plus independent-run evaluation and shadow GitHub projection. -- [product-owner-policy.md](product-owner-policy.md) — additive shadow-mode +- [product-owner-policy.md](product-owner-policy.md) — authoritative product/system Owner membership, requirement, routing, and evaluation contract. -- [owner-acceptance.md](owner-acceptance.md) — shadow-only exact-change Owner +- [owner-acceptance.md](owner-acceptance.md) — authoritative exact-change Owner acceptance binding, event ledger, and human-only API boundary. -- [change-impact-policy.md](change-impact-policy.md) — additive shadow-mode +- [change-impact-policy.md](change-impact-policy.md) — authoritative affected-product, Owner-impact, and engineering-review classification contract. - [operations.md](operations.md) — operator workflows and runtime boundary rules. diff --git a/docs/advisory-governance-checks.md b/docs/advisory-governance-checks.md index 97a93a1d5..76324cab8 100644 --- a/docs/advisory-governance-checks.md +++ b/docs/advisory-governance-checks.md @@ -1,11 +1,11 @@ --- -title: Advisory Governance Check Projection +title: Governance Check Projection --- ## Purpose Launchplane projects its server-owned engineering review and Owner acceptance -decisions into GitHub as advisory check runs. GitHub is a visibility surface, +decisions into GitHub as check runs. GitHub is a visibility and routing surface, not an authority source. The projection cannot authorize merge, tenant admission, promotion, or production deployment. @@ -14,11 +14,11 @@ The two stable check names are: - `launchplane/engineering-review` - `launchplane/owner-acceptance` -Both check runs complete with GitHub conclusion `neutral`. Their title and -summary expose the current Launchplane decision, exact binding digest, and the -fixed `mode=shadow`, `authoritative=false`, `enforcement_effect=none` contract. -Owner projection uses one stable aggregate check and lists each affected -product decision in the output instead of creating product-derived check names. +Engineering review remains a neutral advisory observation. Owner acceptance uses +`success`, `action_required`, or `failure` to make current, pending/stale, and +unavailable states unmistakable. Its summary routes the reviewer to Launchplane, +the only Owner action surface. Owner projection uses one stable aggregate check +and lists each affected product decision instead of product-derived check names. ## GitHub App Identity @@ -34,8 +34,11 @@ value `LAUNCHPLANE_ADVISORY_GITHUB_APP_PRIVATE_KEY`. Both belong to the Launchplane service context in DB-backed runtime records; they are not checked in, persisted in projection records, or accepted from callers. Installation tokens are minted for one exact repository with only Checks write permission -and are revoked after the projection attempt. They are never logged or -persisted. +and are revoked after the projection attempt. Once GitHub returns a usable token +string, Launchplane also revokes it before surfacing any later expiry, +permission, repository-count, repository-id, or repository-name validation +failure. A cleanup failure is attached to the original validation error rather +than replacing it. Tokens are never logged or persisted. Registering and installing the live App is an operator authorization step. The code and dry-run contracts remain testable before that authorization exists; @@ -64,20 +67,42 @@ Identical state replays without a write. Changed binding or decision state on the same head updates the App-owned check run. A changed head receives its own new check run and cannot reuse evidence from the prior head. -Successful browser Owner event writes also trigger a best-effort refresh of the -current App-owned check. This delivery attempt is non-authoritative: projection -or token-revocation failure does not roll back the persisted event or alter its -successful API response, and browser sessions do not gain projection authority. +Browser Owner event writes first replace the exact-head check with a +conservative `action_required` state. Failure to establish that non-green state +blocks the immutable append. Launchplane then projects the stored event's final +decision from a fresh current-ledger evaluation while holding a store-backed +immutable-repository-id projection lock for the pull request. Repository-id +changes are rejected and retried before the critical section, and the resolved +event binding must still match the held lock before projection or append. +Repository renames remain serialized by the stable id. Ready preview-feedback +hydration and the explicit endpoint use this same locked current-ledger +reconciliation service rather than projecting independently. Preview feedback +runs the synchronous lock, storage, and GitHub work in a worker thread instead +of blocking the ASGI event loop. Bindingless stale and unavailable decisions +still project against the exact resolved target as `action_required` or +`failure`; only `not_required` intentionally omits the Owner action. A second +evaluation confirms that the projected state stayed current before the lock is +released, and every minted installation token is revoked after its attempt. +PostgreSQL waiters use dedicated unpooled advisory-lock connections rather than +consuming the record-store pool. Successful acquisition is committed before +provider work; cleanup explicitly unlocks, commits, and verifies the session +lock. Final delivery, token cleanup, or confirmation failure demotes the exact +attempted target before any potentially failing re-resolution, then returns a +reconciliation-required error. Idempotent replay uses stable GitHub user ID +rather than mutable Owner login and can safely retry the final projection. This +sequence remains non-authoritative for admission, and browser sessions do not +gain projection authority. ## No Feedback Loop Launchplane-owned governance check names are excluded from merge-train check-run aggregation and from tenant-admission commit-status, check-run, and required- check inputs. The legacy `launchplane/engineering-review-shadow` status is also -excluded from tenant admission during cutover. Tests prove that merge and -admission results are unchanged when advisory checks are present, pending, or -failed. - -GitHub rulesets must not make these advisory names required while the contracts -remain shadow-only. Ruleset and CODEOWNERS drift detection and reconciliation -belong to the downstream projection workstream. +excluded from tenant admission during its separate cutover. Tests prove that merge +and admission results are unchanged when GitHub projections are present, pending, +or failed. + +Do not make the Owner projection a required GitHub status until the separate +ruleset reconciliation work proves refresh behavior for every staleness source. +Launchplane recomputes the authoritative Owner decision immediately before +admission; the GitHub check remains a routing and visibility projection. diff --git a/docs/architecture.md b/docs/architecture.md index 59f88c660..a7cf1ec4c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -46,8 +46,9 @@ pull requests, labels, checks, PR comments, releases, and CI execution. Product/system human ownership is represented by additive Launchplane records. Owner membership, Owner requirements, and preferred routing are separate -revision streams. Their current implementation is shadow-only and cannot alter -legacy repository-human admission or any production authorization result; see +revision streams. Owner requirements and exact-change acceptance are authoritative +for Launchplane merge readiness; preferred routing cannot grant authority and +production authorization remains separate. See `docs/product-owner-policy.md`. This repository is the product boundary today. Keep reusable nouns in diff --git a/docs/change-impact-policy.md b/docs/change-impact-policy.md index d874b407d..ddb2194b4 100644 --- a/docs/change-impact-policy.md +++ b/docs/change-impact-policy.md @@ -4,11 +4,11 @@ title: Change Impact Policy ## Purpose -Launchplane now has an additive, shadow-only change-impact classifier for pull +Launchplane has an authoritative change-impact classifier for pull requests. It derives affected products, Owner acceptance impact, and engineering review tier from Launchplane policy plus trusted repository and change evidence. Public callers submit only a repository/pull-request target reference and -non-authoritative request metadata. They cannot submit changed files, dependency +request metadata. They cannot submit changed files, dependency or reviewer facts, head/tree expectations, product impact, sensitive areas, or review tiers. @@ -78,8 +78,9 @@ Every evaluation returns: - exact repository/PR/head/tree and policy provenance; - explicit unknown evidence when classification fails closed. -The current API is shadow-only: `mode=shadow`, `authoritative=false`, and -`enforcement_effect=none`. It does not alter required GitHub checks. +The evaluation is the authoritative source for which product Owner decisions are +required by Launchplane merge readiness. GitHub checks only project the resulting +state and are never accepted as substitute evidence. ## HTTP API @@ -92,7 +93,7 @@ CAS apply/dry-run endpoint: - `POST /v1/change-impact/policies/apply` -Policy writes are `policy_admin`. Evaluation reads are observational evidence. +Policy writes are `policy_admin`. Evaluation reads are server-derived authority inputs. Generated OpenAPI is the client contract source. The evaluation request schema contains only `target.repository`, diff --git a/docs/governance-evidence.md b/docs/governance-evidence.md index 1fa6d5b54..c3e2ef078 100644 --- a/docs/governance-evidence.md +++ b/docs/governance-evidence.md @@ -24,9 +24,9 @@ uses the GitHub token source declared by that repository policy. The response preserves these independent facts: -- **Level 1 Owner product judgment:** current product-review evaluation plus - immutable stored events. `accepted` remains product judgment, - `human_action_semantics=product_review_accepted`, and `authorizes=[]`. Each +- **Level 1 Owner acceptance:** the authoritative current product-review decision + plus immutable stored events. `accepted` retains + `human_action_semantics=product_review_accepted`. Each event is explicitly classified as current or historical for the resolved head/tree and as current or historical to the folded decision. - **Level 2 merge readiness:** current ephemeral readiness with every Owner, @@ -42,10 +42,10 @@ The response preserves these independent facts: evidence is `not_observed`, never landed, and recorded outcomes carry the same current/historical target classification as their admission. Landing observations are `authoritative=false` and `authorizes=[]`. -- **Advisory observations:** reserved Launchplane GitHub check observations +- **GitHub projection observations:** reserved Launchplane GitHub check observations copied from current Level 2 evidence or, when no current readiness result is - available, the admitted Level 2 snapshot. They remain neutral, - non-authoritative, and authorize nothing. + available, the admitted Level 2 snapshot. They remain non-authoritative routing + evidence and authorize nothing; Owner checks may visibly require action. Historical Level 1 evidence remains visible after current policy, authority, age, self-review, preview isolation, or binding changes make it inadmissible. @@ -68,11 +68,11 @@ Owner, admission, and outcome records remain visible in both cases. `/ui/engineering/governance-projection` renders five separately named regions: -1. historical Owner product judgment; +1. authoritative current Owner acceptance with immutable history; 2. current ephemeral merge readiness and every sub-facet reason; 3. immutable merge admission; 4. separate landing outcome; -5. neutral advisory observations. +5. non-authoritative GitHub status observations. The same vocabulary and hierarchy are preserved on desktop and narrow viewports. Text and semantic headings identify historical/current, diff --git a/docs/merge-readiness.md b/docs/merge-readiness.md index 1d1de0caf..c341d0f23 100644 --- a/docs/merge-readiness.md +++ b/docs/merge-readiness.md @@ -120,10 +120,11 @@ unscoped facet is `unknown` and fails closed. ## Advisory Checks -Launchplane Owner, engineering-review, and legacy shadow check contexts are -observations only. The live adapter removes them from required technical-check -policy and signal aggregation. Their observed names and states may be returned -for diagnostics, but they cannot change readiness state or reason codes. +The `launchplane/owner-acceptance`, `launchplane/engineering-review`, and legacy +`launchplane/engineering-review-shadow` check contexts are observations only. +The live adapter removes them from required technical-check policy and signal +aggregation. Their observed names and states may be returned for diagnostics, +but they cannot change readiness state or reason codes. Engineering-review records remain shadow-only while the repository's active DB-backed merge-train policy selects `engineering_review_mode = "advisory"`. diff --git a/docs/operator-experience.md b/docs/operator-experience.md index 3792acd4a..5abe0dba3 100644 --- a/docs/operator-experience.md +++ b/docs/operator-experience.md @@ -263,10 +263,10 @@ The first product/site read endpoints are: - `GET /v1/products/{product}/contexts/{context}/instances/{instance}/operational-readiness?action={authz_action}&artifact_id={artifact_id}&expected_current_artifact_id={expected_current_artifact_id}` Engineering Ops also exposes `/ui/engineering/governance-projection`, backed by -`GET /v1/governance/projection`. The workbench keeps historical Owner product -judgment, current ephemeral readiness, immutable admission, landing outcome, -and advisory observations in separately named regions. It is read-only and -does not add a browser mutation contract. +`GET /v1/governance/projection`. The workbench keeps authoritative current Owner +acceptance and its immutable history, current ephemeral readiness, immutable +admission, landing outcome, and GitHub observations in separately named +regions. It is read-only and does not add a browser mutation contract. These endpoints are profile and driver driven. A standard `generic-web` site should appear in the read model from Launchplane records alone: product profile, diff --git a/docs/owner-acceptance.md b/docs/owner-acceptance.md index 7ef5fa584..d316fd550 100644 --- a/docs/owner-acceptance.md +++ b/docs/owner-acceptance.md @@ -4,10 +4,11 @@ title: Owner Acceptance ## Purpose -Owner acceptance is a shadow-only exact-change ledger for product/system Owner -review of pull requests. Every affected product receives an independent binding -and decision. It does not authorize production, merge trains, tenant admission, -promotion, GitHub required checks, or manager-preview flows. +Owner acceptance is Launchplane's authoritative exact-change product decision for +pull requests. Every affected product receives an independent binding and decision. +A current accepted decision is required by merge readiness; it does not replace +technical checks, engineering review, merge admission, landing, or production +authorization. The persisted human action remains `accepted` because the event records the Owner's durable product judgment. The workbench presents that action as @@ -77,8 +78,8 @@ providers and storage. The pure read remains outside the browser-mutation surface and cannot consume a request body. The Owner workbench also accepts `repository` and `pull_request` query -parameters at `/ui/engineering/owner-acceptance`. A server-issued advisory -check details link uses the configured browser public origin and those exact +parameters at `/ui/engineering/owner-acceptance`. A server-issued GitHub check +details link uses the configured browser public origin and those exact parameters, so opening the link expands Exact lookup, prefills the repository and pull-request fields, and evaluates the target automatically. The browser still sends only the exact repository and PR reference to the evaluation API; @@ -113,7 +114,7 @@ exact-change-only acceptance. ## Admissibility A recorded event is immutable. Admissibility is a separate, recomputed judgment -about whether that history is *currently* usable. `OwnerAcceptanceDecision` and +about whether that history is _currently_ usable. `OwnerAcceptanceDecision` and each product decision expose `admissible`, which is true only when the status is `accepted` and every current check passes. @@ -156,29 +157,28 @@ revision makes the historical event inadmissible instead of silently valid. `403 owner_acceptance_self_review_denied` when the rule denies the write, and viewer eligibility mirrors the same rule with reason code `self_review_denied`. -## Non-Authority Semantics +## Authority Semantics -Stored human actions and their digests never change. API projections add -machine-readable non-authority descriptors: +Stored human actions and their digests never change. API projections expose +machine-readable review semantics through `human_action_semantics` on decisions +and event responses. -- decisions expose `human_action_semantics` (for example - `product_review_accepted`) and `authorizes`, which is always empty; -- event responses expose the same pair under `semantics`. - -The contract rejects any decision that claims authority or claims admissibility -without a current acceptance, so a client cannot read Owner product review as -merge readiness, landed state, or production authorization. +The contract rejects admissibility without a current acceptance. Acceptance is +authoritative for the Owner facet while remaining distinct from aggregate merge +readiness, the one-attempt admission record, provider landing, and production +authorization. For every successfully impact-resolved product change, the response includes a deterministic `products` entry for each affected product in change-impact order; single-product changes therefore contain one entry. Early engineering-only, stale-impact, and unavailable-impact results contain no product entries. Each entry carries its own status, reason, binding, and current event. The top-level -status is the worst current product status using this precedence: `unavailable`, `stale`, -`revoked`, `changes_requested`, `pending`, `accepted`, `not_required`. Ties use -the existing product order. The singular top-level binding and event mirror -that governing product for compatibility. Aggregate acceptance is therefore -`accepted` only when every affected product is currently accepted. +status is the worst current product status using this precedence: +`unavailable`, `stale`, `revoked`, `changes_requested`, `pending`, `accepted`, +`not_required`. Ties use the existing product order. The singular top-level +binding and event mirror that governing product for compatibility. Aggregate +acceptance is therefore `accepted` only when every affected product is currently +accepted. Products removed from later current change-impact evidence stop governing the read result; evaluation does not write synthetic ledger events. Products added @@ -213,9 +213,11 @@ The `Idempotency-Key` is scoped by the exact binding because the immutable event ID includes both values. Reusing one key for different product-binding digests creates distinct product events; replaying it for the same binding remains idempotent. Exact replay returns the already-persisted event and receives no new -subject sequence. A different idempotency key cannot deliberately reaffirm the -same human state on an identical binding; reaffirmation requires changed bound -evidence and therefore a new binding. +subject sequence. Replay identity uses the stable GitHub user ID; a mutable +Owner login rename does not turn the same authorized event into a conflict. A +different idempotency key cannot deliberately reaffirm the same human state on +an identical binding; reaffirmation requires changed bound evidence and +therefore a new binding. Human actions are: @@ -316,6 +318,7 @@ state under clock skew. **Ledger status:** Each entry carries a `ledger_status` and `next_action` derived from the latest recorded event action: + - `accepted` → `accepted` - `changes_requested` → `changes_requested` - `revoked` → `revoked` @@ -353,8 +356,8 @@ server-issued decisions and binding-scoped Owner controls directly on each PR. It also displays queue entries from `GET /v1/owner-acceptance/queue` with: - loading, error, denied, and empty states via `EngineeringResourceGate`; -- a boundary note explaining shadow mode, automatic Current discovery, and the - separate ledger-only recorded derivation; +- a boundary note explaining Launchplane authority, automatic Current + discovery, and the separate ledger-only recorded derivation; - server-side filters by status (exact) and repository (substring); - per-entry recorded binding and event provenance with `verification_required` framing — rows are labeled **Recorded**, not Current; @@ -376,8 +379,9 @@ evidence. Launchplane re-evaluates the binding and the authenticated GitHub human's current Owner membership at write time. Request-changes and revoke require a reason, revoke requires explicit confirmation, replay preserves the same idempotency key, and `409 owner_acceptance_binding_changed` refreshes the -Current item without auto-resubmitting. Every receipt remains shadow, -non-authoritative, and has no merge or production enforcement effect. +Current item without auto-resubmitting. Every receipt confirms an authoritative +Owner decision and explains that Launchplane will recompute exact-head merge +readiness while production authorization remains separate. When the current identical-binding state is `changes_requested`, choosing accepted reveals required resolution-summary and evidence-reference fields; the browser cannot submit that reversal until both are present. @@ -395,26 +399,49 @@ supplies only repository and pull-request reference. Launchplane derives every product decision and exact binding, rechecks the current target before the provider write, and uses the decision digest as the check-run `external_id`. -After a successful browser Owner event write, Launchplane best-effort -re-evaluates the current exact target and refreshes this same App-owned check -run. Projection or token-revocation failure is logged as advisory delivery -failure only: the persisted human event and its `202` response are not rolled -back or rewritten. Browsers never receive projection credentials or projection -authority. - -The completed check conclusion is always `neutral`; the output lists the -aggregate state plus each affected product and binding. The check remains -shadow-only, is excluded from Launchplane merge/admission technical inputs, and -cannot become Owner authority. +Before appending a browser Owner event, Launchplane replaces the exact-head +check with an `action_required` **updating decision** projection. If that +conservative projection or its token lifecycle fails, the event is not +persisted. After append, Launchplane projects the resulting decision against the +current exact target and ledger. Browser events, explicit reconciliation, and +ready preview-feedback hydration all use the same shared projection service. +Preview feedback executes the synchronous reconciliation in a worker thread so +lock waits, storage reads, and GitHub requests do not block the ASGI event loop. +Every path for one immutable repository id and pull request is serialized +through the same store-backed projection lock. Bindingless stale and unavailable +decisions project their non-green conclusion against the exact resolved target; +only `not_required` intentionally omits the Owner action. A repository-id change +is rejected and retried before entering the critical section, and the freshly +resolved event binding must still match that lock before the conservative +projection or append can occur. A rename remains on the same lock. Final +projection is verified against a second current-state evaluation before the lock +is released, and every minted installation token is revoked after its attempt, +including post-mint validation failures inside the identity provider. +PostgreSQL lock waiters use dedicated unpooled advisory-lock connections so they +cannot exhaust the record-store pool needed by the lock holder. Acquisition is +committed before provider work begins; cleanup explicitly unlocks the session, +commits, and verifies that the lock was held. If final projection, token cleanup, +or current-state confirmation fails, Launchplane first restores the conservative +non-green check on the exact attempted target before any current-target +re-resolution can fail, then returns +`503 owner_acceptance_projection_reconciliation_required`; retrying with the +same idempotency key replays the immutable event and retries projection. The +explicit projection endpoint provides the same reconciliation path. Browsers +never receive projection credentials or projection authority. + +The completed check conclusion is `success` for accepted or not-required state, +`action_required` for pending, stale, revoked, or changes-requested state, and +`failure` when authoritative evidence is unavailable. The output lists the +aggregate state plus each affected product and binding. The check is excluded +from Launchplane technical-check inputs and cannot replace the Launchplane decision. ## Combined Governance Read Model `GET /v1/governance/projection` and the Governance evidence workbench preserve the current Owner evaluation and immutable event history alongside separate L2 -readiness, L3 admission, landing outcome, and advisory facets. This projection -does not reinterpret Owner `accepted`; it continues to expose -`human_action_semantics=product_review_accepted` and `authorizes=[]` even when -later machine evidence is ready, admitted, or landed. +readiness, L3 admission, landing outcome, and projection facets. The Level 1 +facet is authoritative Owner acceptance and preserves the immutable event history; +later readiness, admission, and landing records remain independent evidence. ## Out Of Scope diff --git a/docs/preview-workflow-contract.md b/docs/preview-workflow-contract.md index 15654222b..7b6afeaa6 100644 --- a/docs/preview-workflow-contract.md +++ b/docs/preview-workflow-contract.md @@ -222,6 +222,20 @@ Manual `workflow_dispatch` may request `refresh` or `destroy` when a product rep needs an operator retry path. Manual refresh still follows the same build, publish, and Launchplane-refresh handoff as a PR refresh. +## Owner Review Handoff + +When a ready preview belongs to a repository with an authoritative Owner +requirement, the Launchplane-owned PR feedback comment is the canonical handoff. +It includes the public preview URL, immutable image and current revision, the +exact Launchplane Owner-workbench deep link, the PR changes link, a concise test +plan, Accept and Request changes instructions, a staleness warning, and the next +Launchplane step. GitHub reviews and comments do not record Owner acceptance. + +If Owner authority cannot be resolved or the workbench route is unavailable, +the comment fails closed: it tells reviewers not to merge and exposes no Owner +action instructions. Repositories classified as not requiring Owner acceptance +receive the ordinary ready-preview comment without an interactive Owner handoff. + ## Manager Preview Approval Manager approval is a Launchplane-owned interaction layered on the serving diff --git a/docs/product-owner-policy.md b/docs/product-owner-policy.md index 7805595e1..b846decaa 100644 --- a/docs/product-owner-policy.md +++ b/docs/product-owner-policy.md @@ -4,10 +4,9 @@ title: Product Owner Policy ## Purpose -Launchplane has an additive, shadow-only product/system Owner policy. It models -future human Owner authority without changing any current authorization, -promotion, tenant-admission, manager-preview, technical-waiver, or trusted- -maintenance decision. +Launchplane owns the authoritative product/system Owner policy used by exact-change +Owner acceptance. The policy remains independent from production authorization, +promotion, technical checks, engineering review, and provider landing effects. The contract has one human `Owner` class. Membership and evaluated actors are bound only to an immutable, positive numeric GitHub user ID. GitHub Actions, @@ -23,14 +22,14 @@ Three independently revisioned record streams prevent accidental authority: - `ProductOwnerPolicyRecord` grants Owner membership. It does not make any action require an Owner. - `ProductOwnerRequirementRecord` lists the actions, repositories, and - environments that would require an Owner. It contains no identities. + environments that require an Owner. It contains no identities. - `ProductOwnerRoutingRecord` records preferred Owner routing. It is persisted with `authoritative=false` and never participates in membership evaluation. Launchplane authz grants control who may invoke the read and policy-admin APIs. They never satisfy an Owner requirement. Administrative roles are not part of Owner actor identity and cannot influence evaluation. A GitHub human who also -has Launchplane administration satisfies a shadow Owner action only when that +has Launchplane administration satisfies an Owner action only when that human's immutable GitHub ID appears in the current product/system policy and matches the action's repository and environment scope. @@ -66,11 +65,11 @@ exact binding and stales prior evidence rather than silently reinterpreting it. ## Current-Policy Evaluation -Shadow evaluation resolves only active records for the exact product/system +Authority evaluation resolves only active records for the exact product/system scope. When an explicit requirement matches, evidence is checked against the current policy revision and digest. Stale revisions, removed Owners, another product's Owners, and identities present only in preferred routing do not -satisfy the shadow action. +satisfy the required action. If a current policy exists but no current Owner grant covers the requested repository and environment, evaluation returns `unavailable` with @@ -78,12 +77,6 @@ repository and environment, evaluation returns `unavailable` with which means the policy scope is covered but the evaluated GitHub human is not one of its current Owners. -Every evaluation response is explicit about its boundary: - -- `mode=shadow` -- `authoritative=false` -- `enforcement_effect=none` - When one current Owner would satisfy the quorum, the read model returns every current Owner in scope as the notification audience. Preferred routing only marks which Owner is preferred; another current Owner remains able to satisfy @@ -99,8 +92,8 @@ Filesystem rehearsal records live under: PostgreSQL uses tables with the same names. Each stream has one active-record partial unique index, a unique scope/revision index, a current-history index, -and a record-id primary key. The requirement table enforces shadow mode and the -routing table enforces non-authority at the database layer. +and a record-id primary key. Owner requirements are authoritative by definition; +preferred routing remains non-authoritative and is enforced as such at the database layer. Successor revisions must have a non-decreasing `effective_at`. An incoming active revision must already be effective when applied; future scheduling @@ -116,6 +109,14 @@ Migration `b2d4f6a8c0e2` writes the fail-closed `review_age`, `self_review`, and self-describing. Because those fields are outside `policy_digest`, the backfill cannot change any persisted digest. +Migration `f0a2c4e6b8d1` removes the legacy requirement `enforcement_mode` column +without promoting old requirements into authority. It archives every exact +pre-cutover row, replaces each product/system scope with an empty successor +revision-1 baseline, and requires an operator to create the first explicit +authoritative requirement revision. Existing Owner acceptance events remain +immutable and become stale against the empty baseline until that deliberate +cutover occurs. + ## HTTP API Reads: @@ -123,7 +124,7 @@ Reads: - `GET /v1/product-owner/policy` - `GET /v1/product-owner/requirement` - `GET /v1/product-owner/routing` -- `GET /v1/product-owner/shadow-evaluation` +- `GET /v1/product-owner/evaluation` CAS apply/dry-run endpoints: @@ -131,14 +132,13 @@ CAS apply/dry-run endpoints: - `POST /v1/product-owner/requirements/apply` - `POST /v1/product-owner/routing/apply` -The three write actions are classified as `policy_admin`. The shadow read action -remains observational. Generated OpenAPI is the contract source for clients. +The three write actions are classified as `policy_admin`. Authority evaluation +remains a read action; only a browser-authenticated current Owner can write an +acceptance event. Generated OpenAPI is the contract source for clients. -## Rollback Boundary +## Unconfigured Repositories -Legacy repository-human admission, manager-preview, technical-waiver, and -trusted-maintenance readers remain fully operational and unchanged. This slice -does not delete manager, delegate, or technical-waiver vocabulary, records, or -code. A later, separately reviewed cutover may consume Owner decisions only -after shadow evidence proves equivalence and the migration plan explicitly -defines rollback. +Repositories without active change-impact, Owner policy, and Owner requirement +records do not expose an interactive Owner action. Their evaluation is either +`not_required` or fail-closed `unavailable`; Launchplane never falls back to a +non-authoritative approval path. diff --git a/docs/records.md b/docs/records.md index fe468a7d9..f561405f0 100644 --- a/docs/records.md +++ b/docs/records.md @@ -243,9 +243,17 @@ records: All three streams use deterministic record IDs, canonical SHA-256 payload digests, active/superseded history, exact-next revision sequencing, predecessor -links, and compare-and-swap expected-tip writes. Shadow evaluation reports the -current policy, requirement, and routing provenance and always returns -`authoritative=false` with `enforcement_effect=none`. +links, and compare-and-swap expected-tip writes. Authority evaluation reports the +current policy, requirement, and routing provenance. Matching requirements govern +the exact Owner acceptance binding; preferred routing never grants authority. + +The authority-cutover migration archives exact pre-cutover requirement rows in +`launchplane_product_owner_requirement_authority_migrations`. Runtime readers +and writers do not consume that table. Each migrated scope receives an empty +revision-1 baseline, intentionally resetting the executable revision stream +while preserving the prior chain in the archive. The next supported write can +append revision 2, so Owner actions cannot govern a repository until an operator +supplies an explicit authoritative requirement. The PostgreSQL tables are `launchplane_product_owner_policies`, `launchplane_product_owner_requirements`, and @@ -254,7 +262,7 @@ tables without inserting or inferring any owner data. ## Owner Acceptance Event Records -`OwnerAcceptanceEventRecord` is the append-only shadow ledger for exact-change +`OwnerAcceptanceEventRecord` is the append-only authoritative ledger for exact-change Owner acceptance. Human-authored events are `accepted`, `changes_requested`, and `revoked`; `superseded` and `invalidated` are system-only. Human events require a browser-authenticated GitHub human who is a current Owner in the bound product @@ -296,7 +304,7 @@ preserve the original #2022 binding, event, and replay digests byte-for-byte. ## Change Impact Policy Records `ChangeImpactPolicyRecord` stores repository-scoped component/path impact rules -for shadow pull-request classification. Each active revision binds the exact +for authoritative pull-request classification. Each active revision binds the exact numeric GitHub repository ID, numeric owner ID, owner/name, component rules, affected product/system scopes, engineering review tier, source, reason, effective timestamp, predecessor, and canonical policy digest. diff --git a/docs/service-boundary.md b/docs/service-boundary.md index 74bad1db3..2d3733678 100644 --- a/docs/service-boundary.md +++ b/docs/service-boundary.md @@ -3521,7 +3521,7 @@ The first explicit drivers should be: Repo-specific variation should stay thin and declarative where possible. -## Product Owner Shadow API +## Product Owner Authority API The product Owner API is an additive policy-administration and read-model surface. Policy, requirement, and preferred-routing revisions are written @@ -3529,15 +3529,15 @@ through separate endpoints and separate authz actions. Their write actions are classified as `policy_admin`; an invocation grant authorizes the API call but never satisfies an Owner requirement. -Shadow evaluation derives human identity only from immutable provider subject +Authority evaluation derives human identity only from immutable provider subject identity. It does not consume global-admin, bootstrap-admin, manager, -delegation, repository-permission, or routing state as Owner authority. Every -response declares `authoritative=false` and `enforcement_effect=none`, so no -existing service route may use it as an authorization verdict in this slice. +delegation, repository-permission, or routing state as Owner authority. Matching +product Owner requirements feed the exact Owner acceptance decision consumed by +Launchplane merge readiness. See `docs/product-owner-policy.md` for routes and persisted record contracts. -## Owner Acceptance Shadow API +## Owner Acceptance API `GET /v1/owner-acceptance/evaluation` accepts only `repository` and `pull_request_number` query parameters. Launchplane derives repository @@ -3584,13 +3584,15 @@ invalid reaffirmations, unreasoned revocations, and unsupported transitions return a conflict. Current state folds by sequence only, while timestamps remain audit and display fields. -The ledger is append-only and shadow-only. Changed bound evidence or changed +The ledger is append-only and authoritative for the Owner merge-readiness facet. +Changed bound evidence or changed Owner policy/requirement/membership makes prior acceptance stale for the new binding. Evaluation returns one decision per affected product and is accepted only when all are current; dropped products stop governing without a read-side -write. GitHub projection, frontend workbench, tenant-admission consumers, -production authorization, and legacy manager cleanup remain out of scope. See -`docs/owner-acceptance.md` for the full record and migration boundary. +write. The GitHub projection and frontend workbench route reviewers without +becoming authority. Tenant-admission consumers, production authorization, and +legacy manager cleanup remain out of scope. See `docs/owner-acceptance.md` for +the full record and migration boundary. `GET /v1/governance/projection` accepts only repository, pull request number, and base branch scope. It requires Owner-acceptance, engineering-review @@ -3600,7 +3602,7 @@ the route fails closed rather than returning a partially authorized projection. It returns one read-only model containing immutable Owner history, current Owner evaluation, current ephemeral merge readiness when an active landing lineage exists, latest immutable merge admission, separate -landing outcome, and neutral advisory observations. It reuses the guarded +landing outcome, and non-authoritative GitHub status observations. It reuses the guarded landing readiness evaluator instead of duplicating readiness logic in the HTTP or frontend layers, binds the requested branch to the current pull request base ref, and resolves the repository policy's declared GitHub token source. The @@ -3609,7 +3611,7 @@ effect, classifies historical head/tree evidence explicitly, and never interprets a missing landing outcome as landed. See `docs/governance-evidence.md`. -## Change Impact Shadow API +## Change Impact API `POST /v1/change-impact/evaluation` accepts only a repository/pull-request target reference plus optional non-authoritative metadata. Launchplane uses its @@ -3622,9 +3624,9 @@ require trusted same-component dependency evidence. Missing extension records, stale heads, incomplete provider evidence, and provider failures cannot fall back to caller input and therefore fail closed. -The response remains shadow-only with exact policy revision/digest and -repository/PR/head/tree binding. See `docs/change-impact-policy.md` for the -policy, evidence, and persistence contracts. +The response is the authoritative Owner-impact classification with exact policy +revision/digest and repository/PR/head/tree binding. See +`docs/change-impact-policy.md` for the policy, evidence, and persistence contracts. ## Out Of Scope For This First Slice diff --git a/frontend/generated/openapi-canonical.json b/frontend/generated/openapi-canonical.json index a8f8cfe17..2a55cf381 100644 --- a/frontend/generated/openapi-canonical.json +++ b/frontend/generated/openapi-canonical.json @@ -75,8 +75,12 @@ "type": "integer" }, "conclusion": { - "const": "neutral", - "default": "neutral", + "enum": [ + "neutral", + "success", + "failure", + "action_required" + ], "title": "Conclusion", "type": "string" }, @@ -122,7 +126,8 @@ "external_id", "app_id", "installation_id", - "check_run_id" + "check_run_id", + "conclusion" ], "title": "AdvisoryCheckProjectionResult", "type": "object" @@ -1535,18 +1540,6 @@ "title": "Affected Products", "type": "array" }, - "authoritative": { - "const": false, - "default": false, - "title": "Authoritative", - "type": "boolean" - }, - "enforcement_effect": { - "const": "none", - "default": "none", - "title": "Enforcement Effect", - "type": "string" - }, "engineering_review_tier": { "default": "sensitive", "enum": [ @@ -1564,12 +1557,6 @@ "title": "Matched Evidence", "type": "array" }, - "mode": { - "const": "shadow", - "default": "shadow", - "title": "Mode", - "type": "string" - }, "owner_impact": { "default": "unknown", "enum": [ @@ -1904,12 +1891,6 @@ "ChangeImpactPolicyReadModel": { "additionalProperties": false, "properties": { - "authoritative": { - "const": false, - "default": false, - "title": "Authoritative", - "type": "boolean" - }, "current_policy": { "anyOf": [ { @@ -1920,18 +1901,6 @@ } ] }, - "enforcement_effect": { - "const": "none", - "default": "none", - "title": "Enforcement Effect", - "type": "string" - }, - "mode": { - "const": "shadow", - "default": "shadow", - "title": "Mode", - "type": "string" - }, "policy_history_count": { "default": 0, "minimum": 0.0, @@ -8561,12 +8530,6 @@ "title": "Authorizes", "type": "array" }, - "neutral": { - "const": true, - "default": true, - "title": "Neutral", - "type": "boolean" - }, "observation": { "$ref": "#/components/schemas/MergeReadinessAdvisoryObservation" }, @@ -8781,14 +8744,6 @@ "GovernanceOwnerHistoryEntry": { "additionalProperties": false, "properties": { - "authorizes": { - "default": [], - "items": { - "type": "string" - }, - "title": "Authorizes", - "type": "array" - }, "decision_relationship": { "enum": [ "current", @@ -8834,19 +8789,11 @@ "additionalProperties": false, "properties": { "authoritative": { - "const": false, - "default": false, + "const": true, + "default": true, "title": "Authoritative", "type": "boolean" }, - "authorizes": { - "default": [], - "items": { - "type": "string" - }, - "title": "Authorizes", - "type": "array" - }, "current": { "$ref": "#/components/schemas/OwnerAcceptanceDecision" }, @@ -8865,8 +8812,8 @@ "type": "integer" }, "mode": { - "const": "historical_product_judgment", - "default": "historical_product_judgment", + "const": "owner_acceptance", + "default": "owner_acceptance", "title": "Mode", "type": "string" } @@ -14257,12 +14204,6 @@ "OwnerAcceptanceCurrentItemsResponse": { "additionalProperties": false, "properties": { - "authoritative": { - "const": false, - "default": false, - "title": "Authoritative", - "type": "boolean" - }, "candidate_count": { "title": "Candidate Count", "type": "integer" @@ -14273,12 +14214,6 @@ "title": "Derivation", "type": "string" }, - "enforcement_effect": { - "const": "none", - "default": "none", - "title": "Enforcement Effect", - "type": "string" - }, "evaluated_count": { "title": "Evaluated Count", "type": "integer" @@ -14294,12 +14229,6 @@ "title": "Items", "type": "array" }, - "mode": { - "const": "shadow", - "default": "shadow", - "title": "Mode", - "type": "string" - }, "repository_count": { "title": "Repository Count", "type": "integer" @@ -14361,20 +14290,6 @@ "title": "Admissible", "type": "boolean" }, - "authoritative": { - "const": false, - "default": false, - "title": "Authoritative", - "type": "boolean" - }, - "authorizes": { - "default": [], - "items": { - "type": "string" - }, - "title": "Authorizes", - "type": "array" - }, "binding": { "anyOf": [ { @@ -14395,12 +14310,6 @@ } ] }, - "enforcement_effect": { - "const": "none", - "default": "none", - "title": "Enforcement Effect", - "type": "string" - }, "evaluated_at": { "title": "Evaluated At", "type": "string" @@ -14418,12 +14327,6 @@ "title": "Human Action Semantics", "type": "string" }, - "mode": { - "const": "shadow", - "default": "shadow", - "title": "Mode", - "type": "string" - }, "products": { "default": [], "items": { @@ -14728,17 +14631,8 @@ }, "OwnerAcceptanceEventSemantics": { "additionalProperties": false, - "description": "Machine-readable projection of a stored human product-review action.\n\nThe stored enum and every persisted digest stay unchanged. This projection\nexists so no API client can read Owner product review as merge readiness,\nlanded state, or production authorization.", + "description": "Machine-readable projection of a stored human product-review action.\n\nThe stored enum and every persisted digest stay unchanged. Acceptance is an\nauthoritative merge-admission prerequisite, while technical readiness,\nlanding, and production authorization remain separate decisions.", "properties": { - "authorizes": { - "default": [], - "description": "Always empty. Owner product review authorizes nothing on its own.", - "items": { - "type": "string" - }, - "title": "Authorizes", - "type": "array" - }, "human_action_semantics": { "enum": [ "none", @@ -14913,14 +14807,6 @@ "title": "Admissible", "type": "boolean" }, - "authorizes": { - "default": [], - "items": { - "type": "string" - }, - "title": "Authorizes", - "type": "array" - }, "binding": { "anyOf": [ { @@ -15075,18 +14961,6 @@ "title": "Action", "type": "string" }, - "authoritative": { - "const": false, - "default": false, - "title": "Authoritative", - "type": "boolean" - }, - "enforcement_effect": { - "const": "none", - "default": "none", - "title": "Enforcement Effect", - "type": "string" - }, "environment": { "title": "Environment", "type": "string" @@ -15110,12 +14984,6 @@ "title": "Ledger Status", "type": "string" }, - "mode": { - "const": "shadow", - "default": "shadow", - "title": "Mode", - "type": "string" - }, "next_action": { "title": "Next Action", "type": "string" @@ -15150,7 +15018,6 @@ "type": "string" }, "verification_required": { - "const": true, "default": true, "title": "Verification Required", "type": "boolean" @@ -15176,12 +15043,6 @@ "OwnerAcceptanceQueueResponse": { "additionalProperties": false, "properties": { - "authoritative": { - "const": false, - "default": false, - "title": "Authoritative", - "type": "boolean" - }, "candidate": { "title": "Candidate", "type": "integer" @@ -15192,12 +15053,6 @@ "title": "Derivation", "type": "string" }, - "enforcement_effect": { - "const": "none", - "default": "none", - "title": "Enforcement Effect", - "type": "string" - }, "entries": { "items": { "$ref": "#/components/schemas/OwnerAcceptanceQueueEntry" @@ -15217,12 +15072,6 @@ "title": "Has More", "type": "boolean" }, - "mode": { - "const": "shadow", - "default": "shadow", - "title": "Mode", - "type": "string" - }, "status": { "const": "ok", "default": "ok", @@ -21487,6 +21336,163 @@ "title": "ProductOwnerActionContext", "type": "object" }, + "ProductOwnerAuthorityEvaluation": { + "additionalProperties": false, + "properties": { + "actor_identity_id": { + "title": "Actor Identity Id", + "type": "string" + }, + "actor_is_preferred": { + "default": false, + "title": "Actor Is Preferred", + "type": "boolean" + }, + "context": { + "$ref": "#/components/schemas/ProductOwnerActionContext" + }, + "decision": { + "enum": [ + "authorized", + "denied", + "not_required", + "unavailable" + ], + "title": "Decision", + "type": "string" + }, + "notify_owner_identity_ids": { + "default": [], + "items": { + "type": "string" + }, + "title": "Notify Owner Identity Ids", + "type": "array" + }, + "policy_digest": { + "default": "", + "title": "Policy Digest", + "type": "string" + }, + "policy_record_id": { + "default": "", + "title": "Policy Record Id", + "type": "string" + }, + "policy_revision": { + "anyOf": [ + { + "minimum": 1.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Policy Revision" + }, + "quorum": { + "const": 1, + "default": 1, + "title": "Quorum", + "type": "integer" + }, + "reason_code": { + "title": "Reason Code", + "type": "string" + }, + "requirement_digest": { + "default": "", + "title": "Requirement Digest", + "type": "string" + }, + "requirement_record_id": { + "default": "", + "title": "Requirement Record Id", + "type": "string" + }, + "requirement_revision": { + "anyOf": [ + { + "minimum": 1.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Requirement Revision" + }, + "routing_digest": { + "default": "", + "title": "Routing Digest", + "type": "string" + }, + "routing_record_id": { + "default": "", + "title": "Routing Record Id", + "type": "string" + }, + "routing_revision": { + "anyOf": [ + { + "minimum": 1.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Routing Revision" + }, + "satisfying_owner_identity_ids": { + "default": [], + "items": { + "type": "string" + }, + "title": "Satisfying Owner Identity Ids", + "type": "array" + }, + "schema_version": { + "default": 1, + "minimum": 1.0, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "decision", + "reason_code", + "context", + "actor_identity_id" + ], + "title": "ProductOwnerAuthorityEvaluation", + "type": "object" + }, + "ProductOwnerAuthorityEvaluationResponse": { + "additionalProperties": false, + "properties": { + "evaluation": { + "$ref": "#/components/schemas/ProductOwnerAuthorityEvaluation" + }, + "status": { + "const": "ok", + "default": "ok", + "title": "Status", + "type": "string" + }, + "trace_id": { + "title": "Trace Id", + "type": "string" + } + }, + "required": [ + "trace_id", + "evaluation" + ], + "title": "ProductOwnerAuthorityEvaluationResponse", + "type": "object" + }, "ProductOwnerGrant": { "additionalProperties": false, "properties": { @@ -21790,12 +21796,6 @@ "ProductOwnerReadModel": { "additionalProperties": false, "properties": { - "authoritative": { - "const": false, - "default": false, - "title": "Authoritative", - "type": "boolean" - }, "current_policy": { "anyOf": [ { @@ -21826,18 +21826,6 @@ } ] }, - "enforcement_effect": { - "const": "none", - "default": "none", - "title": "Enforcement Effect", - "type": "string" - }, - "mode": { - "const": "shadow", - "default": "shadow", - "title": "Mode", - "type": "string" - }, "policy_history_count": { "default": 0, "minimum": 0.0, @@ -22043,12 +22031,6 @@ "title": "Effective At", "type": "string" }, - "enforcement_mode": { - "const": "shadow", - "default": "shadow", - "title": "Enforcement Mode", - "type": "string" - }, "product": { "title": "Product", "type": "string" @@ -22365,181 +22347,6 @@ "title": "ProductOwnerSelfReviewPolicy", "type": "object" }, - "ProductOwnerShadowEvaluation": { - "additionalProperties": false, - "properties": { - "actor_identity_id": { - "title": "Actor Identity Id", - "type": "string" - }, - "actor_is_preferred": { - "default": false, - "title": "Actor Is Preferred", - "type": "boolean" - }, - "authoritative": { - "const": false, - "default": false, - "title": "Authoritative", - "type": "boolean" - }, - "context": { - "$ref": "#/components/schemas/ProductOwnerActionContext" - }, - "decision": { - "enum": [ - "authorized", - "denied", - "not_required", - "unavailable" - ], - "title": "Decision", - "type": "string" - }, - "enforcement_effect": { - "const": "none", - "default": "none", - "title": "Enforcement Effect", - "type": "string" - }, - "mode": { - "const": "shadow", - "default": "shadow", - "title": "Mode", - "type": "string" - }, - "notify_owner_identity_ids": { - "default": [], - "items": { - "type": "string" - }, - "title": "Notify Owner Identity Ids", - "type": "array" - }, - "policy_digest": { - "default": "", - "title": "Policy Digest", - "type": "string" - }, - "policy_record_id": { - "default": "", - "title": "Policy Record Id", - "type": "string" - }, - "policy_revision": { - "anyOf": [ - { - "minimum": 1.0, - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Policy Revision" - }, - "quorum": { - "const": 1, - "default": 1, - "title": "Quorum", - "type": "integer" - }, - "reason_code": { - "title": "Reason Code", - "type": "string" - }, - "requirement_digest": { - "default": "", - "title": "Requirement Digest", - "type": "string" - }, - "requirement_record_id": { - "default": "", - "title": "Requirement Record Id", - "type": "string" - }, - "requirement_revision": { - "anyOf": [ - { - "minimum": 1.0, - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Requirement Revision" - }, - "routing_digest": { - "default": "", - "title": "Routing Digest", - "type": "string" - }, - "routing_record_id": { - "default": "", - "title": "Routing Record Id", - "type": "string" - }, - "routing_revision": { - "anyOf": [ - { - "minimum": 1.0, - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Routing Revision" - }, - "satisfying_owner_identity_ids": { - "default": [], - "items": { - "type": "string" - }, - "title": "Satisfying Owner Identity Ids", - "type": "array" - }, - "schema_version": { - "default": 1, - "minimum": 1.0, - "title": "Schema Version", - "type": "integer" - } - }, - "required": [ - "decision", - "reason_code", - "context", - "actor_identity_id" - ], - "title": "ProductOwnerShadowEvaluation", - "type": "object" - }, - "ProductOwnerShadowEvaluationResponse": { - "additionalProperties": false, - "properties": { - "evaluation": { - "$ref": "#/components/schemas/ProductOwnerShadowEvaluation" - }, - "status": { - "const": "ok", - "default": "ok", - "title": "Status", - "type": "string" - }, - "trace_id": { - "title": "Trace Id", - "type": "string" - } - }, - "required": [ - "trace_id", - "evaluation" - ], - "title": "ProductOwnerShadowEvaluationResponse", - "type": "object" - }, "ProductPreviewProfile": { "additionalProperties": false, "properties": { @@ -59847,10 +59654,109 @@ "summary": "Apply product onboarding records" } }, - "/v1/product-owner/policies/apply": { - "post": { - "operationId": "apply_product_owner_policy", + "/v1/product-owner/evaluation": { + "get": { + "operationId": "read_product_owner_authority_evaluation", "parameters": [ + { + "in": "query", + "name": "product", + "required": true, + "schema": { + "title": "Product", + "type": "string" + } + }, + { + "in": "query", + "name": "system", + "required": true, + "schema": { + "title": "System", + "type": "string" + } + }, + { + "in": "query", + "name": "repository_id", + "required": true, + "schema": { + "title": "Repository Id", + "type": "string" + } + }, + { + "in": "query", + "name": "environment", + "required": true, + "schema": { + "title": "Environment", + "type": "string" + } + }, + { + "in": "query", + "name": "action", + "required": true, + "schema": { + "title": "Action", + "type": "string" + } + }, + { + "in": "query", + "name": "claimed_policy_revision", + "required": false, + "schema": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Claimed Policy Revision" + } + }, + { + "in": "query", + "name": "claimed_policy_digest", + "required": false, + "schema": { + "default": "", + "title": "Claimed Policy Digest", + "type": "string" + } + }, + { + "in": "query", + "name": "claimed_requirement_revision", + "required": false, + "schema": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Claimed Requirement Revision" + } + }, + { + "in": "query", + "name": "claimed_requirement_digest", + "required": false, + "schema": { + "default": "", + "title": "Claimed Requirement Digest", + "type": "string" + } + }, { "in": "header", "name": "Authorization", @@ -59860,24 +59766,24 @@ "title": "Authorization", "type": "string" } + }, + { + "in": "header", + "name": "Cookie", + "required": false, + "schema": { + "default": "", + "title": "Cookie", + "type": "string" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProductOwnerPolicyApplyEnvelope" - } - } - }, - "required": true - }, "responses": { - "202": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProductOwnerPolicyApplyResponse" + "$ref": "#/components/schemas/ProductOwnerAuthorityEvaluationResponse" } } }, @@ -59934,31 +59840,13 @@ "description": "Service Unavailable" } }, - "summary": "Apply or dry-run a product Owner policy revision" + "summary": "Evaluate current product Owner authority" } }, - "/v1/product-owner/policy": { - "get": { - "operationId": "read_product_owner_policy", + "/v1/product-owner/policies/apply": { + "post": { + "operationId": "apply_product_owner_policy", "parameters": [ - { - "in": "query", - "name": "product", - "required": true, - "schema": { - "title": "Product", - "type": "string" - } - }, - { - "in": "query", - "name": "system", - "required": true, - "schema": { - "title": "System", - "type": "string" - } - }, { "in": "header", "name": "Authorization", @@ -59968,24 +59856,24 @@ "title": "Authorization", "type": "string" } - }, - { - "in": "header", - "name": "Cookie", - "required": false, - "schema": { - "default": "", - "title": "Cookie", - "type": "string" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProductOwnerPolicyApplyEnvelope" + } + } + }, + "required": true + }, "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProductOwnerReadResponse" + "$ref": "#/components/schemas/ProductOwnerPolicyApplyResponse" } } }, @@ -60042,12 +59930,12 @@ "description": "Service Unavailable" } }, - "summary": "Read the shadow product Owner policy bundle" + "summary": "Apply or dry-run a product Owner policy revision" } }, - "/v1/product-owner/requirement": { + "/v1/product-owner/policy": { "get": { - "operationId": "read_product_owner_requirement", + "operationId": "read_product_owner_policy", "parameters": [ { "in": "query", @@ -60150,102 +60038,12 @@ "description": "Service Unavailable" } }, - "summary": "Read the shadow product Owner requirement bundle" - } - }, - "/v1/product-owner/requirements/apply": { - "post": { - "operationId": "apply_product_owner_requirement", - "parameters": [ - { - "in": "header", - "name": "Authorization", - "required": false, - "schema": { - "default": "", - "title": "Authorization", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProductOwnerRequirementApplyEnvelope" - } - } - }, - "required": true - }, - "responses": { - "202": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProductOwnerRequirementApplyResponse" - } - } - }, - "description": "Successful Response" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LaunchplaneErrorResponse" - } - } - }, - "description": "Bad Request" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LaunchplaneErrorResponse" - } - } - }, - "description": "Unauthorized" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LaunchplaneErrorResponse" - } - } - }, - "description": "Forbidden" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LaunchplaneErrorResponse" - } - } - }, - "description": "Conflict" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LaunchplaneErrorResponse" - } - } - }, - "description": "Service Unavailable" - } - }, - "summary": "Apply or dry-run a product Owner requirement revision" + "summary": "Read the product Owner policy bundle" } }, - "/v1/product-owner/routing": { + "/v1/product-owner/requirement": { "get": { - "operationId": "read_product_owner_routing", + "operationId": "read_product_owner_requirement", "parameters": [ { "in": "query", @@ -60348,12 +60146,12 @@ "description": "Service Unavailable" } }, - "summary": "Read non-authoritative product Owner routing" + "summary": "Read the product Owner requirement bundle" } }, - "/v1/product-owner/routing/apply": { + "/v1/product-owner/requirements/apply": { "post": { - "operationId": "apply_product_owner_routing", + "operationId": "apply_product_owner_requirement", "parameters": [ { "in": "header", @@ -60370,7 +60168,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProductOwnerRoutingApplyEnvelope" + "$ref": "#/components/schemas/ProductOwnerRequirementApplyEnvelope" } } }, @@ -60381,7 +60179,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProductOwnerRoutingApplyResponse" + "$ref": "#/components/schemas/ProductOwnerRequirementApplyResponse" } } }, @@ -60438,12 +60236,12 @@ "description": "Service Unavailable" } }, - "summary": "Apply or dry-run non-authoritative product Owner routing" + "summary": "Apply or dry-run a product Owner requirement revision" } }, - "/v1/product-owner/shadow-evaluation": { + "/v1/product-owner/routing": { "get": { - "operationId": "read_product_owner_shadow_evaluation", + "operationId": "read_product_owner_routing", "parameters": [ { "in": "query", @@ -60464,86 +60262,95 @@ } }, { - "in": "query", - "name": "repository_id", - "required": true, - "schema": { - "title": "Repository Id", - "type": "string" - } - }, - { - "in": "query", - "name": "environment", - "required": true, + "in": "header", + "name": "Authorization", + "required": false, "schema": { - "title": "Environment", + "default": "", + "title": "Authorization", "type": "string" } }, { - "in": "query", - "name": "action", - "required": true, + "in": "header", + "name": "Cookie", + "required": false, "schema": { - "title": "Action", + "default": "", + "title": "Cookie", "type": "string" } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProductOwnerReadResponse" + } + } + }, + "description": "Successful Response" }, - { - "in": "query", - "name": "claimed_policy_revision", - "required": false, - "schema": { - "anyOf": [ - { - "minimum": 1, - "type": "integer" - }, - { - "type": "null" + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LaunchplaneErrorResponse" } - ], - "title": "Claimed Policy Revision" - } + } + }, + "description": "Bad Request" }, - { - "in": "query", - "name": "claimed_policy_digest", - "required": false, - "schema": { - "default": "", - "title": "Claimed Policy Digest", - "type": "string" - } + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LaunchplaneErrorResponse" + } + } + }, + "description": "Unauthorized" }, - { - "in": "query", - "name": "claimed_requirement_revision", - "required": false, - "schema": { - "anyOf": [ - { - "minimum": 1, - "type": "integer" - }, - { - "type": "null" + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LaunchplaneErrorResponse" } - ], - "title": "Claimed Requirement Revision" - } + } + }, + "description": "Forbidden" }, - { - "in": "query", - "name": "claimed_requirement_digest", - "required": false, - "schema": { - "default": "", - "title": "Claimed Requirement Digest", - "type": "string" - } + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LaunchplaneErrorResponse" + } + } + }, + "description": "Conflict" }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LaunchplaneErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + }, + "summary": "Read non-authoritative product Owner routing" + } + }, + "/v1/product-owner/routing/apply": { + "post": { + "operationId": "apply_product_owner_routing", + "parameters": [ { "in": "header", "name": "Authorization", @@ -60553,24 +60360,24 @@ "title": "Authorization", "type": "string" } - }, - { - "in": "header", - "name": "Cookie", - "required": false, - "schema": { - "default": "", - "title": "Cookie", - "type": "string" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProductOwnerRoutingApplyEnvelope" + } + } + }, + "required": true + }, "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProductOwnerShadowEvaluationResponse" + "$ref": "#/components/schemas/ProductOwnerRoutingApplyResponse" } } }, @@ -60627,7 +60434,7 @@ "description": "Service Unavailable" } }, - "summary": "Evaluate product Owner authority without changing enforcement" + "summary": "Apply or dry-run non-authoritative product Owner routing" } }, "/v1/product-profiles": { diff --git a/frontend/generated/openapi-ui.json b/frontend/generated/openapi-ui.json index 262cf7c31..86939f4b0 100644 --- a/frontend/generated/openapi-ui.json +++ b/frontend/generated/openapi-ui.json @@ -6981,12 +6981,6 @@ "title": "Authorizes", "type": "array" }, - "neutral": { - "const": true, - "default": true, - "title": "Neutral", - "type": "boolean" - }, "observation": { "$ref": "#/components/schemas/MergeReadinessAdvisoryObservation" }, @@ -7002,7 +6996,6 @@ "required": [ "authoritative", "authorizes", - "neutral", "observation", "observation_scope" ], @@ -7219,14 +7212,6 @@ "GovernanceOwnerHistoryEntry": { "additionalProperties": false, "properties": { - "authorizes": { - "default": [], - "items": { - "type": "string" - }, - "title": "Authorizes", - "type": "array" - }, "decision_relationship": { "enum": [ "current", @@ -7260,7 +7245,6 @@ } }, "required": [ - "authorizes", "decision_relationship", "human_action_semantics", "record", @@ -7273,19 +7257,11 @@ "additionalProperties": false, "properties": { "authoritative": { - "const": false, - "default": false, + "const": true, + "default": true, "title": "Authoritative", "type": "boolean" }, - "authorizes": { - "default": [], - "items": { - "type": "string" - }, - "title": "Authorizes", - "type": "array" - }, "current": { "$ref": "#/components/schemas/OwnerAcceptanceDecision" }, @@ -7304,15 +7280,14 @@ "type": "integer" }, "mode": { - "const": "historical_product_judgment", - "default": "historical_product_judgment", + "const": "owner_acceptance", + "default": "owner_acceptance", "title": "Mode", "type": "string" } }, "required": [ "authoritative", - "authorizes", "current", "history", "level", @@ -11399,12 +11374,6 @@ "OwnerAcceptanceCurrentItemsResponse": { "additionalProperties": false, "properties": { - "authoritative": { - "const": false, - "default": false, - "title": "Authoritative", - "type": "boolean" - }, "candidate_count": { "title": "Candidate Count", "type": "integer" @@ -11415,12 +11384,6 @@ "title": "Derivation", "type": "string" }, - "enforcement_effect": { - "const": "none", - "default": "none", - "title": "Enforcement Effect", - "type": "string" - }, "evaluated_count": { "title": "Evaluated Count", "type": "integer" @@ -11436,12 +11399,6 @@ "title": "Items", "type": "array" }, - "mode": { - "const": "shadow", - "default": "shadow", - "title": "Mode", - "type": "string" - }, "repository_count": { "title": "Repository Count", "type": "integer" @@ -11480,14 +11437,11 @@ } }, "required": [ - "authoritative", "candidate_count", "derivation", - "enforcement_effect", "evaluated_count", "generated_at", "items", - "mode", "repository_count", "repository_failure_count", "repository_failures", @@ -11508,20 +11462,6 @@ "title": "Admissible", "type": "boolean" }, - "authoritative": { - "const": false, - "default": false, - "title": "Authoritative", - "type": "boolean" - }, - "authorizes": { - "default": [], - "items": { - "type": "string" - }, - "title": "Authorizes", - "type": "array" - }, "binding": { "anyOf": [ { @@ -11542,12 +11482,6 @@ } ] }, - "enforcement_effect": { - "const": "none", - "default": "none", - "title": "Enforcement Effect", - "type": "string" - }, "evaluated_at": { "title": "Evaluated At", "type": "string" @@ -11565,12 +11499,6 @@ "title": "Human Action Semantics", "type": "string" }, - "mode": { - "const": "shadow", - "default": "shadow", - "title": "Mode", - "type": "string" - }, "products": { "default": [], "items": { @@ -11625,14 +11553,10 @@ }, "required": [ "admissible", - "authoritative", - "authorizes", "binding", "current_event", - "enforcement_effect", "evaluated_at", "human_action_semantics", - "mode", "products", "reason_code", "schema_version", @@ -11866,17 +11790,8 @@ }, "OwnerAcceptanceEventSemantics": { "additionalProperties": false, - "description": "Machine-readable projection of a stored human product-review action.\n\nThe stored enum and every persisted digest stay unchanged. This projection\nexists so no API client can read Owner product review as merge readiness,\nlanded state, or production authorization.", + "description": "Machine-readable projection of a stored human product-review action.\n\nThe stored enum and every persisted digest stay unchanged. Acceptance is an\nauthoritative merge-admission prerequisite, while technical readiness,\nlanding, and production authorization remain separate decisions.", "properties": { - "authorizes": { - "default": [], - "description": "Always empty. Owner product review authorizes nothing on its own.", - "items": { - "type": "string" - }, - "title": "Authorizes", - "type": "array" - }, "human_action_semantics": { "enum": [ "none", @@ -11891,7 +11806,6 @@ } }, "required": [ - "authorizes", "human_action_semantics" ], "title": "OwnerAcceptanceEventSemantics", @@ -12057,14 +11971,6 @@ "title": "Admissible", "type": "boolean" }, - "authorizes": { - "default": [], - "items": { - "type": "string" - }, - "title": "Authorizes", - "type": "array" - }, "binding": { "anyOf": [ { @@ -12157,7 +12063,6 @@ "required": [ "action", "admissible", - "authorizes", "binding", "current_event", "environment", @@ -12178,18 +12083,6 @@ "title": "Action", "type": "string" }, - "authoritative": { - "const": false, - "default": false, - "title": "Authoritative", - "type": "boolean" - }, - "enforcement_effect": { - "const": "none", - "default": "none", - "title": "Enforcement Effect", - "type": "string" - }, "environment": { "title": "Environment", "type": "string" @@ -12213,12 +12106,6 @@ "title": "Ledger Status", "type": "string" }, - "mode": { - "const": "shadow", - "default": "shadow", - "title": "Mode", - "type": "string" - }, "next_action": { "title": "Next Action", "type": "string" @@ -12253,7 +12140,6 @@ "type": "string" }, "verification_required": { - "const": true, "default": true, "title": "Verification Required", "type": "boolean" @@ -12261,13 +12147,10 @@ }, "required": [ "action", - "authoritative", - "enforcement_effect", "environment", "latest_binding", "latest_event", "ledger_status", - "mode", "next_action", "occurred_at", "product", @@ -12284,12 +12167,6 @@ "OwnerAcceptanceQueueResponse": { "additionalProperties": false, "properties": { - "authoritative": { - "const": false, - "default": false, - "title": "Authoritative", - "type": "boolean" - }, "candidate": { "title": "Candidate", "type": "integer" @@ -12300,12 +12177,6 @@ "title": "Derivation", "type": "string" }, - "enforcement_effect": { - "const": "none", - "default": "none", - "title": "Enforcement Effect", - "type": "string" - }, "entries": { "items": { "$ref": "#/components/schemas/OwnerAcceptanceQueueEntry" @@ -12325,12 +12196,6 @@ "title": "Has More", "type": "boolean" }, - "mode": { - "const": "shadow", - "default": "shadow", - "title": "Mode", - "type": "string" - }, "status": { "const": "ok", "default": "ok", @@ -12351,15 +12216,12 @@ } }, "required": [ - "authoritative", "candidate", "derivation", - "enforcement_effect", "entries", "entry_count", "generated_at", "has_more", - "mode", "status", "total", "trace_id", diff --git a/frontend/src/EngineeringGovernanceProjectionRoute.tsx b/frontend/src/EngineeringGovernanceProjectionRoute.tsx index 8ca8416c0..ff926bb43 100644 --- a/frontend/src/EngineeringGovernanceProjectionRoute.tsx +++ b/frontend/src/EngineeringGovernanceProjectionRoute.tsx @@ -113,17 +113,17 @@ export function EngineeringGovernanceProjectionRoute({ /> ) : undefined } - description="Inspect historical Owner product judgment, current ephemeral merge readiness, immutable admission, separate landing outcome, and neutral advisory observations for one pull request." + description="Inspect authoritative Owner acceptance, current ephemeral merge readiness, immutable admission, separate landing outcome, and GitHub status projections for one pull request." icon={ShieldCheck} title="Governance evidence" view="governance-projection" > - - Level 1 records product judgment and exposes authorizes: []. + + Level 1 records authoritative Owner acceptance for the exact change. Level 2 remains mode: ephemeral, authoritative: false, and authorizes no effect. Level 3 admits one exact attempt; it does not mean the provider effect landed. Landing outcome is an independent durable fact. - Advisory GitHub checks are neutral visibility only. + GitHub checks route reviewers and mirror status; decisions remain in Launchplane. +

- Owner accepted means product judgment only. authorizes: []. + Owner accepted is the authoritative product decision for this exact change. It never means merge-ready, admitted, landed, release-authorized, or production-authorized.

@@ -426,18 +426,19 @@ function GovernanceAdvisoryFacet({ projection }: { projection: GovernanceProject return (

- These check observations are explicitly neutral, non-authoritative, and excluded - from required technical checks. + These check observations may be success, action required, failure, or neutral. + Launchplane remains authoritative, and the observed checks are excluded from + technical-check evidence.

{projection.advisory_observations.length ? (
    diff --git a/frontend/src/EngineeringOps.tsx b/frontend/src/EngineeringOps.tsx index 897b810e9..d97c93dd0 100644 --- a/frontend/src/EngineeringOps.tsx +++ b/frontend/src/EngineeringOps.tsx @@ -27,7 +27,7 @@ import type { DevFixtureMode } from "./dev-fixture-loader"; const ENGINEERING_SURFACES = [ { detail: - "Inspect historical Owner judgment, current readiness, immutable admission, landing outcome, and advisory observations without fusing authority.", + "Inspect authoritative Owner acceptance, current readiness, immutable admission, landing outcome, and GitHub observations without fusing the layers.", icon: ShieldCheck, label: "Read only", title: "Governance evidence", diff --git a/frontend/src/EngineeringOwnerAcceptanceRoute.tsx b/frontend/src/EngineeringOwnerAcceptanceRoute.tsx index c548bd326..939e3de3f 100644 --- a/frontend/src/EngineeringOwnerAcceptanceRoute.tsx +++ b/frontend/src/EngineeringOwnerAcceptanceRoute.tsx @@ -155,12 +155,12 @@ export function EngineeringOwnerAcceptanceRoute({ title="Owner product review" view="owner-acceptance" > - - Owner product review is separate from technical checks, engineering review, merge - readiness and admission, and production authorization. All decisions are{" "} - mode: shadow, authoritative: false,{" "} - enforcement_effect: none. Current items come from open pull requests in - repositories with active change-impact policy records and are evaluated server-side. + + Owner acceptance is required for product-impacting changes and is evaluated against the + exact pull request head, tree, serving preview, artifact, runtime identity, impact policy, + and Owner policy. It remains separate from technical checks, engineering review, merge + admission, landing, and production authorization. Current items come from open pull requests + in repositories with active change-impact policy records and are evaluated server-side. Recorded entries are{" "} Recorded — derived from the persisted acceptance event ledger with no live GitHub calls. Recorded queue rows remain read-only. @@ -232,9 +232,6 @@ function ownerAcceptanceCurrentItemsForFixture( return { status: "ok", trace_id: `fixture-owner-acceptance-current-${fixtureMode}`, - mode: "shadow", - authoritative: false, - enforcement_effect: "none", derivation: "active_change_impact_open_pull_requests", generated_at: decision.evaluated_at, viewer_capabilities: ownerAcceptanceFixtureViewerCapabilities(decision, fixtureMode), @@ -741,8 +738,9 @@ function ownerAcceptanceFixtureViewerCapabilities( fixtureMode: DevFixtureMode, ): OwnerAcceptanceViewerCapabilities { const eventWriteAuthorized = fixtureMode !== "empty"; - const currentOwner = - new URLSearchParams(window.location.search).get("viewer") !== "non-owner"; + const viewer = new URLSearchParams(window.location.search).get("viewer"); + const currentOwner = viewer !== "non-owner"; + const canAccept = currentOwner && viewer !== "contributor"; return { event_write_authorized: eventWriteAuthorized, bindings: eventWriteAuthorized @@ -757,12 +755,14 @@ function ownerAcceptanceFixtureViewerCapabilities( action: product.action, environment: product.environment, can_submit_event: currentOwner, - can_accept: currentOwner, + can_accept: canAccept, can_request_changes: currentOwner, can_revoke: currentOwner, - reason_code: currentOwner + reason_code: canAccept ? "current_product_owner" - : "not_current_product_owner", + : currentOwner + ? "self_review_denied" + : "not_current_product_owner", } satisfies OwnerAcceptanceViewerBindingEligibility, ] : [], @@ -836,7 +836,6 @@ function OwnerAcceptanceActionPanel({ : payload.action === "revoked" ? ("product_review_revoked" as const) : ("product_review_changes_requested" as const), - authorizes: [], }, decision: { ...decision, @@ -913,7 +912,7 @@ function OwnerAcceptanceActionPanel({
    {binding.product}{binding.system} · {binding.action} · {binding.environment}
    {binding.binding_sha256.slice(0, 12)} -

    Product review only. Record your judgment of this exact change. This does not indicate that technical checks passed, make the pull request merge-ready, or authorize production. Launchplane revalidates the exact change and your Owner authority at write time.

    +

    Authoritative Owner decision. Record your judgment of this exact change. Acceptance satisfies the Owner prerequisite only when the binding remains current; technical checks, engineering review, merge admission, landing, and production authorization remain separate. Launchplane revalidates the exact change and your Owner authority at write time.

    {!eligibility.can_accept ?

    You contributed to this exact change, so product policy prevents you from accepting it. You may still request changes or revoke prior acceptance.

    : null}
); @@ -1042,16 +1041,8 @@ function OwnerAcceptanceContent({ {data.trace_id ? {data.trace_id} : null}
- Mode - {data.mode} -
-
- Authoritative - {String(data.authoritative)} -
-
- Enforcement - {data.enforcement_effect} + Authority + Launchplane Owner acceptance
{data.truncated ? (
@@ -1162,8 +1153,7 @@ function OwnerAcceptanceEntryCard({ entry }: { entry: OwnerAcceptanceQueueEntry
- {entry.mode} - enforcement: {entry.enforcement_effect} + recorded ledger verification required: {String(entry.verification_required)} {entry.occurred_at ? ( Recorded {formatTime(entry.occurred_at)} diff --git a/frontend/src/dev-fixtures.ts b/frontend/src/dev-fixtures.ts index 4f84a0a66..d3e7ab0c6 100644 --- a/frontend/src/dev-fixtures.ts +++ b/frontend/src/dev-fixtures.ts @@ -2886,9 +2886,6 @@ function _ownerAcceptanceQueueEntry( system: binding.system, action: binding.action, environment: binding.environment, - mode: "shadow" as const, - authoritative: false as const, - enforcement_effect: "none" as const, verification_required: true as const, ledger_status, next_action, @@ -2905,15 +2902,12 @@ export function ownerAcceptanceForFixture( if (fixture === "empty") { return { - authoritative: false, candidate: 0, derivation: "ledger_only", - enforcement_effect: "none", entries: [], entry_count: 0, generated_at: OBSERVED_AT, has_more: false, - mode: "shadow", status: "ok", total: 0, trace_id: "fixture-owner-acceptance-empty", @@ -2981,15 +2975,12 @@ export function ownerAcceptanceForFixture( const entries = fixture === "missing" ? allEntries.slice(0, 1) : allEntries; return { - authoritative: false, candidate: entries.length, derivation: "ledger_only", - enforcement_effect: "none", entries, entry_count: entries.length, generated_at: OBSERVED_AT, has_more: false, - mode: "shadow", status: "ok", total: entries.length, trace_id: "fixture-owner-acceptance", @@ -3008,16 +2999,12 @@ export function ownerAcceptanceEvaluationForFixture( }); return { schema_version: 1, - mode: "shadow", - authoritative: false, - enforcement_effect: "none", status: "pending", reason_code: "acceptance_missing", binding, current_event: null, admissible: false, human_action_semantics: "none", - authorizes: [], products: [ { schema_version: 1, @@ -3031,7 +3018,6 @@ export function ownerAcceptanceEvaluationForFixture( current_event: null, admissible: false, human_action_semantics: "none", - authorizes: [], }, ], evaluated_at: OBSERVED_AT, @@ -3088,7 +3074,6 @@ export function governanceProjectionForFixture( admissible: currentStatus === "accepted", human_action_semantics: scenario === "15" ? "product_review_revoked" : "product_review_accepted", - authorizes: [], }; const products: OwnerAcceptanceProductDecision[] = scenario === "24" @@ -3105,9 +3090,6 @@ export function governanceProjectionForFixture( : [product]; const decision: OwnerAcceptanceDecision = { schema_version: 1, - mode: "shadow", - authoritative: false, - enforcement_effect: "none", status: currentStatus, reason_code: currentReason, binding, @@ -3115,7 +3097,6 @@ export function governanceProjectionForFixture( admissible: currentStatus === "accepted", human_action_semantics: scenario === "15" ? "product_review_revoked" : "product_review_accepted", - authorizes: [], products, evaluated_at: OBSERVED_AT, }; @@ -3144,9 +3125,8 @@ export function governanceProjectionForFixture( }, owner_judgment: { level: 1, - mode: "historical_product_judgment", - authoritative: false, - authorizes: [], + mode: "owner_acceptance", + authoritative: true, current: decision, history: [ { @@ -3154,7 +3134,6 @@ export function governanceProjectionForFixture( human_action_semantics: "product_review_accepted", target_status: "current", decision_relationship: scenario === "15" ? "historical" : "current", - authorizes: [], }, ...(scenario === "15" ? [ @@ -3163,7 +3142,6 @@ export function governanceProjectionForFixture( human_action_semantics: "product_review_revoked" as const, target_status: "current" as const, decision_relationship: "current" as const, - authorizes: [], }, ] : []), @@ -3204,7 +3182,6 @@ export function governanceProjectionForFixture( state: "neutral", app_id: 42, }, - neutral: true, authoritative: false, authorizes: [], }, @@ -3215,7 +3192,6 @@ export function governanceProjectionForFixture( state: "neutral", app_id: 42, }, - neutral: true, authoritative: false, authorizes: [], }, diff --git a/frontend/src/generated/openapi.ts/types.gen.ts b/frontend/src/generated/openapi.ts/types.gen.ts index 62ec0b529..ab9b63a75 100644 --- a/frontend/src/generated/openapi.ts/types.gen.ts +++ b/frontend/src/generated/openapi.ts/types.gen.ts @@ -544,7 +544,6 @@ export type GitHubIssueInboxRepositoryGroup = { export type GovernanceAdvisoryObservation = { authoritative: false; authorizes: Array; - neutral: true; observation: MergeReadinessAdvisoryObservation; observation_scope: 'current_readiness' | 'admission_readiness'; }; @@ -580,7 +579,6 @@ export type GovernanceMergeReadinessFacet = { }; export type GovernanceOwnerHistoryEntry = { - authorizes: Array; decision_relationship: 'current' | 'historical'; human_action_semantics: 'none' | 'product_review_accepted' | 'product_review_changes_requested' | 'product_review_revoked' | 'product_review_superseded' | 'product_review_invalidated'; record: OwnerAcceptanceEventRecord; @@ -588,12 +586,11 @@ export type GovernanceOwnerHistoryEntry = { }; export type GovernanceOwnerJudgmentFacet = { - authoritative: false; - authorizes: Array; + authoritative: true; current: OwnerAcceptanceDecision; history: Array; level: 1; - mode: 'historical_product_judgment'; + mode: 'owner_acceptance'; }; export type GovernanceProjection = { @@ -1232,14 +1229,11 @@ export type OwnerAcceptanceCurrentItemsRepositoryFailure = { }; export type OwnerAcceptanceCurrentItemsResponse = { - authoritative: false; candidate_count: number; derivation: 'active_change_impact_open_pull_requests'; - enforcement_effect: 'none'; evaluated_count: number; generated_at: string; items: Array; - mode: 'shadow'; repository_count: number; repository_failure_count: number; repository_failures: Array; @@ -1252,14 +1246,10 @@ export type OwnerAcceptanceCurrentItemsResponse = { export type OwnerAcceptanceDecision = { admissible: boolean; - authoritative: false; - authorizes: Array; binding: OwnerAcceptanceBinding | null; current_event: OwnerAcceptanceEventRecord | null; - enforcement_effect: 'none'; evaluated_at: string; human_action_semantics: 'none' | 'product_review_accepted' | 'product_review_changes_requested' | 'product_review_revoked' | 'product_review_superseded' | 'product_review_invalidated'; - mode: 'shadow'; products: Array; reason_code: 'engineering_only' | 'acceptance_missing' | 'acceptance_valid' | 'changes_requested' | 'acceptance_revoked' | 'acceptance_stale' | 'change_impact_unavailable' | 'change_impact_stale' | 'multi_product_unsupported' | 'owner_authority_unavailable' | 'owner_authority_denied' | 'preview_evidence_unavailable' | 'preview_evidence_stale' | 'owner_review_expired' | 'preview_isolation_insufficient' | 'contributing_identity_unknown' | 'self_review_denied' | 'review_context_missing'; schema_version: number; @@ -1307,7 +1297,6 @@ export type OwnerAcceptanceEventResponse = { }; export type OwnerAcceptanceEventSemantics = { - authorizes: Array; human_action_semantics: 'none' | 'product_review_accepted' | 'product_review_changes_requested' | 'product_review_revoked' | 'product_review_superseded' | 'product_review_invalidated'; }; @@ -1343,7 +1332,6 @@ export type OwnerAcceptancePreviewIsolationBinding = { export type OwnerAcceptanceProductDecision = { action: string; admissible: boolean; - authorizes: Array; binding: OwnerAcceptanceBinding | null; current_event: OwnerAcceptanceEventRecord | null; environment: string; @@ -1357,13 +1345,10 @@ export type OwnerAcceptanceProductDecision = { export type OwnerAcceptanceQueueEntry = { action: string; - authoritative: false; - enforcement_effect: 'none'; environment: string; latest_binding: OwnerAcceptanceBinding; latest_event: OwnerAcceptanceEventRecord; ledger_status: 'not_required' | 'pending' | 'accepted' | 'changes_requested' | 'revoked' | 'stale' | 'unavailable'; - mode: 'shadow'; next_action: string; occurred_at: string; product: string; @@ -1372,19 +1357,16 @@ export type OwnerAcceptanceQueueEntry = { repository_id: string; schema_version: number; system: string; - verification_required: true; + verification_required: boolean; }; export type OwnerAcceptanceQueueResponse = { - authoritative: false; candidate: number; derivation: 'ledger_only'; - enforcement_effect: 'none'; entries: Array; entry_count: number; generated_at: string; has_more: boolean; - mode: 'shadow'; status: 'ok'; total: number; trace_id: string; diff --git a/frontend/tests/browser/operator-journeys.spec.ts b/frontend/tests/browser/operator-journeys.spec.ts index 85eab67ba..19a530531 100644 --- a/frontend/tests/browser/operator-journeys.spec.ts +++ b/frontend/tests/browser/operator-journeys.spec.ts @@ -748,18 +748,18 @@ test.describe("operator journeys", () => { await expect( page.getByRole("heading", { level: 1, name: "Governance evidence" }), ).toBeFocused(); - await expect(page.getByRole("region", { name: "Level 1 historical Owner judgment" })).toBeVisible(); + await expect(page.getByRole("region", { name: "Level 1 authoritative Owner acceptance" })).toBeVisible(); await expect(page.getByRole("region", { name: "Level 2 current merge readiness" })).toBeVisible(); await expect(page.getByRole("region", { name: "Level 3 immutable merge admission" })).toBeVisible(); await expect(page.getByRole("region", { name: "Separate landing outcome" })).toBeVisible(); - await expect(page.getByRole("region", { name: "Neutral advisory observations" })).toBeVisible(); - const owner = page.getByRole("region", { name: "Level 1 historical Owner judgment" }); - await expect(owner).toContainText("Owner product judgment"); - await expect(owner).toContainText("authorizes: []"); + await expect(page.getByRole("region", { name: "GitHub status observations" })).toBeVisible(); + const owner = page.getByRole("region", { name: "Level 1 authoritative Owner acceptance" }); + await expect(owner).toContainText("Owner acceptance"); + await expect(owner).toContainText("authoritative product decision"); await expect(owner).toContainText("product_review_accepted"); await expect(page.getByText("No admission recorded", { exact: true })).toBeVisible(); await expect(page.getByText("Not Observed · None target", { exact: true })).toBeVisible(); - await expect(page.getByText("Neutral · non-blocking", { exact: true })).toBeVisible(); + await expect(page.getByText("Non-authoritative", { exact: true })).toBeVisible(); await assertDocumentBasics(page); await captureScreenshot(page, testInfo, "governance-evidence-independent-facets"); diagnostics.assertClean(); @@ -772,7 +772,7 @@ test.describe("operator journeys", () => { await page.goto("/ui/engineering/governance-projection?fixture=products&scenario=15"); - const owner = page.getByRole("region", { name: "Level 1 historical Owner judgment" }); + const owner = page.getByRole("region", { name: "Level 1 authoritative Owner acceptance" }); await expect(owner.getByText("Revoked", { exact: true }).first()).toBeVisible(); await expect(page.getByRole("region", { name: "Level 3 immutable merge admission" })).toContainText("Recorded for current target"); await expect(page.getByRole("region", { name: "Separate landing outcome" })).toContainText("Landed · Current target"); @@ -804,7 +804,7 @@ test.describe("operator journeys", () => { await expect(page.getByText("preview_isolation_insufficient", { exact: true }).first()).toBeVisible(); await page.getByRole("button", { name: "Refresh governance" }).click(); await expect(page.getByText("Cached evidence", { exact: true })).toBeVisible(); - await expect(page.getByRole("region", { name: "Level 1 historical Owner judgment" })).toBeVisible(); + await expect(page.getByRole("region", { name: "Level 1 authoritative Owner acceptance" })).toBeVisible(); await page.goto("/ui/engineering/governance-projection?fixture=denied"); await expect(page.getByText("Access denied", { exact: true })).toBeVisible(); @@ -828,7 +828,7 @@ test.describe("operator journeys", () => { page.getByRole("link", { name: "Owner product review", exact: true }), ).toBeVisible(); await expect( - page.getByText("Shadow mode — product review evidence only"), + page.getByText("Launchplane is the Owner-review authority"), ).toBeVisible(); const recordedHistory = page.getByLabel("Recorded Owner acceptance history"); await expect(recordedHistory.getByText(/Recorded/).first()).toBeVisible(); @@ -946,20 +946,20 @@ test.describe("operator journeys", () => { diagnostics.assertClean(); }); - test("current Owner acceptance item records an exact shadow action", async ({ page }) => { + test("current Owner acceptance item records an authoritative exact action", async ({ page }) => { const diagnostics = monitorBrowser(page); await page.goto("/ui/engineering/owner-acceptance?fixture=products"); const panel = page.getByRole("region", { name: /Owner product review for/ }); await expect(panel).toBeVisible(); - await expect(panel.getByText("Product review only.", { exact: true })).toBeVisible(); - await expect(panel.getByText(/does not indicate that technical checks passed/i)).toBeVisible(); - await expect(panel.getByText(/make the pull request merge-ready/i)).toBeVisible(); - await expect(panel.getByText(/or authorize production/i)).toBeVisible(); + await expect(panel.getByText("Authoritative Owner decision.", { exact: true })).toBeVisible(); + await expect(panel.getByText(/technical checks, engineering review, merge admission/i)).toBeVisible(); + await expect(panel.getByText(/production authorization remain separate/i)).toBeVisible(); await panel.getByRole("button", { name: "Record product review" }).click(); - await expect(panel.getByText(/recorded in shadow mode/i)).toBeVisible(); - await expect(panel.getByText(/No merge or production authority/i)).toBeVisible(); + await expect(page.getByText("Owner product review: accepted", { exact: true }).first()).toBeVisible(); + await expect(panel.getByText(/authoritative Owner decision recorded/i)).toBeVisible(); + await expect(panel.getByText(/recompute exact-head merge readiness/i)).toBeVisible(); diagnostics.assertClean(); }); @@ -978,6 +978,9 @@ test.describe("operator journeys", () => { .fill("Please clarify the product behavior."); await submit.click(); + await expect( + page.getByText("Owner product review: changes requested", { exact: true }).first(), + ).toBeVisible(); await expect(panel.getByText("Resolution evidence required.", { exact: true })).toBeVisible(); await expect(submit).toBeDisabled(); await panel @@ -991,7 +994,7 @@ test.describe("operator journeys", () => { await submit.click(); await expect(panel.getByText("Resolution evidence required.", { exact: true })).toHaveCount(0); - await expect(panel.getByText(/recorded in shadow mode/i)).toBeVisible(); + await expect(panel.getByText(/authoritative Owner decision recorded/i)).toBeVisible(); diagnostics.assertClean(); }); @@ -1017,6 +1020,31 @@ test.describe("operator journeys", () => { diagnostics.assertClean(); }); + test("contributing Owner resets to an allowed action after submission", async ({ page }) => { + const diagnostics = monitorBrowser(page); + + await page.goto( + "/ui/engineering/owner-acceptance?fixture=products&viewer=contributor", + ); + + const panel = page.getByRole("region", { name: /Owner product review for/ }); + const action = panel.getByRole("combobox"); + const reason = panel.getByRole("textbox", { name: "Reason" }); + const submit = panel.getByRole("button", { name: "Record product review" }); + await expect(panel.getByText(/policy prevents you from accepting/i)).toBeVisible(); + await expect(action).toHaveValue("changes_requested"); + await expect(action.locator('option[value="accepted"]')).toHaveCount(0); + await reason.fill("The product flow still needs correction."); + await submit.click(); + + await expect(action).toHaveValue("changes_requested"); + await expect(reason).toHaveValue(""); + await expect(submit).toBeDisabled(); + await reason.fill("A second product issue remains."); + await expect(submit).toBeEnabled(); + diagnostics.assertClean(); + }); + test("request changes and revoke require explicit human input", async ({ page }) => { const diagnostics = monitorBrowser(page); @@ -1123,7 +1151,21 @@ test.describe("operator journeys", () => { }); await expect(activeLink).toBeVisible(); await expect(activeLink).toHaveAttribute("aria-current", "page"); + await expect(page.getByText("Owner product review: pending", { exact: true }).first()).toBeVisible(); + await expect(page.getByRole("region", { name: /Owner product review for/ })).toBeVisible(); + await assertDocumentBasics(page); + + diagnostics.assertClean(); + }); + + test("Owner acceptance denied state exposes no action surface", async ({ page }) => { + const diagnostics = monitorBrowser(page); + + await page.goto("/ui/engineering/owner-acceptance?fixture=denied"); + await expect(page.getByText("Access denied", { exact: true }).first()).toBeVisible(); + await expect(page.getByRole("button", { name: "Record product review" })).toHaveCount(0); + await assertDocumentBasics(page); diagnostics.assertClean(); }); diff --git a/tests/test_change_impact_http.py b/tests/test_change_impact_http.py index 32247a5e9..4115e1704 100644 --- a/tests/test_change_impact_http.py +++ b/tests/test_change_impact_http.py @@ -178,9 +178,7 @@ def _stored_dependency() -> ChangeImpactStoredEvidence: return ChangeImpactStoredEvidence( record_id="dependency-record-1", component="generic-web-runtime", - affected_products=( - ChangeImpactProductScope(product="generic-web-a", system="web"), - ), + affected_products=(ChangeImpactProductScope(product="generic-web-a", system="web"),), kind="dependency", reason="Launchplane storage maps the component to product A.", ) @@ -231,7 +229,7 @@ def _app( class ChangeImpactHttpTests(unittest.IsolatedAsyncioTestCase): - async def test_apply_read_and_server_derived_evaluate_are_shadow_only(self) -> None: + async def test_apply_read_and_server_derived_evaluate_are_authoritative(self) -> None: with TemporaryDirectory() as directory: store = _ChangeImpactStore(Path(directory), (_stored_dependency(),)) provider = _EvidenceProvider(_repository_evidence()) @@ -250,7 +248,7 @@ async def test_apply_read_and_server_derived_evaluate_are_shadow_only(self) -> N params={"repository_id": REPOSITORY_ID}, ) self.assertEqual(read_response.status_code, 200, read_response.text) - self.assertFalse(read_response.json()["read_model"]["authoritative"]) + self.assertNotIn("authoritative", read_response.json()["read_model"]) evaluated = await client.post( CHANGE_IMPACT_EVALUATION_ROUTE, @@ -261,7 +259,7 @@ async def test_apply_read_and_server_derived_evaluate_are_shadow_only(self) -> N "repository": REPOSITORY, "pull_request_number": 2000, }, - "metadata": {"request_id": "request-1", "reason": "shadow check"}, + "metadata": {"request_id": "request-1", "reason": "impact check"}, }, ) self.assertEqual(evaluated.status_code, 200, evaluated.text) @@ -270,7 +268,7 @@ async def test_apply_read_and_server_derived_evaluate_are_shadow_only(self) -> N self.assertEqual(evaluation["engineering_review_tier"], "routine") self.assertEqual(evaluation["required_engineering_review_count"], 1) self.assertEqual(evaluation["owner_impact"], "required") - self.assertFalse(evaluation["authoritative"]) + self.assertNotIn("authoritative", evaluation) self.assertEqual(len(provider.calls), 1) self.assertEqual( diff --git a/tests/test_github_app_identity.py b/tests/test_github_app_identity.py index 45c46e133..26666e1ff 100644 --- a/tests/test_github_app_identity.py +++ b/tests/test_github_app_identity.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Callable from datetime import datetime, timezone import unittest @@ -144,7 +145,12 @@ def api_request(**kwargs): # type: ignore[no-untyped-def] ) def test_rejects_expired_installation_token(self) -> None: - def api_request(**kwargs): # type: ignore[no-untyped-def] + calls: list[dict[str, object]] = [] + + def api_request(**kwargs: object) -> object: + calls.append(dict(kwargs)) + if kwargs["path"] == "/installation/token": + return None if kwargs["path"] == "/app": return {"id": 42} if kwargs["path"] == "/repos/example/repo/installation": @@ -169,6 +175,155 @@ def api_request(**kwargs): # type: ignore[no-untyped-def] now=datetime(2026, 8, 7, 14, 0, tzinfo=timezone.utc), ) + self.assertEqual(calls[-1]["path"], "/installation/token") + self.assertEqual(calls[-1]["method"], "DELETE") + self.assertEqual(calls[-1]["token"], "expired-token") + + def test_revokes_token_after_post_mint_validation_failures(self) -> None: + cases: tuple[tuple[str, dict[str, object], str], ...] = ( + ( + "malformed expiry", + { + "token": "malformed-expiry-token", + "expires_at": "not-a-timestamp", + "permissions": {"checks": "write"}, + "repositories": [{"id": 123, "full_name": "example/repo"}], + }, + "expiry is malformed", + ), + ( + "surplus permission", + { + "token": "surplus-permission-token", + "expires_at": "2026-08-07T15:00:00Z", + "permissions": {"checks": "write", "contents": "read"}, + "repositories": [{"id": 123, "full_name": "example/repo"}], + }, + "beyond advisory check projection", + ), + ( + "repository count", + { + "token": "repository-count-token", + "expires_at": "2026-08-07T15:00:00Z", + "permissions": {"checks": "write"}, + "repositories": [], + }, + "exactly one repository", + ), + ( + "repository id", + { + "token": "repository-id-token", + "expires_at": "2026-08-07T15:00:00Z", + "permissions": {"checks": "write"}, + "repositories": [{"id": 999, "full_name": "example/repo"}], + }, + "exact repository id", + ), + ( + "repository name", + { + "token": "repository-name-token", + "expires_at": "2026-08-07T15:00:00Z", + "permissions": {"checks": "write"}, + "repositories": [{"id": 123, "full_name": "example/other"}], + }, + "exact repository name", + ), + ) + for label, token_payload, error_pattern in cases: + with self.subTest(label=label): + calls: list[dict[str, object]] = [] + api_request = self._token_api_request( + token_payload=token_payload, + calls=calls, + ) + + with self.assertRaisesRegex(GitHubAppIdentityError, error_pattern): + mint_repository_installation_token( + identity=GitHubAppIdentity(app_id=42, private_key=self.private_key), + repository="example/repo", + repository_id="123", + api_request=api_request, + now=datetime(2026, 8, 7, 14, 0, tzinfo=timezone.utc), + ) + + self.assertEqual(calls[-1]["path"], "/installation/token") + self.assertEqual(calls[-1]["method"], "DELETE") + self.assertEqual(calls[-1]["token"], token_payload["token"]) + + def test_preserves_validation_error_when_post_mint_revocation_fails(self) -> None: + calls: list[dict[str, object]] = [] + api_request = self._token_api_request( + token_payload={ + "token": "invalid-scope-token", + "expires_at": "2026-08-07T15:00:00Z", + "permissions": {"checks": "write"}, + "repositories": [], + }, + calls=calls, + revocation_response={"unexpected": "payload"}, + ) + + with self.assertRaisesRegex(GitHubAppIdentityError, "exactly one repository") as caught: + mint_repository_installation_token( + identity=GitHubAppIdentity(app_id=42, private_key=self.private_key), + repository="example/repo", + repository_id="123", + api_request=api_request, + now=datetime(2026, 8, 7, 14, 0, tzinfo=timezone.utc), + ) + + self.assertEqual(calls[-1]["path"], "/installation/token") + self.assertTrue( + any("revocation also failed" in note for note in caught.exception.__notes__) + ) + + def test_does_not_revoke_without_usable_minted_token(self) -> None: + calls: list[dict[str, object]] = [] + api_request = self._token_api_request( + token_payload={ + "expires_at": "2026-08-07T15:00:00Z", + "permissions": {"checks": "write"}, + "repositories": [{"id": 123, "full_name": "example/repo"}], + }, + calls=calls, + ) + + with self.assertRaisesRegex(GitHubAppIdentityError, "requires token"): + mint_repository_installation_token( + identity=GitHubAppIdentity(app_id=42, private_key=self.private_key), + repository="example/repo", + repository_id="123", + api_request=api_request, + ) + + self.assertNotIn("/installation/token", tuple(call["path"] for call in calls)) + + @staticmethod + def _token_api_request( + *, + token_payload: dict[str, object], + calls: list[dict[str, object]], + revocation_response: object = None, + ) -> Callable[..., object]: + def api_request(**kwargs: object) -> object: + calls.append(dict(kwargs)) + if kwargs["path"] == "/installation/token": + return revocation_response + if kwargs["path"] == "/app": + return {"id": 42} + if kwargs["path"] == "/repos/example/repo/installation": + return { + "id": 77, + "app_id": 42, + "permissions": {"checks": "write"}, + } + return token_payload + + return api_request + if __name__ == "__main__": unittest.main() diff --git a/tests/test_governance_projection.py b/tests/test_governance_projection.py index d0506e98b..be45f8a98 100644 --- a/tests/test_governance_projection.py +++ b/tests/test_governance_projection.py @@ -185,7 +185,7 @@ def resolve(self, target: ChangeImpactTargetReference) -> object: projection.target.head_sha, ) - def test_scenario_25_owner_accepted_remains_product_judgment_only(self) -> None: + def test_scenario_25_owner_accepted_is_authoritative_level_one(self) -> None: with TemporaryDirectory() as directory: store = _store(Path(directory)) provider = _EvidenceProvider(_repository_evidence()) @@ -225,9 +225,8 @@ def test_scenario_25_owner_accepted_remains_product_judgment_only(self) -> None: projection.owner_judgment.current.human_action_semantics, "product_review_accepted", ) - self.assertEqual(projection.owner_judgment.authorizes, ()) - self.assertEqual(projection.owner_judgment.current.authorizes, ()) - self.assertEqual(projection.owner_judgment.history[0].authorizes, ()) + self.assertTrue(projection.owner_judgment.authoritative) + self.assertEqual(projection.owner_judgment.mode, "owner_acceptance") self.assertEqual(projection.owner_judgment.history[0].target_status, "current") self.assertEqual(projection.owner_judgment.history[0].decision_relationship, "current") self.assertEqual(projection.merge_admission.status, "not_recorded") diff --git a/tests/test_governance_projection_http.py b/tests/test_governance_projection_http.py index 0c99b10e5..5d04c4872 100644 --- a/tests/test_governance_projection_http.py +++ b/tests/test_governance_projection_http.py @@ -41,7 +41,7 @@ def _configured_store(directory: str) -> object: class GovernanceProjectionHttpTests(unittest.IsolatedAsyncioTestCase): - async def test_reads_one_bounded_non_authoritative_projection(self) -> None: + async def test_reads_projection_with_authoritative_owner_facet(self) -> None: with TemporaryDirectory() as directory: store = _configured_store(directory) common = ReadRouteDependencies( @@ -77,7 +77,8 @@ async def test_reads_one_bounded_non_authoritative_projection(self) -> None: self.assertEqual(projection["mode"], "read_only_projection") self.assertFalse(projection["authoritative"]) self.assertEqual(projection["authorizes"], []) - self.assertEqual(projection["owner_judgment"]["authorizes"], []) + self.assertTrue(projection["owner_judgment"]["authoritative"]) + self.assertEqual(projection["owner_judgment"]["mode"], "owner_acceptance") self.assertEqual(projection["merge_readiness"]["mode"], "ephemeral") self.assertEqual(projection["merge_readiness"]["authorizes"], []) self.assertEqual(projection["merge_admission"]["status"], "not_recorded") diff --git a/tests/test_merge_admission_live.py b/tests/test_merge_admission_live.py index a4e6e73d7..c0dfaef64 100644 --- a/tests/test_merge_admission_live.py +++ b/tests/test_merge_admission_live.py @@ -1,7 +1,10 @@ from dataclasses import dataclass +from pathlib import Path +from tempfile import TemporaryDirectory import unittest from unittest.mock import patch +from control_plane.contracts.authz_policy_record import LaunchplaneAuthzPolicyRecord from control_plane.contracts.change_impact import ( ChangeImpactChangedFileEvidence, ChangeImpactEvaluation, @@ -12,19 +15,51 @@ from control_plane.contracts.merge_train_structural_provenance import ( MergeTrainStructuralEntryObservation, ) -from control_plane.merge_admission import MergeAdmissionDeniedError +from control_plane.contracts.merge_readiness import MergeReadinessOwnerFacet +from control_plane.contracts.owner_acceptance import OwnerAcceptanceAction +from control_plane.contracts.product_owner import ( + ProductOwnerGrant, + ProductOwnerIdentity, + ProductOwnerPolicyRecord, +) +from control_plane.merge_admission import MergeAdmissionDeniedError, MergeAdmissionEvaluation from control_plane.merge_admission_live import LiveMergeAdmissionEvaluator from control_plane.merge_train import ( MergeTrainDryRunSnapshot, MergeTrainPullRequestSnapshot, ) from control_plane.merge_train_github import RecordingMergeTrainGitHubTransport +from control_plane.owner_acceptance import ( + OwnerAcceptanceWriteResult, + build_owner_acceptance_system_event, + evaluate_owner_acceptance, + record_owner_acceptance_event, +) +from control_plane.service_auth import LaunchplaneAuthzPolicy +from control_plane.storage.filesystem import FilesystemRecordStore from control_plane.tenant_admission_controller import ( TenantAdmissionControllerGitHubClient, + TenantAdmissionRequiredTechnicalCheck, + TenantAdmissionTechnicalCheckSignal, TenantAdmissionTechnicalChecks, ) from tests.merge_train_policy_fixtures import build_test_merge_train_policy_record from tests.test_merge_admission_records import _guard_records +from tests.test_owner_acceptance import ( + BASE_SHA as OWNER_BASE_SHA, + HEAD_SHA as OWNER_HEAD_SHA, + OWNER_GITHUB_ID, + REPOSITORY as OWNER_REPOSITORY, + TREE_SHA as OWNER_TREE_SHA, + _EvidenceProvider, + _human, + _impact_policy, + _owner_policy, + _owner_requirement, + _preview_profile, + _repository_evidence, + _write_preview_evidence, +) from tests.test_merge_readiness import ( BASE_SHA, HEAD_SHA, @@ -84,6 +119,36 @@ def read_technical_checks( ) +class _PassingTechnicalCheckClient(TenantAdmissionControllerGitHubClient): + def __init__(self) -> None: + super().__init__(transport=RecordingMergeTrainGitHubTransport()) + + def read_technical_checks( + self, + *, + repository: str, + base_branch: str, + base_sha: str, + head_sha: str, + evaluated_at: str, + ) -> TenantAdmissionTechnicalChecks: + return TenantAdmissionTechnicalChecks( + head_sha=head_sha, + base_sha=base_sha, + strict=False, + status="pass", + required_checks=(TenantAdmissionRequiredTechnicalCheck(name="ci-gate"),), + signals=( + TenantAdmissionTechnicalCheckSignal( + source="check_run", + name="ci-gate", + state="pass", + ), + ), + evaluated_at=evaluated_at, + ) + + class _EmptyEngineeringReviewStore: @staticmethod def list_engineering_review_run_records(**_filters: object) -> tuple[()]: @@ -125,6 +190,404 @@ def _queued_pull_request( ) +def _authz_policy_record() -> LaunchplaneAuthzPolicyRecord: + return LaunchplaneAuthzPolicyRecord( + record_id="authz-policy-live-owner-tests", + source="test", + updated_at="2026-08-11T03:00:00Z", + policy=LaunchplaneAuthzPolicy(), + ) + + +def _owner_repository_evidence( + *, + head_sha: str = OWNER_HEAD_SHA, + tree_sha: str = OWNER_TREE_SHA, +) -> ChangeImpactRepositoryEvidence: + evidence = _repository_evidence(head=head_sha) + return evidence.model_copy( + update={ + "target": evidence.target.model_copy(update={"tree_sha": tree_sha}), + } + ) + + +def _seed_owner_store( + root: Path, + *, + owner_policy: ProductOwnerPolicyRecord | None = None, + include_owner_policy: bool = True, + preview_enabled: bool = False, +) -> FilesystemRecordStore: + store = FilesystemRecordStore(state_dir=root) + store.write_change_impact_policy_record(_impact_policy()) + if include_owner_policy: + store.write_product_owner_policy_record(owner_policy or _owner_policy()) + store.write_product_owner_requirement_record(_owner_requirement()) + store.write_product_profile_record(_preview_profile(enabled=preview_enabled)) + return store + + +def _record_owner_action( + *, + store: FilesystemRecordStore, + provider: _EvidenceProvider, + action: OwnerAcceptanceAction, + source_event_id: str, + reason: str = "", +) -> OwnerAcceptanceWriteResult: + decision = evaluate_owner_acceptance( + store=store, + repository_evidence_provider=provider, + target=ChangeImpactTargetReference( + repository=OWNER_REPOSITORY, + pull_request_number=2022, + ), + evaluated_at="2026-08-11T03:00:00Z", + ) + if decision.binding is None: + raise AssertionError("Expected a current Owner acceptance binding") + return record_owner_acceptance_event( + store=store, + repository_evidence_provider=provider, + target=ChangeImpactTargetReference( + repository=OWNER_REPOSITORY, + pull_request_number=2022, + ), + identity=_human(), + action=action, + expected_binding_sha256=decision.binding.binding_sha256, + source_event_kind="browser_api", + source_event_id=source_event_id, + reason=reason, + occurred_at="2026-08-11T03:00:00Z", + ) + + +def _evaluate_owner_live( + *, + store: FilesystemRecordStore, + provider: _EvidenceProvider, + evidence: ChangeImpactRepositoryEvidence, +) -> MergeAdmissionEvaluation: + policy_record = build_test_merge_train_policy_record(repository=OWNER_REPOSITORY) + candidate_record, landing_record, controller_state, _ = _guard_records( + policy_sha256=policy_record.policy_sha256, + repository=OWNER_REPOSITORY, + pull_request_number=2022, + base_sha=OWNER_BASE_SHA, + head_sha=evidence.target.head_sha, + tree_sha=evidence.target.tree_sha, + ) + evaluator = LiveMergeAdmissionEvaluator( + store=store, + repository_evidence_provider=provider, + technical_check_client=_PassingTechnicalCheckClient(), + policy_record_provider=lambda: policy_record, + snapshot_reader=_StaticSnapshotReader( + MergeTrainDryRunSnapshot( + repository=OWNER_REPOSITORY, + base_branch="main", + base_sha=OWNER_BASE_SHA, + pull_requests=( + _queued_pull_request( + number=2022, + head_sha=evidence.target.head_sha, + created_at="2026-08-11T03:00:00Z", + ), + ), + ) + ), + ) + with ( + patch( + "control_plane.merge_admission_live.read_active_authz_policy_record", + return_value=_authz_policy_record(), + ), + patch( + "control_plane.merge_admission_live.require_engineering_review_decision_store", + return_value=_EmptyEngineeringReviewStore(), + ), + ): + return evaluator.evaluate( + candidate_record=candidate_record, + landing_plan_record=landing_record, + entry=landing_record.landing_plan.entries[0], + observed_base_sha=OWNER_BASE_SHA, + observed_base_tree_sha="5" * 40, + observed_head_sha=evidence.target.head_sha, + observed_head_tree_sha=evidence.target.tree_sha, + controller_state=controller_state, + stack_collapse_record=None, + evaluated_at="2026-08-11T03:01:00Z", + ) + + +def _owner_facet(evaluation: MergeAdmissionEvaluation) -> MergeReadinessOwnerFacet: + if len(evaluation.readiness.owner_facets) != 1: + raise AssertionError("Expected one Owner readiness facet") + return evaluation.readiness.owner_facets[0] + + +class LiveMergeAdmissionRealStoreTests(unittest.TestCase): + def test_pending_changes_requested_revoked_and_stale_owner_states(self) -> None: + scenarios = ("pending", "changes_requested", "revoked", "stale") + expected_reasons = { + "pending": "owner_acceptance_missing", + "changes_requested": "owner_changes_requested", + "revoked": "owner_acceptance_revoked", + "stale": "owner_acceptance_stale", + } + for scenario in scenarios: + with self.subTest(scenario=scenario), TemporaryDirectory() as directory: + store = _seed_owner_store(Path(directory)) + evidence = _owner_repository_evidence() + provider = _EvidenceProvider(evidence) + if scenario == "changes_requested": + _record_owner_action( + store=store, + provider=provider, + action="changes_requested", + source_event_id="changes-requested", + reason="Owner requested a product correction.", + ) + elif scenario in {"revoked", "stale"}: + accepted = _record_owner_action( + store=store, + provider=provider, + action="accepted", + source_event_id=f"accepted-before-{scenario}", + ) + if scenario == "revoked": + _record_owner_action( + store=store, + provider=provider, + action="revoked", + source_event_id="revoked", + reason="Owner withdrew product acceptance.", + ) + else: + store.write_owner_acceptance_event_record( + build_owner_acceptance_system_event( + binding=accepted.record.binding, + action="invalidated", + occurred_at="2026-08-11T03:00:30Z", + source_event_id="invalidate-accepted-binding", + reason="Current evidence invalidated the prior acceptance.", + ) + ) + + evaluation = _evaluate_owner_live( + store=store, + provider=provider, + evidence=evidence, + ) + facet = _owner_facet(evaluation) + + self.assertEqual(facet.owner_status, scenario) + self.assertEqual(facet.state, "blocked_owner_evidence") + self.assertIn(expected_reasons[scenario], facet.reason_codes) + self.assertNotEqual(evaluation.readiness.state, "ready") + self.assertIn(expected_reasons[scenario], evaluation.readiness.reason_codes) + + def test_denied_and_unavailable_owner_authority_are_fail_closed(self) -> None: + scenarios = { + "denied": ( + _owner_policy( + owners=( + ProductOwnerGrant( + identity=ProductOwnerIdentity( + provider="github", + provider_subject_id=str(OWNER_GITHUB_ID), + ), + repository_ids=("9999",), + environments=("pull_request",), + ), + ) + ), + True, + "owner_authority_denied", + ), + "unavailable": (None, False, "owner_authority_unavailable"), + } + for scenario, (owner_policy, include_owner_policy, reason_code) in scenarios.items(): + with self.subTest(scenario=scenario), TemporaryDirectory() as directory: + store = _seed_owner_store( + Path(directory), + owner_policy=owner_policy, + include_owner_policy=include_owner_policy, + ) + evidence = _owner_repository_evidence() + evaluation = _evaluate_owner_live( + store=store, + provider=_EvidenceProvider(evidence), + evidence=evidence, + ) + facet = _owner_facet(evaluation) + + self.assertEqual(facet.owner_status, "unavailable") + self.assertEqual(facet.owner_reason_code, reason_code) + self.assertEqual(facet.state, "blocked_owner_evidence") + self.assertIn(reason_code, facet.reason_codes) + self.assertNotEqual(evaluation.readiness.state, "ready") + self.assertIn(reason_code, evaluation.readiness.reason_codes) + self.assertEqual(facet.event_id, "") + + def test_accepted_exact_binding_is_live_and_admissible(self) -> None: + with TemporaryDirectory() as directory: + store = _seed_owner_store(Path(directory)) + evidence = _owner_repository_evidence() + provider = _EvidenceProvider(evidence) + accepted = _record_owner_action( + store=store, + provider=provider, + action="accepted", + source_event_id="accepted-exact-binding", + ) + evaluation = _evaluate_owner_live( + store=store, + provider=provider, + evidence=evidence, + ) + facet = _owner_facet(evaluation) + + self.assertEqual(facet.owner_status, "accepted") + self.assertEqual(facet.owner_reason_code, "acceptance_valid") + self.assertEqual(facet.state, "ready") + self.assertEqual(facet.event_id, accepted.record.event_id) + self.assertEqual(facet.binding_sha256, accepted.record.binding.binding_sha256) + self.assertEqual(evaluation.structural_result.status, "exact") + self.assertEqual(evaluation.readiness.state, "ready") + + def test_head_and_tree_drift_stale_persisted_acceptance(self) -> None: + drifted_evidence = { + "head": _owner_repository_evidence(head_sha="c" * 40), + "tree": _owner_repository_evidence(tree_sha="d" * 40), + } + for drift_kind, current_evidence in drifted_evidence.items(): + with self.subTest(drift=drift_kind), TemporaryDirectory() as directory: + store = _seed_owner_store(Path(directory)) + provider = _EvidenceProvider(_owner_repository_evidence()) + accepted = _record_owner_action( + store=store, + provider=provider, + action="accepted", + source_event_id=f"accepted-before-{drift_kind}-drift", + ) + provider.evidence = current_evidence + evaluation = _evaluate_owner_live( + store=store, + provider=provider, + evidence=current_evidence, + ) + facet = _owner_facet(evaluation) + + self.assertEqual(facet.owner_status, "stale") + self.assertEqual(facet.owner_reason_code, "acceptance_stale") + self.assertIn("owner_acceptance_stale", facet.reason_codes) + self.assertEqual(facet.event_id, accepted.record.event_id) + self.assertNotEqual( + facet.binding_sha256, + accepted.record.binding.binding_sha256, + ) + self.assertNotEqual(evaluation.readiness.state, "ready") + self.assertIn("owner_acceptance_stale", evaluation.readiness.reason_codes) + + def test_preview_generation_drift_stales_persisted_acceptance(self) -> None: + with TemporaryDirectory() as directory: + store = _seed_owner_store(Path(directory), preview_enabled=True) + evidence = _owner_repository_evidence() + provider = _EvidenceProvider(evidence) + _write_preview_evidence(store) + accepted = _record_owner_action( + store=store, + provider=provider, + action="accepted", + source_event_id="accepted-preview-generation-one", + ) + accepted_evaluation = _evaluate_owner_live( + store=store, + provider=provider, + evidence=evidence, + ) + accepted_facet = _owner_facet(accepted_evaluation) + self.assertEqual(accepted_facet.state, "ready") + self.assertEqual(accepted_evaluation.readiness.state, "ready") + self.assertIsNotNone(accepted.record.binding.preview) + _write_preview_evidence( + store, + generation_id="preview-generic-web-a-pr-2022-generation-0002", + artifact_id="artifact-generic-web-a-pr-2022-v2", + image_digest="c" * 64, + ) + evaluation = _evaluate_owner_live( + store=store, + provider=provider, + evidence=evidence, + ) + facet = _owner_facet(evaluation) + + self.assertEqual(facet.owner_status, "stale") + self.assertEqual(facet.owner_reason_code, "acceptance_stale") + self.assertEqual(facet.event_id, accepted.record.event_id) + self.assertNotEqual(facet.binding_sha256, accepted.record.binding.binding_sha256) + self.assertNotEqual(evaluation.readiness.state, "ready") + self.assertIn("owner_acceptance_stale", evaluation.readiness.reason_codes) + + def test_owner_policy_and_requirement_drift_stale_acceptance(self) -> None: + for drift_kind in ("policy", "requirement"): + with self.subTest(drift=drift_kind), TemporaryDirectory() as directory: + store = _seed_owner_store(Path(directory)) + evidence = _owner_repository_evidence() + provider = _EvidenceProvider(evidence) + accepted = _record_owner_action( + store=store, + provider=provider, + action="accepted", + source_event_id=f"accepted-before-{drift_kind}-drift", + ) + if drift_kind == "policy": + current_policy = store.list_product_owner_policy_records(status="active")[0] + store.compare_and_write_product_owner_policy_record( + _owner_policy( + revision=2, + supersedes_record_id=current_policy.record_id, + ), + expected_current_record_id=current_policy.record_id, + expected_current_policy_digest=current_policy.policy_digest, + ) + else: + current_requirement = store.list_product_owner_requirement_records( + status="active" + )[0] + store.compare_and_write_product_owner_requirement_record( + _owner_requirement( + revision=2, + supersedes_record_id=current_requirement.record_id, + ), + expected_current_record_id=current_requirement.record_id, + expected_current_requirement_digest=( + current_requirement.requirement_digest + ), + ) + evaluation = _evaluate_owner_live( + store=store, + provider=provider, + evidence=evidence, + ) + facet = _owner_facet(evaluation) + + self.assertEqual(facet.owner_status, "stale") + self.assertEqual(facet.owner_reason_code, "acceptance_stale") + self.assertEqual(facet.event_id, accepted.record.event_id) + self.assertNotEqual( + facet.binding_sha256, + accepted.record.binding.binding_sha256, + ) + self.assertNotEqual(evaluation.readiness.state, "ready") + self.assertIn("owner_acceptance_stale", evaluation.readiness.reason_codes) + + class LiveMergeAdmissionEvaluatorTests(unittest.TestCase): def test_repository_policy_controls_engineering_review_authority(self) -> None: candidate_record, landing_record, controller_state, structural_result = _guard_records() diff --git a/tests/test_merge_admission_records.py b/tests/test_merge_admission_records.py index 931d7bdcb..d4b213135 100644 --- a/tests/test_merge_admission_records.py +++ b/tests/test_merge_admission_records.py @@ -110,26 +110,34 @@ def _landed_outcome( ) -def _guard_records() -> tuple[ +def _guard_records( + *, + policy_sha256: str = POLICY_SHA, + repository: str = REPOSITORY, + pull_request_number: int = 2083, + base_sha: str = BASE_SHA, + head_sha: str = HEAD_SHA, + tree_sha: str = TREE_SHA, +) -> tuple[ MergeTrainBatchCandidateRecord, MergeTrainBatchLandingPlanRecord, MergeTrainControllerStateRecord, MergeTrainStructuralCandidateResult, ]: entry = MergeTrainBatchEntry( - pull_request_number=2083, + pull_request_number=pull_request_number, position=2, - head_sha=HEAD_SHA, - head_tree_sha=TREE_SHA, + head_sha=head_sha, + head_tree_sha=tree_sha, impact_status="known", ) provenance = MergeTrainStructuralProvenance( - repository=REPOSITORY, + repository=repository, base_branch="main", - base_sha=BASE_SHA, + base_sha=base_sha, base_tree_sha=OTHER_SHA, - policy_key=f"{REPOSITORY}:main", - policy_sha256=POLICY_SHA, + policy_key=f"{repository}:main", + policy_sha256=policy_sha256, entries=( MergeTrainStructuralEntryBinding( position=1, @@ -143,7 +151,7 @@ def _guard_records() -> tuple[ MergeTrainRollingStep( position=1, pull_request_number=entry.pull_request_number, - parent_sha=BASE_SHA, + parent_sha=base_sha, parent_tree_sha=OTHER_SHA, head_sha=entry.head_sha, head_tree_sha=entry.head_tree_sha, @@ -157,20 +165,20 @@ def _guard_records() -> tuple[ ) normalized_entry = entry.model_copy(update={"position": 1}) batch_id = build_merge_train_batch_id( - repository=REPOSITORY, + repository=repository, base_branch="main", - base_sha=BASE_SHA, - entry_head_shas=(HEAD_SHA,), + base_sha=base_sha, + entry_head_shas=(head_sha,), ) candidate = MergeTrainBatchCandidate( batch_id=batch_id, - repository=REPOSITORY, + repository=repository, base_branch="main", - base_sha=BASE_SHA, - policy_key=f"{REPOSITORY}:main", - policy_sha256=POLICY_SHA, + base_sha=base_sha, + policy_key=f"{repository}:main", + policy_sha256=policy_sha256, candidate_ref=build_merge_train_batch_candidate_ref( - repository=REPOSITORY, + repository=repository, base_branch="main", batch_id=batch_id, ), @@ -202,13 +210,13 @@ def _guard_records() -> tuple[ ) controller_state = MergeTrainControllerStateRecord( controller_key=build_merge_train_controller_key( - repository=REPOSITORY, + repository=repository, base_branch="main", ), - repository=REPOSITORY, + repository=repository, base_branch="main", - policy_key=f"{REPOSITORY}:main", - policy_sha256=POLICY_SHA, + policy_key=f"{repository}:main", + policy_sha256=policy_sha256, status="running", updated_at="2026-08-11T03:00:00Z", lease_owner="controller-run-1", @@ -217,7 +225,7 @@ def _guard_records() -> tuple[ heartbeat_at="2026-08-11T03:00:00Z", active_action="land_batch", active_phase="merge_batch_entries", - active_pull_request_number=2083, + active_pull_request_number=pull_request_number, step_payload={ "landing_plan_id": landing_plan.plan_id, "expected_effect_sha": landing_plan.candidate_sha, @@ -226,7 +234,7 @@ def _guard_records() -> tuple[ structural_result = MergeTrainStructuralCandidateResult( status="exact", reason_codes=("structural_single_entry_exact",), - effective_base_sha=BASE_SHA, + effective_base_sha=base_sha, effective_base_tree_sha=OTHER_SHA, candidate_sha256=candidate.candidate_sha256, landing_plan_sha256=landing_plan.landing_plan_sha256, diff --git a/tests/test_merge_readiness.py b/tests/test_merge_readiness.py index 4da4c6921..7bb5f5335 100644 --- a/tests/test_merge_readiness.py +++ b/tests/test_merge_readiness.py @@ -468,6 +468,32 @@ def test_scenario_7_authority_age_and_self_review_fail_closed(self) -> None: self.assertEqual(result.state, "blocked_owner_evidence") self.assertIn(expected_reason, result.reason_codes) + def test_owner_authority_blocks_every_nonaccepted_current_state(self) -> None: + cases = ( + ("pending", "acceptance_missing", "owner_acceptance_missing"), + ("changes_requested", "changes_requested", "owner_changes_requested"), + ("revoked", "acceptance_revoked", "owner_acceptance_revoked"), + ("stale", "acceptance_stale", "owner_acceptance_stale"), + ("unavailable", "change_impact_unavailable", "owner_evidence_unavailable"), + ) + for owner_status, owner_reason, expected_reason in cases: + with self.subTest(owner_status=owner_status): + result = _evaluate( + owner_decision=_owner_decision( + _owner_product( + status=owner_status, + reason_code=owner_reason, + admissible=False, + ) + ) + ) + self.assertEqual(result.state, "blocked_owner_evidence") + self.assertIn(expected_reason, result.reason_codes) + + accepted = _evaluate(owner_decision=_owner_decision(_owner_product())) + self.assertEqual(accepted.state, "ready") + self.assertIn("owner_acceptance_valid", accepted.reason_codes) + def test_scenario_20_preview_isolation_history_remains_inadmissible(self) -> None: owner = _owner_product( status="stale", diff --git a/tests/test_owner_acceptance.py b/tests/test_owner_acceptance.py index a499521fd..53f5183c1 100644 --- a/tests/test_owner_acceptance.py +++ b/tests/test_owner_acceptance.py @@ -115,9 +115,13 @@ def list_open_pull_requests( return self.open_pull_requests[:limit] -def _human(github_id: int = OWNER_GITHUB_ID) -> GitHubHumanIdentity: +def _human( + github_id: int = OWNER_GITHUB_ID, + *, + login: str = "owner", +) -> GitHubHumanIdentity: return GitHubHumanIdentity( - login="owner", + login=login, github_id=github_id, name="Owner", email="", @@ -491,7 +495,9 @@ def test_non_preview_binding_digest_remains_backward_compatible(self) -> None: owner_policy_digest=_owner_policy().policy_digest, owner_requirement_record_id=_owner_requirement().record_id, owner_requirement_revision=1, - owner_requirement_digest=_owner_requirement().requirement_digest, + owner_requirement_digest=( + "8aadfe5b7b0291143ec9e0f653e1978c9cc081d12e36e86b49d87e4a7015e356" + ), ) self.assertEqual( @@ -774,15 +780,15 @@ def test_acceptance_records_and_replays_for_exact_binding(self) -> None: self.assertEqual(result.record.subject_sequence, 1) self.assertEqual( result.record.acceptance_id, - "owner-acceptance-1cc2c18bea5c40c21cb1a9ba02ffe2a0", + "owner-acceptance-0dab839e317015df00d2c5caa0639130", ) self.assertEqual( result.record.event_id, - "owner-acceptance-event-b961f97ffb3c028a1cd19c0f8b951f8e", + "owner-acceptance-event-ba52f0656d92980fcf96fa9b2fe905fa", ) self.assertEqual( owner_acceptance_event_replay_digest(result.record), - "07145b7467300ec9d5bea196fed65f36315ca6dc867a21633396ecf657e1ca62", + "715064def80cb17f42b6b9c655afce293c0d2315148d09693f45abb11f140c2b", ) replay = record_owner_acceptance_event( @@ -799,12 +805,56 @@ def test_acceptance_records_and_replays_for_exact_binding(self) -> None: self.assertEqual(replay.status, "replayed") self.assertEqual(replay.record, result.record) + renamed_owner_replay = record_owner_acceptance_event( + store=store, + repository_evidence_provider=provider, + target=target, + identity=_human(login="renamed-owner"), + action="accepted", + expected_binding_sha256=expected_binding_sha256, + source_event_kind="browser_api", + source_event_id="accept-1", + occurred_at="2026-08-07T12:02:00Z", + ) + self.assertEqual(renamed_owner_replay.status, "replayed") + self.assertEqual(renamed_owner_replay.record, result.record) + conflicting = OwnerAcceptanceEventRecord.model_validate( result.record.model_dump(mode="json") | {"reason": "changed"} ) with self.assertRaises(OwnerAcceptanceEventConflictError): store.write_owner_acceptance_event_record(conflicting) + def test_before_write_can_reject_resolved_binding_before_append(self) -> None: + with TemporaryDirectory() as directory: + store = _store(Path(directory)) + target = ChangeImpactTargetReference(repository=REPOSITORY, pull_request_number=2022) + provider = _EvidenceProvider(_repository_evidence()) + expected_binding_sha256 = _expected_binding_sha256(store=store, provider=provider) + observed_records: list[OwnerAcceptanceEventRecord] = [] + + def reject_before_write(record: OwnerAcceptanceEventRecord) -> None: + observed_records.append(record) + raise RuntimeError("resolved binding changed outside the held lock") + + with self.assertRaisesRegex(RuntimeError, "outside the held lock"): + record_owner_acceptance_event( + store=store, + repository_evidence_provider=provider, + target=target, + identity=_human(), + action="accepted", + expected_binding_sha256=expected_binding_sha256, + source_event_kind="browser_api", + source_event_id="reject-before-write", + occurred_at="2026-08-07T12:00:00Z", + before_write=reject_before_write, + ) + + self.assertEqual(len(observed_records), 1) + self.assertEqual(observed_records[0].binding.repository_id, REPOSITORY_ID) + self.assertEqual(store.list_owner_acceptance_event_records(), ()) + def test_complete_human_transition_table_and_resolution_evidence(self) -> None: with TemporaryDirectory() as directory: store = _store(Path(directory)) diff --git a/tests/test_owner_acceptance_http.py b/tests/test_owner_acceptance_http.py index d345a63b6..ffe1dcd40 100644 --- a/tests/test_owner_acceptance_http.py +++ b/tests/test_owner_acceptance_http.py @@ -2,8 +2,11 @@ from pathlib import Path from tempfile import TemporaryDirectory +from threading import Event from typing import Any, Callable, cast +import asyncio import unittest +from unittest.mock import MagicMock, patch from fastapi import FastAPI, HTTPException @@ -11,7 +14,9 @@ from control_plane.contracts.owner_acceptance import ( OWNER_ACCEPTANCE_EVENT_WRITE_ACTION, OWNER_ACCEPTANCE_READ_ACTION, + OwnerAcceptanceDecision, ) +from control_plane.contracts.change_impact import ChangeImpactTargetReference from control_plane.contracts.product_owner import ProductOwnerGrant, ProductOwnerIdentity from control_plane.http_routes.owner_acceptance import ( OWNER_ACCEPTANCE_EVALUATION_ROUTE, @@ -23,11 +28,13 @@ ) from control_plane.github_app_identity import GitHubAppInstallationToken from control_plane.http_routes.support import ApiRouteRegistrar, ReadRouteDependencies +from control_plane.owner_acceptance_projection import OwnerAcceptanceProjectionService from control_plane.service_auth import ( GitHubHumanIdentity, LaunchplaneIdentity, TerminalAgentIdentity, ) +from control_plane.storage.postgres import PostgresRecordStore from tests.support.http import lifespan_client from tests.test_owner_acceptance import ( PRODUCT, @@ -48,6 +55,90 @@ def _http_error(**kwargs: object) -> HTTPException: return HTTPException(status_code=int(str(kwargs["status_code"])), detail=kwargs) +def _installation_token( + _repository: str, + _repository_id: str, +) -> GitHubAppInstallationToken: + return GitHubAppInstallationToken( + token="installation-token", + app_id=42, + installation_id=77, + repository_id=int(REPOSITORY_ID), + repository=REPOSITORY, + expires_at="2026-08-07T15:00:00Z", + ) + + +class _GitHubCheckApi: + def __init__(self, *, fail_read_numbers: tuple[int, ...] = ()) -> None: + self.calls: list[dict[str, Any]] = [] + self.check_run: dict[str, Any] | None = None + self.successful_write_bodies: list[dict[str, Any]] = [] + self.read_count = 0 + self.fail_read_numbers = set(fail_read_numbers) + + def __call__(self, **kwargs: Any) -> object: + self.calls.append(kwargs) + method = str(kwargs.get("method") or "GET") + if method == "DELETE": + return None + if method == "GET": + self.read_count += 1 + if self.read_count in self.fail_read_numbers: + raise RuntimeError("GitHub is unavailable") + return {"check_runs": [self.check_run] if self.check_run is not None else []} + body = dict(kwargs["body"]) + self.successful_write_bodies.append(body) + if method == "POST": + self.check_run = { + "id": 91, + **body, + "app": {"id": 42}, + } + else: + assert self.check_run is not None + self.check_run = { + **self.check_run, + **body, + } + return self.check_run + + +class _BlockingAcceptedProjectionApi(_GitHubCheckApi): + def __init__(self) -> None: + super().__init__() + self.block_accepted_projection = True + self.accepted_projection_entered = Event() + self.release_accepted_projection = Event() + + def __call__(self, **kwargs: Any) -> object: + body = kwargs.get("body") + if ( + isinstance(body, dict) + and isinstance(body.get("output"), dict) + and body["output"].get("title") == "Owner acceptance: accepted" + and self.block_accepted_projection + ): + self.accepted_projection_entered.set() + if not self.release_accepted_projection.wait(timeout=10): + raise RuntimeError("timed out waiting to release accepted projection") + return super().__call__(**kwargs) + + +class _FailingDeleteApi(_GitHubCheckApi): + def __init__(self, *, fail_delete_numbers: tuple[int, ...]) -> None: + super().__init__() + self.delete_count = 0 + self.fail_delete_numbers = set(fail_delete_numbers) + + def __call__(self, **kwargs: Any) -> object: + if kwargs.get("method") == "DELETE": + self.delete_count += 1 + if self.delete_count in self.fail_delete_numbers: + raise RuntimeError("GitHub token revocation is unavailable") + return super().__call__(**kwargs) + + def _app( *, store: object, @@ -58,9 +149,11 @@ def _app( github_app_token: Callable[[str, str], GitHubAppInstallationToken] | None = None, github_api: Callable[..., object] | None = None, public_origin: str | None = None, + projection_service: OwnerAcceptanceProjectionService | None = None, ) -> FastAPI: resolved_identity = identity or _human() resolved_browser_identity = browser_identity or resolved_identity + resolved_github_api = github_api or _GitHubCheckApi() common = ReadRouteDependencies( read_identity=lambda: resolved_identity, get_record_store=lambda: store, @@ -79,16 +172,272 @@ def _app( repository_evidence_provider=( repository_evidence_provider or _EvidenceProvider(_repository_evidence()) ), - github_app_token=github_app_token, - public_origin=public_origin, - **({"github_api": github_api} if github_api is not None else {}), + github_app_token=github_app_token or _installation_token, + public_origin=public_origin or "https://ops.example.test", + github_api=resolved_github_api, + projection_service=projection_service, ), ) return app +def _postgres_store(root: Path) -> PostgresRecordStore: + source_store = _store(root / "filesystem") + store = PostgresRecordStore(database_url=f"sqlite+pysqlite:///{root / 'records.sqlite3'}") + store.ensure_schema() + store.import_core_records_from_filesystem(source_store) + return store + + class OwnerAcceptanceHttpTests(unittest.IsolatedAsyncioTestCase): - async def test_projects_current_owner_decision_as_neutral_github_app_check(self) -> None: + async def test_preview_projection_and_negative_event_share_projection_lock(self) -> None: + with TemporaryDirectory() as directory: + store = _store(Path(directory)) + github_api = _BlockingAcceptedProjectionApi() + github_api.block_accepted_projection = False + provider = _EvidenceProvider(_repository_evidence()) + projection_service = OwnerAcceptanceProjectionService( + repository_evidence_provider=provider, + github_app_token=_installation_token, + public_origin="https://ops.example.test", + api_request=github_api, + ) + app = _app( + store=store, + repository_evidence_provider=provider, + github_api=github_api, + projection_service=projection_service, + ) + + async with lifespan_client(app) as client: + evaluated = await client.get( + OWNER_ACCEPTANCE_EVALUATION_ROUTE, + params={"repository": REPOSITORY, "pull_request_number": 2022}, + ) + binding_sha256 = evaluated.json()["decision"]["binding"]["binding_sha256"] + accepted = await client.post( + OWNER_ACCEPTANCE_EVENTS_ROUTE, + json={ + "target": { + "repository": REPOSITORY, + "pull_request_number": 2022, + }, + "action": "accepted", + "expected_binding_sha256": binding_sha256, + }, + headers={"Idempotency-Key": "preview-lock-accepted"}, + ) + self.assertEqual(accepted.status_code, 202, accepted.text) + + assert github_api.check_run is not None + github_api.check_run["external_id"] = "0" * 64 + github_api.block_accepted_projection = True + github_api.accepted_projection_entered.clear() + github_api.release_accepted_projection.clear() + preview_task = asyncio.create_task( + asyncio.to_thread( + projection_service.reconcile_if_required, + store=store, + target=ChangeImpactTargetReference( + repository=REPOSITORY, + pull_request_number=2022, + ), + source_event_id="preview-ready-feedback", + ) + ) + entered = await asyncio.to_thread( + github_api.accepted_projection_entered.wait, + 10, + ) + self.assertIs(entered, True) + changes_task = asyncio.create_task( + client.post( + OWNER_ACCEPTANCE_EVENTS_ROUTE, + json={ + "target": { + "repository": REPOSITORY, + "pull_request_number": 2022, + }, + "action": "changes_requested", + "expected_binding_sha256": binding_sha256, + "reason": "Preview validation found a blocking correction.", + }, + headers={"Idempotency-Key": "preview-lock-changes-requested"}, + ) + ) + await asyncio.sleep(0.1) + self.assertIs(changes_task.done(), False) + github_api.release_accepted_projection.set() + _, changes_requested = await asyncio.gather(preview_task, changes_task) + + self.assertEqual(changes_requested.status_code, 202, changes_requested.text) + assert github_api.check_run is not None + self.assertEqual(github_api.check_run["conclusion"], "action_required") + self.assertEqual( + github_api.check_run["output"]["title"], + "Owner acceptance: changes requested", + ) + + async def test_preview_projection_revokes_its_installation_token(self) -> None: + with TemporaryDirectory() as directory: + store = _store(Path(directory)) + github_api = _GitHubCheckApi() + service = OwnerAcceptanceProjectionService( + repository_evidence_provider=_EvidenceProvider(_repository_evidence()), + github_app_token=_installation_token, + public_origin="https://ops.example.test", + api_request=github_api, + ) + + outcome = service.reconcile_if_required( + store=store, + target=ChangeImpactTargetReference( + repository=REPOSITORY, + pull_request_number=2022, + ), + source_event_id="preview-ready-token-revocation", + ) + + self.assertIsNotNone(outcome.result) + self.assertEqual( + [call["method"] for call in github_api.calls if call.get("method") == "DELETE"], + ["DELETE"], + ) + + async def test_bindingless_negative_decisions_project_exact_target(self) -> None: + cases = ( + ( + OwnerAcceptanceDecision( + status="stale", + reason_code="change_impact_stale", + evaluated_at="2026-08-17T01:30:00Z", + ), + "action_required", + ), + ( + OwnerAcceptanceDecision( + status="unavailable", + reason_code="change_impact_unavailable", + evaluated_at="2026-08-17T01:30:00Z", + ), + "failure", + ), + ) + exact_target = _repository_evidence().target + for decision, expected_conclusion in cases: + with self.subTest(status=decision.status), TemporaryDirectory() as directory: + store = _store(Path(directory)) + github_api = _GitHubCheckApi() + service = OwnerAcceptanceProjectionService( + repository_evidence_provider=_EvidenceProvider(_repository_evidence()), + github_app_token=_installation_token, + public_origin="https://ops.example.test", + api_request=github_api, + ) + + with patch.object( + OwnerAcceptanceProjectionService, + "resolve_current", + return_value=(decision, exact_target), + ): + outcome = service.reconcile_if_required( + store=store, + target=ChangeImpactTargetReference( + repository=REPOSITORY, + pull_request_number=2022, + ), + source_event_id=f"bindingless-{decision.status}", + ) + + self.assertIsNone(outcome.decision.binding) + self.assertEqual(outcome.target, exact_target) + self.assertIsNotNone(outcome.result) + assert github_api.check_run is not None + self.assertEqual(github_api.check_run["head_sha"], exact_target.head_sha) + self.assertEqual(github_api.check_run["conclusion"], expected_conclusion) + self.assertIn( + "No product-specific Owner decision is available", + github_api.check_run["output"]["summary"], + ) + + async def test_not_required_decision_intentionally_skips_projection(self) -> None: + with TemporaryDirectory() as directory: + store = _store(Path(directory)) + github_api = _GitHubCheckApi() + service = OwnerAcceptanceProjectionService( + repository_evidence_provider=_EvidenceProvider(_repository_evidence()), + github_app_token=_installation_token, + public_origin="https://ops.example.test", + api_request=github_api, + ) + decision = OwnerAcceptanceDecision( + status="not_required", + reason_code="engineering_only", + evaluated_at="2026-08-17T01:30:00Z", + ) + exact_target = _repository_evidence().target + + with patch.object( + OwnerAcceptanceProjectionService, + "resolve_current", + return_value=(decision, exact_target), + ): + outcome = service.reconcile_if_required( + store=store, + target=ChangeImpactTargetReference( + repository=REPOSITORY, + pull_request_number=2022, + ), + source_event_id="not-required-no-projection", + ) + + self.assertIsNone(outcome.result) + self.assertEqual(github_api.calls, []) + + async def test_restoration_demotes_exact_target_before_reresolution(self) -> None: + exact_target = _repository_evidence().target + service = OwnerAcceptanceProjectionService( + repository_evidence_provider=_EvidenceProvider(_repository_evidence()), + github_app_token=_installation_token, + public_origin="https://ops.example.test", + ) + operations: list[str] = [] + + def project_conservative(*_args: object, **_kwargs: object) -> MagicMock: + operations.append("demote") + return MagicMock() + + def fail_resolution(*_args: object, **_kwargs: object) -> object: + operations.append("resolve") + raise RuntimeError("repository evidence is unavailable") + + with ( + patch.object( + OwnerAcceptanceProjectionService, + "project_conservative_locked", + side_effect=project_conservative, + ), + patch.object( + OwnerAcceptanceProjectionService, + "resolve_current", + side_effect=fail_resolution, + ), + self.assertRaisesRegex(RuntimeError, "repository evidence is unavailable"), + ): + service.restore_conservative_locked( + store=MagicMock(), + target=ChangeImpactTargetReference( + repository=REPOSITORY, + pull_request_number=2022, + ), + lock_target=exact_target, + exact_target=exact_target, + source_event_id="restore-exact-first", + ) + + self.assertEqual(operations, ["demote", "resolve"]) + + async def test_projects_pending_owner_decision_as_action_required_check(self) -> None: with TemporaryDirectory() as directory: store = _store(Path(directory)) calls: list[dict[str, Any]] = [] @@ -141,50 +490,22 @@ def github_api(**kwargs): # type: ignore[no-untyped-def] payload = response.json() self.assertEqual(payload["decision"]["status"], "pending") self.assertEqual(payload["result"]["name"], "launchplane/owner-acceptance") - self.assertEqual(payload["result"]["conclusion"], "neutral") + self.assertEqual(payload["result"]["conclusion"], "action_required") self.assertEqual( calls[-2]["body"]["details_url"], "https://ops.example.test/ui/engineering/owner-acceptance?repository=example%2Fweb&pull_request=2022", ) - self.assertEqual(calls[-2]["body"]["conclusion"], "neutral") + self.assertEqual(calls[-2]["body"]["conclusion"], "action_required") self.assertEqual(calls[-1]["method"], "DELETE") - async def test_successful_event_best_effort_projects_current_decision(self) -> None: + async def test_successful_event_projects_conservative_then_final_decision(self) -> None: with TemporaryDirectory() as directory: store = _store(Path(directory)) - calls: list[dict[str, Any]] = [] - - def github_api(**kwargs): # type: ignore[no-untyped-def] - calls.append(kwargs) - if kwargs.get("method") == "DELETE": - return None - if kwargs.get("method") == "POST": - body = kwargs["body"] - return { - "id": 92, - "name": body["name"], - "head_sha": body["head_sha"], - "status": body["status"], - "conclusion": body["conclusion"], - "external_id": body["external_id"], - "details_url": body["details_url"], - "output": body["output"], - "app": {"id": 42}, - } - return {"check_runs": []} + github_api = _GitHubCheckApi() app = _app( store=store, - github_app_token=lambda _repository, _repository_id: GitHubAppInstallationToken( - token="installation-token", - app_id=42, - installation_id=77, - repository_id=int(REPOSITORY_ID), - repository=REPOSITORY, - expires_at="2026-08-07T15:00:00Z", - ), github_api=github_api, - public_origin="https://ops.example.test", ) async with lifespan_client(app) as client: @@ -207,32 +528,27 @@ def github_api(**kwargs): # type: ignore[no-untyped-def] self.assertEqual(response.status_code, 202, response.text) self.assertEqual(event_count, 1) - projection_body = next(call for call in calls if call.get("method") == "POST")["body"] + self.assertEqual( + [body["conclusion"] for body in github_api.successful_write_bodies], + ["action_required", "success"], + ) + self.assertEqual( + [body["output"]["title"] for body in github_api.successful_write_bodies], + ["Owner acceptance: updating decision", "Owner acceptance: accepted"], + ) + projection_body = github_api.successful_write_bodies[-1] self.assertEqual( projection_body["details_url"], "https://ops.example.test/ui/engineering/owner-acceptance?repository=example%2Fweb&pull_request=2022", ) - self.assertEqual(projection_body["output"]["title"], "Owner acceptance: accepted") - async def test_event_response_and_persisted_event_survive_projection_failure(self) -> None: + async def test_prewrite_projection_failure_blocks_event_append(self) -> None: with TemporaryDirectory() as directory: store = _store(Path(directory)) - - def failing_github_api(**_kwargs): # type: ignore[no-untyped-def] - raise RuntimeError("GitHub is unavailable") - + github_api = _GitHubCheckApi(fail_read_numbers=(1,)) app = _app( store=store, - github_app_token=lambda _repository, _repository_id: GitHubAppInstallationToken( - token="installation-token", - app_id=42, - installation_id=77, - repository_id=int(REPOSITORY_ID), - repository=REPOSITORY, - expires_at="2026-08-07T15:00:00Z", - ), - github_api=failing_github_api, - public_origin="https://ops.example.test", + github_api=github_api, ) async with lifespan_client(app) as client: @@ -253,9 +569,258 @@ def failing_github_api(**_kwargs): # type: ignore[no-untyped-def] ) event_count = len(store.list_owner_acceptance_event_records()) - self.assertEqual(response.status_code, 202, response.text) - self.assertEqual(response.json()["write_status"], "written") + self.assertEqual(response.status_code, 503, response.text) + self.assertEqual( + response.json()["detail"]["code"], + "owner_acceptance_projection_unavailable", + ) + self.assertEqual(event_count, 0) + + async def test_final_projection_failure_requires_idempotent_reconciliation(self) -> None: + with TemporaryDirectory() as directory: + store = _store(Path(directory)) + github_api = _GitHubCheckApi(fail_read_numbers=(2,)) + app = _app(store=store, github_api=github_api) + + async with lifespan_client(app) as client: + evaluated = await client.get( + OWNER_ACCEPTANCE_EVALUATION_ROUTE, + params={"repository": REPOSITORY, "pull_request_number": 2022}, + ) + request = { + "target": {"repository": REPOSITORY, "pull_request_number": 2022}, + "action": "accepted", + "expected_binding_sha256": evaluated.json()["decision"]["binding"][ + "binding_sha256" + ], + } + failed = await client.post( + OWNER_ACCEPTANCE_EVENTS_ROUTE, + json=request, + headers={"Idempotency-Key": "accept-with-projection-failure"}, + ) + event_count_after_failure = len(store.list_owner_acceptance_event_records()) + assert github_api.check_run is not None + conclusion_after_failure = github_api.check_run["conclusion"] + reconciled = await client.post( + OWNER_ACCEPTANCE_EVENTS_ROUTE, + json=request, + headers={"Idempotency-Key": "accept-with-projection-failure"}, + ) + event_count_after_replay = len(store.list_owner_acceptance_event_records()) + + self.assertEqual(failed.status_code, 503, failed.text) + self.assertEqual( + failed.json()["detail"]["code"], + "owner_acceptance_projection_reconciliation_required", + ) + self.assertEqual(event_count_after_failure, 1) + self.assertEqual(conclusion_after_failure, "action_required") + self.assertEqual(reconciled.status_code, 202, reconciled.text) + self.assertEqual(reconciled.json()["write_status"], "replayed") + self.assertEqual(event_count_after_replay, 1) + assert github_api.check_run is not None + self.assertEqual(github_api.check_run["conclusion"], "success") + self.assertEqual( + [body["conclusion"] for body in github_api.successful_write_bodies], + ["action_required", "success"], + ) + + async def test_final_token_revoke_failure_restores_conservative_projection(self) -> None: + with TemporaryDirectory() as directory: + store = _store(Path(directory)) + github_api = _FailingDeleteApi(fail_delete_numbers=(2,)) + app = _app(store=store, github_api=github_api) + + async with lifespan_client(app) as client: + evaluated = await client.get( + OWNER_ACCEPTANCE_EVALUATION_ROUTE, + params={"repository": REPOSITORY, "pull_request_number": 2022}, + ) + response = await client.post( + OWNER_ACCEPTANCE_EVENTS_ROUTE, + json={ + "target": { + "repository": REPOSITORY, + "pull_request_number": 2022, + }, + "action": "accepted", + "expected_binding_sha256": evaluated.json()["decision"]["binding"][ + "binding_sha256" + ], + }, + headers={"Idempotency-Key": "accept-with-revoke-failure"}, + ) + event_count = len(store.list_owner_acceptance_event_records()) + + self.assertEqual(response.status_code, 503, response.text) + self.assertEqual( + response.json()["detail"]["code"], + "owner_acceptance_projection_reconciliation_required", + ) self.assertEqual(event_count, 1) + assert github_api.check_run is not None + self.assertEqual(github_api.check_run["conclusion"], "action_required") + self.assertEqual( + github_api.check_run["output"]["title"], + "Owner acceptance: updating decision", + ) + self.assertEqual( + [body["conclusion"] for body in github_api.successful_write_bodies], + ["action_required", "success", "action_required"], + ) + + async def test_concurrent_negative_event_cannot_restore_stale_success(self) -> None: + for store_kind in ("filesystem", "sqlite"): + with self.subTest(store=store_kind), TemporaryDirectory() as directory: + root = Path(directory) + store = _store(root) if store_kind == "filesystem" else _postgres_store(root) + github_api = _BlockingAcceptedProjectionApi() + app = _app(store=store, github_api=github_api) + + async with lifespan_client(app) as client: + evaluated = await client.get( + OWNER_ACCEPTANCE_EVALUATION_ROUTE, + params={"repository": REPOSITORY, "pull_request_number": 2022}, + ) + binding_sha256 = evaluated.json()["decision"]["binding"]["binding_sha256"] + accepted_task = asyncio.create_task( + client.post( + OWNER_ACCEPTANCE_EVENTS_ROUTE, + json={ + "target": { + "repository": REPOSITORY, + "pull_request_number": 2022, + }, + "action": "accepted", + "expected_binding_sha256": binding_sha256, + }, + headers={"Idempotency-Key": "concurrent-accepted"}, + ) + ) + entered = await asyncio.to_thread( + github_api.accepted_projection_entered.wait, + 10, + ) + self.assertIs(entered, True) + changes_task = asyncio.create_task( + client.post( + OWNER_ACCEPTANCE_EVENTS_ROUTE, + json={ + "target": { + "repository": REPOSITORY, + "pull_request_number": 2022, + }, + "action": "changes_requested", + "expected_binding_sha256": binding_sha256, + "reason": "A concurrent product correction is required.", + }, + headers={"Idempotency-Key": "concurrent-changes-requested"}, + ) + ) + await asyncio.sleep(0.1) + self.assertIs(changes_task.done(), False) + github_api.release_accepted_projection.set() + accepted, changes_requested = await asyncio.gather( + accepted_task, + changes_task, + ) + events = sorted( + store.list_owner_acceptance_event_records(), + key=lambda event: event.subject_sequence, + ) + + self.assertEqual(accepted.status_code, 202, accepted.text) + self.assertEqual(changes_requested.status_code, 202, changes_requested.text) + self.assertEqual( + [event.action for event in events], + ["accepted", "changes_requested"], + ) + assert github_api.check_run is not None + self.assertEqual(github_api.check_run["conclusion"], "action_required") + self.assertEqual( + github_api.check_run["output"]["title"], + "Owner acceptance: changes requested", + ) + + async def test_projection_endpoint_cannot_overwrite_concurrent_negative_event(self) -> None: + with TemporaryDirectory() as directory: + store = _store(Path(directory)) + github_api = _BlockingAcceptedProjectionApi() + github_api.block_accepted_projection = False + app = _app(store=store, github_api=github_api) + + async with lifespan_client(app) as client: + evaluated = await client.get( + OWNER_ACCEPTANCE_EVALUATION_ROUTE, + params={"repository": REPOSITORY, "pull_request_number": 2022}, + ) + binding_sha256 = evaluated.json()["decision"]["binding"]["binding_sha256"] + accepted = await client.post( + OWNER_ACCEPTANCE_EVENTS_ROUTE, + json={ + "target": { + "repository": REPOSITORY, + "pull_request_number": 2022, + }, + "action": "accepted", + "expected_binding_sha256": binding_sha256, + }, + headers={"Idempotency-Key": "projection-race-accepted"}, + ) + self.assertEqual(accepted.status_code, 202, accepted.text) + assert github_api.check_run is not None + github_api.check_run["external_id"] = "0" * 64 + github_api.block_accepted_projection = True + github_api.accepted_projection_entered.clear() + github_api.release_accepted_projection.clear() + projection_task = asyncio.create_task( + client.post( + OWNER_ACCEPTANCE_PROJECT_ROUTE, + json={ + "target": { + "repository": REPOSITORY, + "pull_request_number": 2022, + } + }, + ) + ) + entered = await asyncio.to_thread( + github_api.accepted_projection_entered.wait, + 10, + ) + self.assertIs(entered, True) + changes_task = asyncio.create_task( + client.post( + OWNER_ACCEPTANCE_EVENTS_ROUTE, + json={ + "target": { + "repository": REPOSITORY, + "pull_request_number": 2022, + }, + "action": "changes_requested", + "expected_binding_sha256": binding_sha256, + "reason": "The projected approval is no longer current.", + }, + headers={"Idempotency-Key": "projection-race-changes"}, + ) + ) + await asyncio.sleep(0.1) + self.assertIs(changes_task.done(), False) + github_api.release_accepted_projection.set() + projected, changes_requested = await asyncio.gather( + projection_task, + changes_task, + ) + + self.assertEqual(projected.status_code, 200, projected.text) + self.assertEqual(changes_requested.status_code, 202, changes_requested.text) + assert github_api.check_run is not None + self.assertEqual(github_api.check_run["conclusion"], "action_required") + self.assertEqual( + github_api.check_run["output"]["title"], + "Owner acceptance: changes requested", + ) async def test_projection_rejects_head_drift_before_github_write(self) -> None: class _DriftingProvider(_EvidenceProvider): @@ -600,7 +1165,8 @@ async def test_changes_requested_and_revoked_require_reasons_and_evaluate(self) for action, expected_status in (("changes_requested", "changes_requested"),): with self.subTest(action=action), TemporaryDirectory() as directory: store = _store(Path(directory)) - app = _app(store=store) + github_api = _GitHubCheckApi() + app = _app(store=store, github_api=github_api) async with lifespan_client(app) as client: evaluated = await client.get( OWNER_ACCEPTANCE_EVALUATION_ROUTE, @@ -634,6 +1200,8 @@ async def test_changes_requested_and_revoked_require_reasons_and_evaluate(self) ) self.assertEqual(written.status_code, 202, written.text) self.assertEqual(written.json()["decision"]["status"], expected_status) + assert github_api.check_run is not None + self.assertEqual(github_api.check_run["conclusion"], "action_required") evaluated = await client.get( OWNER_ACCEPTANCE_EVALUATION_ROUTE, @@ -644,7 +1212,8 @@ async def test_changes_requested_and_revoked_require_reasons_and_evaluate(self) with TemporaryDirectory() as directory: store = _store(Path(directory)) - app = _app(store=store) + github_api = _GitHubCheckApi() + app = _app(store=store, github_api=github_api) async with lifespan_client(app) as client: evaluated = await client.get(OWNER_ACCEPTANCE_EVALUATION_ROUTE, params=target) binding_sha256 = evaluated.json()["decision"]["binding"]["binding_sha256"] @@ -658,6 +1227,8 @@ async def test_changes_requested_and_revoked_require_reasons_and_evaluate(self) headers={"Idempotency-Key": "accept-before-revoke"}, ) self.assertEqual(accepted.status_code, 202, accepted.text) + assert github_api.check_run is not None + self.assertEqual(github_api.check_run["conclusion"], "success") missing_reason = await client.post( OWNER_ACCEPTANCE_EVENTS_ROUTE, @@ -682,6 +1253,8 @@ async def test_changes_requested_and_revoked_require_reasons_and_evaluate(self) ) self.assertEqual(revoked.status_code, 202, revoked.text) self.assertEqual(revoked.json()["decision"]["status"], "revoked") + assert github_api.check_run is not None + self.assertEqual(github_api.check_run["conclusion"], "action_required") async def test_changes_requested_resolution_requires_structured_evidence(self) -> None: with TemporaryDirectory() as directory: diff --git a/tests/test_owner_acceptance_queue_http.py b/tests/test_owner_acceptance_queue_http.py index cf48ed5d0..ff46c9ef5 100644 --- a/tests/test_owner_acceptance_queue_http.py +++ b/tests/test_owner_acceptance_queue_http.py @@ -41,6 +41,7 @@ _repository_evidence, _store, ) +from tests.test_owner_acceptance_http import _GitHubCheckApi, _installation_token def _http_error(**kwargs: object) -> HTTPException: @@ -72,6 +73,9 @@ def _app( repository_evidence_provider=( repository_evidence_provider or _EvidenceProvider(_repository_evidence()) ), + github_app_token=_installation_token, + github_api=_GitHubCheckApi(), + public_origin="https://ops.example.test", ), ) return app @@ -188,9 +192,9 @@ async def test_queue_empty_when_no_events(self) -> None: self.assertEqual(response.status_code, 200, response.text) payload = response.json() self.assertEqual(payload["status"], "ok") - self.assertEqual(payload["mode"], "shadow") - self.assertIs(payload["authoritative"], False) - self.assertEqual(payload["enforcement_effect"], "none") + self.assertNotIn("mode", payload) + self.assertNotIn("authoritative", payload) + self.assertNotIn("enforcement_effect", payload) self.assertEqual(payload["derivation"], "ledger_only") self.assertEqual(payload["total"], 0) self.assertEqual(payload["candidate"], 0) @@ -250,9 +254,9 @@ async def test_queue_derives_entries_from_event_ledger(self) -> None: self.assertEqual(entry["pull_request_number"], 2022) self.assertEqual(entry["repository_id"], REPOSITORY_ID) self.assertEqual(entry["product"], PRODUCT) - self.assertEqual(entry["mode"], "shadow") - self.assertIs(entry["authoritative"], False) - self.assertEqual(entry["enforcement_effect"], "none") + self.assertNotIn("mode", entry) + self.assertNotIn("authoritative", entry) + self.assertNotIn("enforcement_effect", entry) self.assertIs(entry["verification_required"], True) self.assertEqual(entry["ledger_status"], "accepted") self.assertIn("next_action", entry) diff --git a/tests/test_owner_review_binding.py b/tests/test_owner_review_binding.py index 2bb4af8ae..6a3ac1e47 100644 --- a/tests/test_owner_review_binding.py +++ b/tests/test_owner_review_binding.py @@ -586,7 +586,7 @@ def test_owner_authority_loss_invalidates_admissibility_without_deleting_history class OwnerReviewNonAuthorityProjectionTests(unittest.TestCase): - def test_decision_projects_product_review_semantics_and_authorizes_nothing(self) -> None: + def test_decision_projects_authoritative_product_review_semantics(self) -> None: with TemporaryDirectory() as directory: store = _store(Path(directory)) provider = _EvidenceProvider(_repository_evidence()) @@ -596,20 +596,22 @@ def test_decision_projects_product_review_semantics_and_authorizes_nothing(self) written.decision.human_action_semantics, "product_review_accepted", ) - self.assertEqual(written.decision.authorizes, ()) + self.assertTrue(written.decision.admissible) self.assertEqual(written.record.action, "accepted") self.assertEqual( owner_acceptance_human_action_semantics("accepted"), "product_review_accepted", ) - def test_decision_cannot_claim_authority(self) -> None: + def test_decision_rejects_removed_compatibility_authorizes_field(self) -> None: with self.assertRaises(ValueError): - OwnerAcceptanceDecision( - status="accepted", - reason_code="acceptance_valid", - evaluated_at=REVIEWED_AT, - authorizes=("merge",), + OwnerAcceptanceDecision.model_validate( + { + "status": "accepted", + "reason_code": "acceptance_valid", + "evaluated_at": REVIEWED_AT, + "authorizes": ["merge"], + } ) def test_only_a_currently_accepted_review_can_be_admissible(self) -> None: diff --git a/tests/test_postgres_store.py b/tests/test_postgres_store.py index 161032d50..535e7a7b0 100644 --- a/tests/test_postgres_store.py +++ b/tests/test_postgres_store.py @@ -5,13 +5,14 @@ from pathlib import Path from tempfile import TemporaryDirectory from typing import Literal -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch from alembic import command as alembic_command from alembic.config import Config as AlembicConfig from click.testing import CliRunner from sqlalchemy import create_engine, inspect, insert, text, update from sqlalchemy.exc import IntegrityError, SQLAlchemyError +from sqlalchemy.pool import NullPool from sqlalchemy.sql.schema import Index from control_plane.cli import main @@ -1630,6 +1631,80 @@ def _merge_train_stack_collapse_plan_record( class PostgresRecordStoreTests(unittest.TestCase): + def test_owner_acceptance_projection_lock_does_not_consume_record_pool(self) -> None: + store = PostgresRecordStore( + database_url="postgresql+psycopg://test:test@127.0.0.1:1/launchplane" + ) + try: + assert store._owner_acceptance_projection_lock_engine is not None + self.assertIsInstance( + store._owner_acceptance_projection_lock_engine.pool, + NullPool, + ) + finally: + store.close() + + def test_owner_acceptance_projection_lock_commits_and_verifies_unlock(self) -> None: + store = PostgresRecordStore( + database_url="postgresql+psycopg://test:test@127.0.0.1:1/launchplane" + ) + original_lock_engine = store._owner_acceptance_projection_lock_engine + assert original_lock_engine is not None + original_lock_engine.dispose() + lock_engine = MagicMock() + connection = MagicMock() + connection.scalar.side_effect = (True, True) + connection_context = MagicMock() + connection_context.__enter__.return_value = connection + connection_context.__exit__.return_value = False + lock_engine.connect.return_value = connection_context + store._owner_acceptance_projection_lock_engine = lock_engine + try: + with store.owner_acceptance_projection_lock( + repository_id="101", + pull_request_number=42, + ): + connection.commit.assert_called_once_with() + + self.assertEqual(connection.commit.call_count, 2) + self.assertEqual(connection.scalar.call_count, 2) + self.assertIn( + "pg_try_advisory_lock", + str(connection.scalar.call_args_list[0].args[0]), + ) + self.assertIn( + "pg_advisory_unlock", + str(connection.scalar.call_args_list[1].args[0]), + ) + finally: + store.close() + + def test_owner_acceptance_projection_lock_rejects_failed_unlock(self) -> None: + store = PostgresRecordStore( + database_url="postgresql+psycopg://test:test@127.0.0.1:1/launchplane" + ) + original_lock_engine = store._owner_acceptance_projection_lock_engine + assert original_lock_engine is not None + original_lock_engine.dispose() + lock_engine = MagicMock() + connection = MagicMock() + connection.scalar.side_effect = (True, False) + connection_context = MagicMock() + connection_context.__enter__.return_value = connection + connection_context.__exit__.return_value = False + lock_engine.connect.return_value = connection_context + store._owner_acceptance_projection_lock_engine = lock_engine + try: + with self.assertRaisesRegex(RuntimeError, "lock cleanup failed"): + with store.owner_acceptance_projection_lock( + repository_id="101", + pull_request_number=42, + ): + pass + self.assertEqual(connection.commit.call_count, 2) + finally: + store.close() + def test_postgres_metadata_index_names_fit_identifier_limit(self) -> None: index_names = tuple( index.name diff --git a/tests/test_preview_pr_feedback.py b/tests/test_preview_pr_feedback.py index 732d45f43..069c4650a 100644 --- a/tests/test_preview_pr_feedback.py +++ b/tests/test_preview_pr_feedback.py @@ -36,6 +36,54 @@ def _every_code_request(*, result_pr_url: str = "") -> EveryCodeWorkRequestRecor class PreviewPrFeedbackWorkflowTests(unittest.TestCase): + def test_ready_owner_handoff_is_exact_and_authoritative(self) -> None: + markdown = _render_preview_pr_feedback_markdown( + marker=DEFAULT_PREVIEW_FEEDBACK_MARKER, + status="ready", + anchor_pr_number=42, + preview_url="https://pr-42.preview.example.test", + immutable_image_reference="ghcr.io/example/web@sha256:" + "a" * 64, + refresh_image_reference="", + revision="b" * 40, + run_url="https://github.com/example/web/actions/runs/1", + failure_summary="", + repository="example/web", + owner_review_status="pending", + owner_review_url=( + "https://launchplane.example.test/ui/engineering/owner-acceptance" + "?repository=example%2Fweb&pull_request=42" + ), + ) + + self.assertIn("Owner review required before merge", markdown) + self.assertIn("https://pr-42.preview.example.test", markdown) + self.assertIn("repository=example%2Fweb&pull_request=42", markdown) + self.assertIn("Current state: **pending**", markdown) + self.assertIn("### What to test", markdown) + self.assertIn("Select **Accept**", markdown) + self.assertIn("Select **Request changes**", markdown) + self.assertIn("A GitHub approval, review, or comment does not record", markdown) + self.assertIn("bound to this exact revision and serving preview", markdown) + + def test_ready_unavailable_owner_state_exposes_no_action(self) -> None: + markdown = _render_preview_pr_feedback_markdown( + marker=DEFAULT_PREVIEW_FEEDBACK_MARKER, + status="ready", + anchor_pr_number=42, + preview_url="https://pr-42.preview.example.test", + immutable_image_reference="", + refresh_image_reference="", + revision="b" * 40, + run_url="", + failure_summary="", + repository="example/web", + owner_review_status="unavailable", + ) + + self.assertIn("cannot expose an Owner action", markdown) + self.assertIn("Do not merge this change", markdown) + self.assertNotIn("Select **Accept**", markdown) + def test_cleanup_failure_without_resource_evidence_does_not_claim_preview_exists( self, ) -> None: diff --git a/tests/test_product_owner_http.py b/tests/test_product_owner_http.py index ecc64f1a3..2d054d480 100644 --- a/tests/test_product_owner_http.py +++ b/tests/test_product_owner_http.py @@ -20,7 +20,7 @@ PRODUCT_OWNER_POLICY_READ_ROUTE, PRODUCT_OWNER_REQUIREMENT_APPLY_ROUTE, PRODUCT_OWNER_ROUTING_APPLY_ROUTE, - PRODUCT_OWNER_SHADOW_EVALUATION_ROUTE, + PRODUCT_OWNER_AUTHORITY_EVALUATION_ROUTE, ProductOwnerWriteRouteDependencies, register_product_owner_read_routes, register_product_owner_write_routes, @@ -101,7 +101,7 @@ def _requirement() -> ProductOwnerRequirementRecord: class ProductOwnerHttpTests(unittest.IsolatedAsyncioTestCase): - async def test_apply_read_and_shadow_routes_are_additive_and_non_authoritative(self) -> None: + async def test_apply_read_and_authority_routes_share_current_records(self) -> None: with TemporaryDirectory() as directory: store = FilesystemRecordStore(Path(directory)) identity_holder: list[LaunchplaneIdentity] = [_human(1001)] @@ -190,9 +190,9 @@ def http_error( ) self.assertEqual(read_response.status_code, 200, read_response.text) read_model = read_response.json()["read_model"] - self.assertEqual(read_model["mode"], "shadow") - self.assertFalse(read_model["authoritative"]) - self.assertEqual(read_model["enforcement_effect"], "none") + self.assertNotIn("mode", read_model) + self.assertNotIn("authoritative", read_model) + self.assertNotIn("enforcement_effect", read_model) evaluation_params: dict[str, str | int] = { "product": PRODUCT, @@ -206,18 +206,18 @@ def http_error( "claimed_requirement_digest": requirement.requirement_digest, } preferred = await client.get( - PRODUCT_OWNER_SHADOW_EVALUATION_ROUTE, + PRODUCT_OWNER_AUTHORITY_EVALUATION_ROUTE, params=evaluation_params, ) self.assertEqual(preferred.status_code, 200, preferred.text) preferred_evaluation = preferred.json()["evaluation"] self.assertEqual(preferred_evaluation["decision"], "authorized") self.assertTrue(preferred_evaluation["actor_is_preferred"]) - self.assertFalse(preferred_evaluation["authoritative"]) + self.assertNotIn("authoritative", preferred_evaluation) identity_holder[0] = _human(1002) non_preferred = await client.get( - PRODUCT_OWNER_SHADOW_EVALUATION_ROUTE, + PRODUCT_OWNER_AUTHORITY_EVALUATION_ROUTE, params=evaluation_params, ) self.assertEqual(non_preferred.status_code, 200, non_preferred.text) @@ -226,7 +226,7 @@ def http_error( identity_holder[0] = _human(9999, role="admin") admin = await client.get( - PRODUCT_OWNER_SHADOW_EVALUATION_ROUTE, + PRODUCT_OWNER_AUTHORITY_EVALUATION_ROUTE, params=evaluation_params, ) self.assertEqual(admin.status_code, 200, admin.text) @@ -258,7 +258,7 @@ def http_error( with self.subTest(identity=type(non_human_identity).__name__): identity_holder[0] = non_human_identity rejected = await client.get( - PRODUCT_OWNER_SHADOW_EVALUATION_ROUTE, + PRODUCT_OWNER_AUTHORITY_EVALUATION_ROUTE, params=evaluation_params, ) self.assertEqual(rejected.status_code, 403, rejected.text) @@ -269,7 +269,7 @@ def http_error( identity_holder[0] = _human(1001, role="admin") listed_admin = await client.get( - PRODUCT_OWNER_SHADOW_EVALUATION_ROUTE, + PRODUCT_OWNER_AUTHORITY_EVALUATION_ROUTE, params=evaluation_params, ) self.assertEqual(listed_admin.status_code, 200, listed_admin.text) @@ -350,14 +350,13 @@ def http_error(**kwargs: object) -> HTTPException: schema = app.openapi() self.assertIn(PRODUCT_OWNER_POLICY_READ_ROUTE, schema["paths"]) self.assertIn(PRODUCT_OWNER_POLICY_APPLY_ROUTE, schema["paths"]) - self.assertIn(PRODUCT_OWNER_SHADOW_EVALUATION_ROUTE, schema["paths"]) + self.assertIn(PRODUCT_OWNER_AUTHORITY_EVALUATION_ROUTE, schema["paths"]) self.assertEqual(action_safety("product_owner_policy.write"), "policy_admin") self.assertEqual(action_safety("product_owner_requirement.write"), "policy_admin") self.assertEqual(action_safety("product_owner_routing.write"), "policy_admin") self.assertEqual(action_safety("product_owner_policy.read"), "read") self.assertEqual(action_safety("product_owner_requirement.read"), "read") self.assertEqual(action_safety("product_owner_routing.read"), "read") - self.assertEqual(action_safety("product_owner_shadow.read"), "read") if __name__ == "__main__": diff --git a/tests/test_product_owner_policy.py b/tests/test_product_owner_policy.py index c605c8a72..120d618aa 100644 --- a/tests/test_product_owner_policy.py +++ b/tests/test_product_owner_policy.py @@ -27,7 +27,7 @@ apply_product_owner_policy, apply_product_owner_requirement, apply_product_owner_routing, - evaluate_product_owner_shadow_authority, + evaluate_product_owner_authority, get_product_owner_read_model, ) from control_plane.storage.filesystem import FilesystemRecordStore @@ -233,7 +233,7 @@ def test_policy_sequence_removes_prior_owner_and_rejects_stale_tip(self) -> None def test_requirement_is_separate_and_routing_is_not_authority(self) -> None: policy = _policy() actor = ProductOwnerActorIdentity(provider="github", provider_subject_id="1001") - not_required = evaluate_product_owner_shadow_authority( + not_required = evaluate_product_owner_authority( context=_context(), actor=actor, policies=(policy,), @@ -251,9 +251,8 @@ def test_requirement_is_separate_and_routing_is_not_authority(self) -> None: ), ) self.assertEqual(not_required.decision, "not_required") - self.assertFalse(not_required.authoritative) - routed_non_owner = evaluate_product_owner_shadow_authority( + routed_non_owner = evaluate_product_owner_authority( context=_context(), actor=ProductOwnerActorIdentity(provider="github", provider_subject_id="9999"), policies=(policy,), @@ -277,7 +276,7 @@ def test_requirement_is_separate_and_routing_is_not_authority(self) -> None: self.assertEqual(routed_non_owner.decision, "denied") self.assertEqual(routed_non_owner.reason_code, "actor_not_current_owner") - missing_provenance = evaluate_product_owner_shadow_authority( + missing_provenance = evaluate_product_owner_authority( context=_context(), actor=actor, policies=(policy,), @@ -297,7 +296,7 @@ def test_stale_removed_cross_product_and_admin_are_denied(self) -> None: current = second.model_copy(update={"status": "active"}) history = first.model_copy(update={"status": "superseded"}) - removed = evaluate_product_owner_shadow_authority( + removed = evaluate_product_owner_authority( context=_context(), actor=ProductOwnerActorIdentity(provider="github", provider_subject_id="1001"), policies=(history, current), @@ -310,7 +309,7 @@ def test_stale_removed_cross_product_and_admin_are_denied(self) -> None: ) self.assertEqual(removed.reason_code, "actor_not_current_owner") - stale = evaluate_product_owner_shadow_authority( + stale = evaluate_product_owner_authority( context=_context(), actor=ProductOwnerActorIdentity(provider="github", provider_subject_id="1002"), policies=(history, current), @@ -323,7 +322,7 @@ def test_stale_removed_cross_product_and_admin_are_denied(self) -> None: ) self.assertEqual(stale.reason_code, "stale_policy") - stale_requirement = evaluate_product_owner_shadow_authority( + stale_requirement = evaluate_product_owner_authority( context=_context(), actor=ProductOwnerActorIdentity(provider="github", provider_subject_id="1002"), policies=(history, current), @@ -336,7 +335,7 @@ def test_stale_removed_cross_product_and_admin_are_denied(self) -> None: ) self.assertEqual(stale_requirement.reason_code, "stale_requirement") - cross_product = evaluate_product_owner_shadow_authority( + cross_product = evaluate_product_owner_authority( context=_context().model_copy(update={"product": "product-beta"}), actor=ProductOwnerActorIdentity(provider="github", provider_subject_id="1002"), policies=(history, current), @@ -345,7 +344,7 @@ def test_stale_removed_cross_product_and_admin_are_denied(self) -> None: ) self.assertEqual(cross_product.reason_code, "owner_policy_unavailable") - unlisted_human = evaluate_product_owner_shadow_authority( + unlisted_human = evaluate_product_owner_authority( context=_context(), actor=ProductOwnerActorIdentity(provider="github", provider_subject_id="9999"), policies=(history, current), @@ -353,7 +352,7 @@ def test_stale_removed_cross_product_and_admin_are_denied(self) -> None: routings=(), ) self.assertEqual(unlisted_human.reason_code, "actor_not_current_owner") - self.assertFalse(unlisted_human.authoritative) + self.assertEqual(unlisted_human.decision, "denied") def test_policy_scope_without_current_owner_is_unavailable(self) -> None: uncovered_policy = ProductOwnerPolicyRecord( @@ -371,7 +370,7 @@ def test_policy_scope_without_current_owner_is_unavailable(self) -> None: source="test", reason="Leave the evaluated repository outside the policy scope.", ) - evaluation = evaluate_product_owner_shadow_authority( + evaluation = evaluate_product_owner_authority( context=_context(), actor=ProductOwnerActorIdentity(provider="github", provider_subject_id="1001"), policies=(uncovered_policy,), @@ -386,7 +385,7 @@ def test_mutated_record_cannot_reuse_stale_authority_digest(self) -> None: original_digest = policy.policy_digest policy.owners = (_grant("9999"),) actor = ProductOwnerActorIdentity(provider="github", provider_subject_id="9999") - evaluation = evaluate_product_owner_shadow_authority( + evaluation = evaluate_product_owner_authority( context=_context(), actor=actor, policies=(policy,), @@ -557,14 +556,14 @@ def test_requirement_and_routing_streams_use_independent_linear_tips(self) -> No expected_current_routing_digest="f" * 64, ) - def test_shadow_evaluation_rejects_noncurrent_or_future_authority(self) -> None: + def test_authority_evaluation_rejects_noncurrent_or_future_authority(self) -> None: requirement = _requirement() gap_policy = _policy( revision=2, subjects=("1001",), supersedes_record_id=_policy().record_id, ) - gap = evaluate_product_owner_shadow_authority( + gap = evaluate_product_owner_authority( context=_context(), actor=ProductOwnerActorIdentity(provider="github", provider_subject_id="1001"), policies=(gap_policy,), @@ -584,7 +583,7 @@ def test_shadow_evaluation_rejects_noncurrent_or_future_authority(self) -> None: source="test", reason="Future requirement must not become current early.", ) - future = evaluate_product_owner_shadow_authority( + future = evaluate_product_owner_authority( context=_context(), actor=ProductOwnerActorIdentity(provider="github", provider_subject_id="1001"), policies=(_policy(),), diff --git a/tests/test_schema_migration.py b/tests/test_schema_migration.py index 56daf86ac..a3496177a 100644 --- a/tests/test_schema_migration.py +++ b/tests/test_schema_migration.py @@ -11,6 +11,13 @@ from sqlalchemy import create_engine, inspect, text from control_plane.contracts.authz_policy_record import LaunchplaneAuthzPolicyRecord +from control_plane.contracts.product_owner import ( + ProductOwnerActionContext, + ProductOwnerActorIdentity, + ProductOwnerRequirement, + ProductOwnerRequirementRecord, +) +from control_plane.product_owner_service import evaluate_product_owner_authority from control_plane.service_auth import LaunchplaneAuthzPolicy from control_plane.storage.postgres import PostgresRecordStore from control_plane.storage.schema_adoption import ( @@ -920,6 +927,10 @@ def test_repository_human_admission_migration_upgrades_and_downgrades( ) self.assertEqual(role_primary_key["constrained_columns"], ["record_id"]) self.assertEqual(waiver_primary_key["constrained_columns"], ["event_id"]) + self.assertNotIn( + "enforcement_mode", + owner_columns["launchplane_product_owner_requirements"], + ) self.assertTrue(role_indexes["launchplane_repo_human_role_revision_uidx"]["unique"]) self.assertTrue(role_indexes["launchplane_repo_human_role_active_uidx"]["unique"]) self.assertEqual( @@ -993,6 +1004,191 @@ def test_repository_human_admission_migration_upgrades_and_downgrades( for table_name in owner_table_names: self.assertNotIn(table_name, downgraded_table_names) + def test_owner_authority_migration_archives_legacy_requirements(self) -> None: + with TemporaryDirectory() as temporary_directory_name: + database_path = Path(temporary_directory_name) / "launchplane.sqlite3" + database_url = f"sqlite+pysqlite:///{database_path}" + config = _alembic_config(database_url) + command.upgrade(config, "e9b1d3f5a7c0") + legacy_payloads = [] + previous_record_id: str | None = None + for revision, status in ((1, "superseded"), (2, "active")): + record_id = f"legacy-owner-requirement-r{revision}" + legacy_payloads.append( + { + "schema_version": 1, + "record_id": record_id, + "status": status, + "product": "example-site", + "system": "web", + "requirement_revision": revision, + "requirements": [ + { + "schema_version": 1, + "action": "pull_request.owner_acceptance", + "repository_ids": ["101"], + "environments": ["preview"], + "quorum": 1, + } + ], + "enforcement_mode": "shadow", + "effective_at": f"2026-08-{14 + revision:02d}T12:00:00Z", + "source": "test", + "reason": "Exercise the authority cutover migration.", + "supersedes_record_id": previous_record_id, + "requirement_digest": chr(96 + revision) * 64, + } + ) + previous_record_id = record_id + engine = create_engine(database_url) + try: + with engine.begin() as connection: + for legacy_payload in legacy_payloads: + connection.execute( + text( + """ + INSERT INTO launchplane_product_owner_requirements ( + record_id, product, system, status, + requirement_revision, enforcement_mode, + effective_at, source, supersedes_record_id, + requirement_digest, payload + ) VALUES ( + :record_id, :product, :system, :status, + :requirement_revision, 'shadow', :effective_at, 'test', + :supersedes_record_id, + :requirement_digest, :payload + ) + """ + ), + { + "record_id": legacy_payload["record_id"], + "product": legacy_payload["product"], + "system": legacy_payload["system"], + "status": legacy_payload["status"], + "requirement_revision": legacy_payload["requirement_revision"], + "effective_at": legacy_payload["effective_at"], + "supersedes_record_id": legacy_payload["supersedes_record_id"], + "requirement_digest": legacy_payload["requirement_digest"], + "payload": json.dumps(legacy_payload), + }, + ) + finally: + engine.dispose() + + command.upgrade(config, "f0a2c4e6b8d1") + engine = create_engine(database_url) + try: + with engine.connect() as connection: + archived = tuple( + connection.execute( + text( + """ + SELECT record_id, requirement_digest, payload + FROM launchplane_product_owner_requirement_authority_migrations + ORDER BY requirement_revision + """ + ) + ) + .mappings() + .all() + ) + current = ( + connection.execute( + text( + """ + SELECT requirement_revision, requirement_digest, payload + FROM launchplane_product_owner_requirements + WHERE product = :product AND system = :system + """ + ), + { + "product": "example-site", + "system": "web", + }, + ) + .mappings() + .one() + ) + finally: + engine.dispose() + + migrated_payload = ( + json.loads(current["payload"]) + if isinstance(current["payload"], str) + else current["payload"] + ) + migrated_record = ProductOwnerRequirementRecord.model_validate(migrated_payload) + store = PostgresRecordStore(database_url=database_url) + successor = ProductOwnerRequirementRecord( + product=migrated_record.product, + system=migrated_record.system, + requirement_revision=2, + requirements=( + ProductOwnerRequirement( + action="pull_request.owner_acceptance", + repository_ids=("101",), + environments=("preview",), + ), + ), + effective_at="2026-08-16T13:00:00Z", + source="test:post-migration-write", + reason="Prove the reset stream accepts the next supported revision.", + supersedes_record_id=migrated_record.record_id, + ) + successor_write = store.compare_and_write_product_owner_requirement_record( + successor, + expected_current_record_id=migrated_record.record_id, + expected_current_requirement_digest=migrated_record.requirement_digest, + ) + active_after_write = store.list_product_owner_requirement_records( + product="example-site", + system="web", + status="active", + ) + + archived_payloads = tuple( + json.loads(row["payload"]) if isinstance(row["payload"], str) else row["payload"] + for row in archived + ) + current_payload = ( + json.loads(current["payload"]) + if isinstance(current["payload"], str) + else current["payload"] + ) + self.assertEqual( + tuple(row["requirement_digest"] for row in archived), + ("a" * 64, "b" * 64), + ) + self.assertEqual(archived_payloads, tuple(legacy_payloads)) + current_record = ProductOwnerRequirementRecord.model_validate(current_payload) + self.assertEqual(current_record.requirement_revision, 1) + self.assertEqual(current_record.requirements, ()) + self.assertIsNone(current_record.supersedes_record_id) + self.assertEqual(current_record.source, "migration:owner-authority-cutover") + self.assertNotIn(current["requirement_digest"], {"a" * 64, "b" * 64}) + evaluation = evaluate_product_owner_authority( + context=ProductOwnerActionContext( + product="example-site", + system="web", + repository_id="101", + environment="preview", + action="pull_request.owner_acceptance", + ), + actor=ProductOwnerActorIdentity( + provider="github", + provider_subject_id="1001", + ), + policies=(), + requirements=(current_record,), + routings=(), + ) + self.assertEqual(evaluation.decision, "not_required") + self.assertEqual(evaluation.reason_code, "owner_action_not_required") + self.assertEqual(successor_write, "written") + self.assertEqual(len(active_after_write), 1) + self.assertEqual(active_after_write[0].requirement_revision, 2) + self.assertEqual(active_after_write[0].supersedes_record_id, current_record.record_id) + def test_owner_acceptance_migration_upgrades_and_downgrades(self) -> None: with TemporaryDirectory() as temporary_directory_name: database_path = Path(temporary_directory_name) / "launchplane.sqlite3" @@ -1293,7 +1489,7 @@ def test_policy_schema_invariants_are_expected(self) -> None: for primary_key in CRITICAL_PRIMARY_KEYS } - self.assertEqual(EXPECTED_ALEMBIC_HEAD_REVISION, "e9b1d3f5a7c0") + self.assertEqual(EXPECTED_ALEMBIC_HEAD_REVISION, "f0a2c4e6b8d1") self.assertEqual( column_types[("launchplane_merge_admissions", "payload")], ("jsonb",), diff --git a/tests/test_service.py b/tests/test_service.py index affd28a40..707e39b98 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -4,12 +4,13 @@ import hmac import json import os +import threading import unittest from collections.abc import Mapping from pathlib import Path from tempfile import TemporaryDirectory from typing import Any, Literal, cast -from unittest.mock import ANY, patch +from unittest.mock import ANY, MagicMock, patch from click import ClickException, Command from click.testing import CliRunner @@ -2264,6 +2265,16 @@ def test_openapi_includes_merge_train_policy_import_contract(self) -> None: self.assertEqual(request_schema["additionalProperties"], False) def test_preview_pr_feedback_hydrates_ready_url_from_preview_record(self) -> None: + request_thread_id = threading.get_ident() + projection_thread_ids: list[int] = [] + + def reconcile_owner_acceptance_in_worker(**_kwargs: object) -> MagicMock: + projection_thread_ids.append(threading.get_ident()) + return MagicMock( + decision=MagicMock(status="pending"), + result=MagicMock(), + ) + with ( TemporaryDirectory() as temporary_directory_name, patch( @@ -2282,6 +2293,10 @@ def test_preview_pr_feedback_hydrates_ready_url_from_preview_record(self) -> Non "control_plane.workflows.preview_pr_feedback.create_github_issue_comment", return_value={"id": 987, "html_url": "https://github.example/comment"}, ) as create_comment, + patch( + "control_plane.http_app.OwnerAcceptanceProjectionService.reconcile_if_required", + side_effect=reconcile_owner_acceptance_in_worker, + ) as reconcile_owner_acceptance, ): root = Path(temporary_directory_name) state_dir = root / "state" @@ -2336,6 +2351,7 @@ def test_preview_pr_feedback_hydrates_ready_url_from_preview_record(self) -> Non ), authz_policy=policy, control_plane_root_path=root, + human_session_manager=MagicMock(public_origin="https://launchplane.example.test"), ) status_code, payload = _invoke_app( app, @@ -2354,6 +2370,72 @@ def test_preview_pr_feedback_hydrates_ready_url_from_preview_record(self) -> Non }, headers={"Idempotency-Key": "preview-pr-feedback-ready-hydrate-url"}, ) + reconcile_owner_acceptance.side_effect = RuntimeError("Owner projection is unavailable") + create_comment.reset_mock() + with patch( + "control_plane.http_app.utc_now_timestamp", + return_value="2026-08-16T23:59:59Z", + ): + failed_status_code, failed_payload = _invoke_app( + app, + method="POST", + path="/v1/previews/pr-feedback", + payload={ + "schema_version": 1, + "product": "sellyouroutboard", + "source": "workflow", + "repository": "cbusillo/sellyouroutboard", + "anchor_repo": "sellyouroutboard", + "anchor_pr_number": 42, + "anchor_pr_url": ("https://github.com/cbusillo/sellyouroutboard/pull/42"), + "status": "ready", + "run_url": ("https://github.com/cbusillo/sellyouroutboard/actions/runs/43"), + }, + headers={"Idempotency-Key": "preview-pr-feedback-ready-projection-failure"}, + ) + failed_comment_body = create_comment.call_args.kwargs["body"] + reconcile_owner_acceptance.side_effect = None + reconcile_owner_acceptance.return_value = MagicMock( + decision=MagicMock(status="pending"), + result=MagicMock(), + ) + create_comment.reset_mock() + app_without_browser_sessions = create_launchplane_fastapi_test_app( + state_dir=state_dir, + verifier=_StubVerifier( + _identity( + repository="cbusillo/sellyouroutboard", + workflow_ref=( + "cbusillo/sellyouroutboard/.github/workflows/preview-control-plane.yml" + "@refs/heads/main" + ), + ) + ), + authz_policy=policy, + control_plane_root_path=root, + ) + with patch( + "control_plane.http_app.utc_now_timestamp", + return_value="2026-08-16T23:59:58Z", + ): + no_browser_status_code, no_browser_payload = _invoke_app( + app_without_browser_sessions, + method="POST", + path="/v1/previews/pr-feedback", + payload={ + "schema_version": 1, + "product": "sellyouroutboard", + "source": "workflow", + "repository": "cbusillo/sellyouroutboard", + "anchor_repo": "sellyouroutboard", + "anchor_pr_number": 42, + "anchor_pr_url": ("https://github.com/cbusillo/sellyouroutboard/pull/42"), + "status": "ready", + "run_url": ("https://github.com/cbusillo/sellyouroutboard/actions/runs/44"), + }, + headers={"Idempotency-Key": "preview-pr-feedback-ready-no-browser"}, + ) + no_browser_comment_body = create_comment.call_args.kwargs["body"] self.assertEqual(status_code, 202, payload) self.assertEqual( @@ -2361,11 +2443,35 @@ def test_preview_pr_feedback_hydrates_ready_url_from_preview_record(self) -> Non "https://pr-42.syo-preview.example.test", ) self.assertEqual(payload["result"]["delivery_status"], "delivered", payload) + self.assertNotEqual(projection_thread_ids[0], request_thread_id) + first_reconciliation_call = reconcile_owner_acceptance.call_args_list[0] + target = first_reconciliation_call.kwargs["target"] + self.assertEqual(target.repository, "cbusillo/sellyouroutboard") + self.assertEqual(target.pull_request_number, 42) + self.assertEqual( + first_reconciliation_call.kwargs["source_event_id"], + "preview-pr-feedback-ready-hydrate-url", + ) create_comment.assert_called_once() self.assertIn( "https://pr-42.syo-preview.example.test", create_comment.call_args.kwargs["body"], ) + self.assertIn( + "repository=cbusillo%2Fsellyouroutboard&pull_request=42", + payload["result"]["comment_markdown"], + ) + self.assertEqual(failed_status_code, 202, failed_payload) + self.assertEqual(failed_payload["result"]["delivery_status"], "delivered") + self.assertIn("unavailable", failed_comment_body) + self.assertNotIn("/ui/engineering/owner-acceptance", failed_comment_body) + self.assertEqual(no_browser_status_code, 202, no_browser_payload) + self.assertEqual(no_browser_payload["result"]["delivery_status"], "delivered") + self.assertEqual( + reconcile_owner_acceptance.call_args.kwargs["source_event_id"], + "preview-pr-feedback-ready-no-browser", + ) + self.assertNotIn("/ui/engineering/owner-acceptance", no_browser_comment_body) def test_preview_pr_feedback_ready_requires_active_preview_url(self) -> None: with (