From fb7d70e7a0cb80fbd89e5a713e48555ba128d5c8 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 2 Sep 2026 23:45:53 -0400 Subject: [PATCH] fix: keep invalid admissions out of Production --- REPOSITORY_LIFECYCLE.md | 24 ++-- production-lifecycle-policy.json | 7 +- .../production-lifecycle-policy.schema.json | 8 +- scripts/validate_production_lifecycle.py | 119 ++++++++++++------ tests/test_production_lifecycle.py | 52 +++++--- tests/test_public_trust_schemas.py | 1 - 6 files changed, 129 insertions(+), 82 deletions(-) diff --git a/REPOSITORY_LIFECYCLE.md b/REPOSITORY_LIFECYCLE.md index 324d3bb..bd3aa7d 100644 --- a/REPOSITORY_LIFECYCLE.md +++ b/REPOSITORY_LIFECYCLE.md @@ -89,21 +89,21 @@ revocation, release drift, an authority failure, or missing evidence produces the same state. The validator never restores an older admission or replaces the state with Beta, Experimental, or Early access. -The signed ledger currently has an active remote-safe-synthetic admission for -each of the seven targets. Product-wide Production is true only while all -seven stay active. These admissions are not a MockMed -`production_acceptance` flip. Native Desktop Apple and Windows installers -remain unsigned and are not required. These are the derived states: +The signed ledger retains remote-safe-synthetic records for the seven targets. +Those records have no expiry. Their campaign counts also do not include a +retained set of executed-trial artifacts. They cannot produce active +Production state under the signed, expiring, and revocable admission policy. +These are the derived states: | Target | Current state | Role | |------------|-----------|------| -| `openadapt` | **Production** | `OpenAdapt` launcher/meta-package and unified CLI | -| `flow` | **Production** | `openadapt-flow` compiler and governed runtime | -| `cloud` | **Production** | Proprietary control plane and hosted execution surface | -| `desktop` | **Production** | Desktop recording, qualification, execution, evidence, and repair cockpit | -| `capture` | **Production** | Native screen, input, timing, window, and media capture | -| `agent` | **Production** | Local MCP and Agent Skills bridge for governed Flow workflows | -| `docs` | **Production** | `docs.openadapt.ai` deployment sourced from `openadapt-ops` | +| `openadapt` | **Not actively admitted** | `OpenAdapt` launcher/meta-package and unified CLI | +| `flow` | **Not actively admitted** | `openadapt-flow` compiler and governed runtime | +| `cloud` | **Not actively admitted** | Proprietary control plane and hosted execution surface | +| `desktop` | **Not actively admitted** | Desktop recording, qualification, execution, evidence, and repair cockpit | +| `capture` | **Not actively admitted** | Native screen, input, timing, window, and media capture | +| `agent` | **Not actively admitted** | Local MCP and Agent Skills bridge for governed Flow workflows | +| `docs` | **Not actively admitted** | `docs.openadapt.ai` deployment sourced from `openadapt-ops` | ## Other Repository Lifecycles diff --git a/production-lifecycle-policy.json b/production-lifecycle-policy.json index ebcd771..7f0392c 100644 --- a/production-lifecycle-policy.json +++ b/production-lifecycle-policy.json @@ -1,10 +1,9 @@ { "$schema": "schemas/production-lifecycle-policy.schema.json", "schema_version": "openadapt.production-lifecycle-policy/v3", - "revision": 5, - "admission_validity": "until_revoked", - "maximum_release_admission_days": null, - "maximum_workflow_admission_days": null, + "revision": 6, + "maximum_release_admission_days": 30, + "maximum_workflow_admission_days": 7, "object_reference_schema_version": "openadapt.production-evidence-object-reference/v2", "release_admission_schema_version": "openadapt.qualification-release/v2", "workflow_admission_schema_version": "openadapt.qualification-admission/v4", diff --git a/schemas/production-lifecycle-policy.schema.json b/schemas/production-lifecycle-policy.schema.json index 3bb1dfc..cb6ed18 100644 --- a/schemas/production-lifecycle-policy.schema.json +++ b/schemas/production-lifecycle-policy.schema.json @@ -8,7 +8,6 @@ "$schema", "schema_version", "revision", - "admission_validity", "maximum_release_admission_days", "maximum_workflow_admission_days", "object_reference_schema_version", @@ -22,10 +21,9 @@ "properties": { "$schema": {"const": "schemas/production-lifecycle-policy.schema.json"}, "schema_version": {"const": "openadapt.production-lifecycle-policy/v3"}, - "revision": {"type": "integer", "minimum": 4}, - "admission_validity": {"const": "until_revoked"}, - "maximum_release_admission_days": {"type": "null"}, - "maximum_workflow_admission_days": {"type": "null"}, + "revision": {"type": "integer", "minimum": 6}, + "maximum_release_admission_days": {"const": 30}, + "maximum_workflow_admission_days": {"const": 7}, "object_reference_schema_version": {"const": "openadapt.production-evidence-object-reference/v2"}, "release_admission_schema_version": {"const": "openadapt.qualification-release/v2"}, "workflow_admission_schema_version": {"const": "openadapt.qualification-admission/v4"}, diff --git a/scripts/validate_production_lifecycle.py b/scripts/validate_production_lifecycle.py index a353787..758378f 100755 --- a/scripts/validate_production_lifecycle.py +++ b/scripts/validate_production_lifecycle.py @@ -13,8 +13,8 @@ evidence manifest can all be fetched, hashed, and verified. The lifecycle policy is a v3 document. It declares the schema versions and -until-revoked admission validity that the signed checkpoint chain enforces, and -it names the protected feed ref that carries live Production state. It does +maximum admission windows that the signed checkpoint chain enforces, and it +names the protected feed ref that carries live Production state. It does not carry a summary authority. For live objects the certificate identity that signs Production acceptance evidence lives in production-evidence-policy.json, keyed by evidence kind. The admission ledger this module reads is the retained @@ -63,12 +63,12 @@ LIFECYCLE_CHECKPOINT_SCHEMA = "openadapt.production-lifecycle-checkpoint/v2" LIFECYCLE_FEED_SCHEMA = "openadapt.production-lifecycle-feed/v2" LIFECYCLE_FEED_REF = "refs/heads/production-lifecycle-feed" -# Live admissions stay valid until revoked or replaced. The retained v1 -# admission ledger still holds timestamped release admissions issued under the -# historical 30-day window, so that bound remains only for those records. +# A release admission and a workflow admission must expire. Revocation can end +# either admission before its expiry. The retained v1 release ledger uses the +# same 30-day maximum. RETAINED_RELEASE_ADMISSION_MAXIMUM_DAYS = 30 -RELEASE_ADMISSION_MAXIMUM_DAYS = None -WORKFLOW_ADMISSION_MAXIMUM_DAYS = None +RELEASE_ADMISSION_MAXIMUM_DAYS = 30 +WORKFLOW_ADMISSION_MAXIMUM_DAYS = 7 # The retained v1 admission ledger holds release admissions on the production # channel only, so the historical release admission window governs its expiry, # and every retained record was issued under policy revision 1. Its records @@ -387,13 +387,16 @@ def load_lifecycle( return _parse_group(text, "lifecycle"), _parse_group(text, "public_surfaces") -def _admission_days(value: object, label: str) -> None: - """Refuse a live admission-day maximum. Validity is until revoked.""" +def _admission_days(value: object, label: str, expected: int) -> int: + """Require the closed maximum for one expiring admission kind.""" - if value is not None: - raise LifecycleError( - f"{label} must be null; admissions stay valid until revoked" - ) + if ( + not isinstance(value, int) + or isinstance(value, bool) + or value != expected + ): + raise LifecycleError(f"{label} must be {expected}") + return value def _validate_summary_authority(value: object) -> dict[str, Any]: @@ -561,7 +564,7 @@ def _validate_policy(value: object) -> tuple[dict[str, Any], dict[str, Any]]: """Validate the v3 policy and pin the versioned admission contracts. The v3 policy states which schema versions the signed checkpoint chain - accepts and that admissions stay valid until revoked. Every target it + accepts and the maximum admission windows. Every target it declares must agree with the Production trust contract that production_trust.validate_release applies to the matching openadapt.qualification-release/v2 object. @@ -573,7 +576,6 @@ def _validate_policy(value: object) -> tuple[dict[str, Any], dict[str, Any]]: "$schema", "schema_version", "revision", - "admission_validity", "maximum_release_admission_days", "maximum_workflow_admission_days", "object_reference_schema_version", @@ -599,18 +601,21 @@ def _validate_policy(value: object) -> tuple[dict[str, Any], dict[str, Any]]: "production lifecycle policy revision must be at least " f"{POLICY_REVISION_MINIMUM}" ) - if policy["admission_validity"] != "until_revoked": - raise LifecycleError( - "production lifecycle policy admission_validity must be until_revoked" - ) - _admission_days( + release_days = _admission_days( policy["maximum_release_admission_days"], "maximum_release_admission_days", + RELEASE_ADMISSION_MAXIMUM_DAYS, ) - _admission_days( + workflow_days = _admission_days( policy["maximum_workflow_admission_days"], "maximum_workflow_admission_days", + WORKFLOW_ADMISSION_MAXIMUM_DAYS, ) + if workflow_days > release_days: + raise LifecycleError( + "maximum_workflow_admission_days cannot exceed " + "maximum_release_admission_days" + ) for key, expected in ( ("object_reference_schema_version", OBJECT_REFERENCE_SCHEMA), ("release_admission_schema_version", RELEASE_ADMISSION_SCHEMA), @@ -1419,6 +1424,33 @@ def _load_registered_json( return raw, value +def _has_policy_window( + value: Mapping[str, Any], *, label: str, maximum_days: int +) -> bool: + """Keep an otherwise valid retained object inactive without an expiry.""" + + try: + issued_at = production_trust.require_timestamp( + value["issued_at"], f"{label} issued_at" + ) + except production_trust.TrustError as exc: + raise LifecycleError(str(exc)) from exc + expires_value = value.get("expires_at") + if expires_value is None: + return False + try: + expires_at = production_trust.require_timestamp( + expires_value, f"{label} expires_at" + ) + except production_trust.TrustError as exc: + raise LifecycleError(str(exc)) from exc + if expires_at <= issued_at or expires_at > issued_at + timedelta( + days=maximum_days + ): + return False + return True + + def _reference_from_entry( entry: Mapping[str, Any], *, @@ -1489,7 +1521,7 @@ def _validate_v2_release_admission( live_targets: Mapping[str, Mapping[str, Any]], now: datetime, fetch: Callable[[str], bytes], -) -> dict[str, Any]: +) -> tuple[dict[str, Any], bool]: """Validate one registered qualification-release/v2 ledger row. remote-safe-synthetic rows are retained and checked. One row is one @@ -1623,19 +1655,24 @@ def _validate_v2_release_admission( raise LifecycleError( f"admission {index} public-trust verification failed: {exc}" ) from exc + has_policy_window = _has_policy_window( + admission, + label=f"release admission {target_id}", + maximum_days=RELEASE_ADMISSION_MAXIMUM_DAYS, + ) package_project = live_target["package_index_project"] if live_target["release_kind"] == "deployment": if package_project is not None: raise LifecycleError( f"admission {target_id} deployment must not declare a package project" ) - else: + elif has_policy_window: if not isinstance(package_project, str) or not package_project: raise LifecycleError(f"admission {target_id} package project is missing") _verify_live_pypi_files( admission, package_index_project=package_project, fetch=fetch ) - return admission + return admission, has_policy_window def _authority_state_identity( @@ -1661,7 +1698,7 @@ def _validate_v2_workflow_admission( registry_document: Mapping[str, Any], registry_entries: Sequence[Mapping[str, Any]], now: datetime, -) -> dict[str, Any]: +) -> tuple[dict[str, Any], bool]: """Validate one registered qualification-admission/v4 ledger row. remote-safe-synthetic tutorial rows are retained and checked. One @@ -1709,8 +1746,6 @@ def _validate_v2_workflow_admission( raise LifecycleError( f"workflow admission {index} is not the synthetic tutorial bundle" ) - if admission["expires_at"] is not None: - raise LifecycleError(f"workflow admission {index} expiry must be until-revoked") if admission.get("evals_production_acceptance") is not False and ( "evals_production_acceptance" in admission ): @@ -1779,7 +1814,12 @@ def _validate_v2_workflow_admission( raise LifecycleError( f"workflow admission {index} public-trust verification failed: {exc}" ) from exc - return admission + has_policy_window = _has_policy_window( + admission, + label=f"workflow admission {index}", + maximum_days=WORKFLOW_ADMISSION_MAXIMUM_DAYS, + ) + return admission, has_policy_window def _validate_workflow_admissions( @@ -1790,7 +1830,7 @@ def _validate_workflow_admissions( registry_entries: Sequence[Mapping[str, Any]], now: datetime, ) -> list[str]: - """Return active workflow admission ids. Require at least one.""" + """Return the active workflow admission ids.""" document = _closed( value, @@ -1811,7 +1851,7 @@ def _validate_workflow_admissions( for index, item in enumerate(rows): if not isinstance(item, dict): raise LifecycleError(f"workflow admission {index} must be an object") - admission = _validate_v2_workflow_admission( + admission, has_policy_window = _validate_v2_workflow_admission( item, index=index, root=root, @@ -1825,12 +1865,8 @@ def _validate_workflow_admissions( f"workflow admission id is duplicate: {admission_id!r}" ) seen.add(admission_id) - active.append(admission_id) - if not active: - raise LifecycleError( - "at least one active workflow admission is required " - "(synthetic tutorial bundle)" - ) + if has_policy_window: + active.append(admission_id) return active @@ -2123,7 +2159,7 @@ def validate( if _is_v2_release_reference(item) or _is_v2_release_object(item): if registry_document is None or root is None: raise LifecycleError("v2 admissions require the evidence registry") - admission = _validate_v2_release_admission( + admission, has_policy_window = _validate_v2_release_admission( item, index=index, root=root, @@ -2150,7 +2186,8 @@ def validate( # remote-safe-synthetic is a real target admission. Product-wide # Production requires all seven targets. It does not flip MockMed # production_acceptance. - active[target_id] = admission_id + if has_policy_window: + active[target_id] = admission_id continue admission = _closed( item, @@ -2326,13 +2363,15 @@ def validate( workflow_value = _load_json( workflow_path, "production workflow admissions" ) - _validate_workflow_admissions( + active_workflows = _validate_workflow_admissions( workflow_value, root=root, registry_document=registry_document, registry_entries=registry_entries, now=now, ) + if not active_workflows: + active.clear() return active @@ -2517,7 +2556,7 @@ def main() -> int: "Validated evidence-gated Production lifecycle: " f"{len(active)} active admission(s)." ) - print(f"Validated {workflow_count} active workflow admission(s).") + print(f"Validated {workflow_count} retained workflow admission record(s).") return 0 diff --git a/tests/test_production_lifecycle.py b/tests/test_production_lifecycle.py index e308581..82b715b 100644 --- a/tests/test_production_lifecycle.py +++ b/tests/test_production_lifecycle.py @@ -1120,19 +1120,18 @@ def test_published_policy_is_accepted(self) -> None: ) self.assertEqual(release["evidence_class"], "remote-safe-synthetic") self.assertEqual(release["target"], "flow") - self.assertEqual(set(active), set(lifecycle.EXPECTED_TARGETS)) - self.assertEqual(len(active), 7) - self.assertEqual(active["flow"], release["admission_id_sha256"]) - self.assertTrue(lifecycle.is_product_production(active)) + self.assertIsNone(release["expires_at"]) + self.assertEqual(active, {}) + self.assertNotIn(release["admission_id_sha256"], active.values()) + self.assertFalse(lifecycle.is_product_production(active)) - def test_seven_synthetic_target_admissions_are_product_production( + def test_non_expiring_synthetic_target_admissions_are_not_production( self, ) -> None: published_now = datetime(2026, 9, 2, 19, 30, 0, tzinfo=timezone.utc) active = lifecycle.validate_files(ROOT, now=published_now) - self.assertEqual(len(active), 7) - self.assertEqual(set(active), set(lifecycle.EXPECTED_TARGETS)) - self.assertTrue(lifecycle.is_product_production(active)) + self.assertEqual(active, {}) + self.assertFalse(lifecycle.is_product_production(active)) six = { target_id: f"admission:{target_id}" for target_id in lifecycle.EXPECTED_TARGETS @@ -1145,7 +1144,7 @@ def test_published_workflow_ledger_lists_synthetic_tutorial_admissions( ) -> None: published_now = datetime(2026, 9, 2, 19, 30, 0, tzinfo=timezone.utc) active = lifecycle.validate_files(ROOT, now=published_now) - self.assertEqual(len(active), 7) + self.assertEqual(active, {}) ledger = json.loads( (ROOT / "production-workflow-admissions.json").read_text(encoding="utf-8") ) @@ -1242,7 +1241,9 @@ def test_published_policy_declares_the_v2_contract(self) -> None: lifecycle.POLICY_SCHEMA, "openadapt.production-lifecycle-policy/v3" ) self.assertGreaterEqual(policy["revision"], 4) - self.assertEqual(policy["admission_validity"], "until_revoked") + self.assertEqual(policy["maximum_release_admission_days"], 30) + self.assertEqual(policy["maximum_workflow_admission_days"], 7) + self.assertNotIn("admission_validity", policy) self.assertNotIn("summary_authority", policy) self.assertNotIn("maximum_admission_days", policy) self.assertEqual( @@ -1272,15 +1273,14 @@ def test_retained_ledger_carries_the_v1_policy_digest(self) -> None: class AdmissionWindowTests(unittest.TestCase): - """Live admissions stay valid until revoked. The retained ledger keeps 30 days.""" + """Live release and workflow admissions are expiring and revocable.""" - def test_live_policy_is_until_revoked(self) -> None: + def test_live_policy_has_closed_expiry_windows(self) -> None: policy = load_policy() - self.assertEqual(policy["admission_validity"], "until_revoked") - self.assertIsNone(policy["maximum_release_admission_days"]) - self.assertIsNone(policy["maximum_workflow_admission_days"]) - self.assertIsNone(lifecycle.RELEASE_ADMISSION_MAXIMUM_DAYS) - self.assertIsNone(lifecycle.WORKFLOW_ADMISSION_MAXIMUM_DAYS) + self.assertEqual(policy["maximum_release_admission_days"], 30) + self.assertEqual(policy["maximum_workflow_admission_days"], 7) + self.assertEqual(lifecycle.RELEASE_ADMISSION_MAXIMUM_DAYS, 30) + self.assertEqual(lifecycle.WORKFLOW_ADMISSION_MAXIMUM_DAYS, 7) def test_retained_ledger_still_uses_the_historical_thirty_day_bound(self) -> None: self.assertEqual(lifecycle.RETAINED_RELEASE_ADMISSION_MAXIMUM_DAYS, 30) @@ -1311,10 +1311,10 @@ def test_trust_core_does_not_cap_admission_windows_by_day_count(self) -> None: self.assertNotIn("timedelta(days=7)", source) self.assertNotIn("timedelta(days=30)", source) - def test_policy_that_declares_an_admission_day_maximum_is_refused(self) -> None: + def test_policy_that_changes_an_admission_day_maximum_is_refused(self) -> None: for key, value in ( ("maximum_release_admission_days", 3), - ("maximum_workflow_admission_days", 7), + ("maximum_workflow_admission_days", 8), ("maximum_release_admission_days", 31), ): with self.subTest(key=key, value=value): @@ -1322,7 +1322,7 @@ def test_policy_that_declares_an_admission_day_maximum_is_refused(self) -> None: policy[key] = value with self.assertRaisesRegex( lifecycle.LifecycleError, - "must be null; admissions stay valid until revoked", + "must be", ): lifecycle.validate( policy, @@ -1332,6 +1332,18 @@ def test_policy_that_declares_an_admission_day_maximum_is_refused(self) -> None: now=NOW, ) + def test_non_expiring_admission_is_inactive(self) -> None: + self.assertFalse( + lifecycle._has_policy_window( + { + "issued_at": "2026-08-18T12:00:00Z", + "expires_at": None, + }, + label="workflow admission", + maximum_days=7, + ) + ) + class CertificateIdentityBindingTests(unittest.TestCase): """The v2 policy dropped the summary authority. The binding must remain. diff --git a/tests/test_public_trust_schemas.py b/tests/test_public_trust_schemas.py index 61951f0..d04bc7b 100644 --- a/tests/test_public_trust_schemas.py +++ b/tests/test_public_trust_schemas.py @@ -437,7 +437,6 @@ "$schema", "schema_version", "revision", - "admission_validity", "maximum_release_admission_days", "maximum_workflow_admission_days", "object_reference_schema_version",