diff --git a/REPOSITORY_LIFECYCLE.md b/REPOSITORY_LIFECYCLE.md index bd3aa7d..0e8cc90 100644 --- a/REPOSITORY_LIFECYCLE.md +++ b/REPOSITORY_LIFECYCLE.md @@ -14,7 +14,7 @@ The machine-readable source is [`repository-lifecycle.yml`](repository-lifecycle | Status | Meaning | |--------|---------| -| **Production** | Exact latest release in the signed Production channel with an active, unexpired, non-revoked, independently attested acceptance admission | +| **Production** | Exact latest release in the signed Production channel with an active, until-revoked or unexpired, non-revoked, independently attested acceptance admission | | **Support** | Current public dependency or operational tool | | **Beta** | Active product surface with compatibility intent, but not a blanket production-readiness claim | | **Experimental** | Active prototype or optional component with no production support promise | @@ -76,10 +76,12 @@ until consumers pin the exact verifier. Each target has an append-only hash chain of signed Production release identities. The highest sequence is current. A new release cannot reuse an old -release identity. If the latest admission expires or is revoked, Production is -empty for that target. The validator does not fall back to an older release. +release identity. If the latest admission is revoked, Production is +empty for that target. `expires_at` JSON null means until-revoked. The validator +does not fall back to an older release. Static Production membership is forbidden. Consumers derive current Production -at read time from the signed admission, its expiry, and its revocation state. +at read time from the signed admission, its revocation state, and until-revoked +validity. ## Admission-Gated Targets @@ -89,21 +91,24 @@ 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 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. +The signed ledger currently has an active remote-safe-synthetic admission for +each of the seven targets. `expires_at` is JSON null (until-revoked). +Product-wide Production is true only while all seven stay active. These +admissions are not a MockMed `production_acceptance` flip. Workflow rows are +`0.0.0-synthetic`, not a customer job. Native Desktop Apple and Windows +installers remain unsigned and are not required. PyPI Flow 1.34.0 still lacks +the v4 Standard bind. The GitHub admission issuer stays installed but inactive. These are the derived states: | Target | Current state | Role | |------------|-----------|------| -| `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` | +| `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` | ## Other Repository Lifecycles diff --git a/production-lifecycle-policy.json b/production-lifecycle-policy.json index 7f0392c..cbf4cc1 100644 --- a/production-lifecycle-policy.json +++ b/production-lifecycle-policy.json @@ -1,9 +1,10 @@ { "$schema": "schemas/production-lifecycle-policy.schema.json", "schema_version": "openadapt.production-lifecycle-policy/v3", - "revision": 6, - "maximum_release_admission_days": 30, - "maximum_workflow_admission_days": 7, + "revision": 7, + "admission_validity": "until_revoked", + "maximum_release_admission_days": null, + "maximum_workflow_admission_days": null, "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 cb6ed18..1500fd6 100644 --- a/schemas/production-lifecycle-policy.schema.json +++ b/schemas/production-lifecycle-policy.schema.json @@ -8,6 +8,7 @@ "$schema", "schema_version", "revision", + "admission_validity", "maximum_release_admission_days", "maximum_workflow_admission_days", "object_reference_schema_version", @@ -21,9 +22,10 @@ "properties": { "$schema": {"const": "schemas/production-lifecycle-policy.schema.json"}, "schema_version": {"const": "openadapt.production-lifecycle-policy/v3"}, - "revision": {"type": "integer", "minimum": 6}, - "maximum_release_admission_days": {"const": 30}, - "maximum_workflow_admission_days": {"const": 7}, + "revision": {"type": "integer", "minimum": 7}, + "admission_validity": {"const": "until_revoked"}, + "maximum_release_admission_days": {"type": "null"}, + "maximum_workflow_admission_days": {"type": "null"}, "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 758378f..48cc4c6 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 -maximum admission windows that the signed checkpoint chain enforces, and it -names the protected feed ref that carries live Production state. It does +until-revoked admission validity 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" -# 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. +# 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. RETAINED_RELEASE_ADMISSION_MAXIMUM_DAYS = 30 -RELEASE_ADMISSION_MAXIMUM_DAYS = 30 -WORKFLOW_ADMISSION_MAXIMUM_DAYS = 7 +RELEASE_ADMISSION_MAXIMUM_DAYS = None +WORKFLOW_ADMISSION_MAXIMUM_DAYS = None # 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,16 +387,13 @@ def load_lifecycle( return _parse_group(text, "lifecycle"), _parse_group(text, "public_surfaces") -def _admission_days(value: object, label: str, expected: int) -> int: - """Require the closed maximum for one expiring admission kind.""" +def _admission_days(value: object, label: str) -> None: + """Refuse a live admission-day maximum. Validity is until revoked.""" - if ( - not isinstance(value, int) - or isinstance(value, bool) - or value != expected - ): - raise LifecycleError(f"{label} must be {expected}") - return value + if value is not None: + raise LifecycleError( + f"{label} must be null; admissions stay valid until revoked" + ) def _validate_summary_authority(value: object) -> dict[str, Any]: @@ -564,7 +561,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 the maximum admission windows. Every target it + accepts and that admissions stay valid until revoked. 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. @@ -576,6 +573,7 @@ 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", @@ -601,21 +599,18 @@ def _validate_policy(value: object) -> tuple[dict[str, Any], dict[str, Any]]: "production lifecycle policy revision must be at least " f"{POLICY_REVISION_MINIMUM}" ) - release_days = _admission_days( + if policy["admission_validity"] != "until_revoked": + raise LifecycleError( + "production lifecycle policy admission_validity must be until_revoked" + ) + _admission_days( policy["maximum_release_admission_days"], "maximum_release_admission_days", - RELEASE_ADMISSION_MAXIMUM_DAYS, ) - workflow_days = _admission_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), @@ -1425,9 +1420,9 @@ def _load_registered_json( def _has_policy_window( - value: Mapping[str, Any], *, label: str, maximum_days: int + value: Mapping[str, Any], *, label: str, maximum_days: int | None ) -> bool: - """Keep an otherwise valid retained object inactive without an expiry.""" + """Until-revoked records are active. A timestamped window uses maximum_days.""" try: issued_at = production_trust.require_timestamp( @@ -1436,6 +1431,8 @@ def _has_policy_window( except production_trust.TrustError as exc: raise LifecycleError(str(exc)) from exc expires_value = value.get("expires_at") + if maximum_days is None: + return expires_value is None if expires_value is None: return False try: @@ -1655,6 +1652,10 @@ def _validate_v2_release_admission( raise LifecycleError( f"admission {index} public-trust verification failed: {exc}" ) from exc + if admission["expires_at"] is not None: + raise LifecycleError( + f"admission {target_id} expiry must be until-revoked" + ) has_policy_window = _has_policy_window( admission, label=f"release admission {target_id}", @@ -1746,6 +1747,8 @@ 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 ): @@ -1867,6 +1870,11 @@ def _validate_workflow_admissions( seen.add(admission_id) if has_policy_window: active.append(admission_id) + if not active: + raise LifecycleError( + "at least one active workflow admission is required " + "(synthetic tutorial bundle)" + ) return active @@ -2556,7 +2564,7 @@ def main() -> int: "Validated evidence-gated Production lifecycle: " f"{len(active)} active admission(s)." ) - print(f"Validated {workflow_count} retained workflow admission record(s).") + print(f"Validated {workflow_count} active workflow admission(s).") return 0 diff --git a/tests/test_production_lifecycle.py b/tests/test_production_lifecycle.py index 82b715b..ae2b642 100644 --- a/tests/test_production_lifecycle.py +++ b/tests/test_production_lifecycle.py @@ -1121,17 +1121,19 @@ def test_published_policy_is_accepted(self) -> None: self.assertEqual(release["evidence_class"], "remote-safe-synthetic") self.assertEqual(release["target"], "flow") self.assertIsNone(release["expires_at"]) - self.assertEqual(active, {}) - self.assertNotIn(release["admission_id_sha256"], active.values()) - self.assertFalse(lifecycle.is_product_production(active)) + 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)) - def test_non_expiring_synthetic_target_admissions_are_not_production( + def test_seven_synthetic_target_admissions_are_product_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(active, {}) - self.assertFalse(lifecycle.is_product_production(active)) + self.assertEqual(len(active), 7) + self.assertEqual(set(active), set(lifecycle.EXPECTED_TARGETS)) + self.assertTrue(lifecycle.is_product_production(active)) six = { target_id: f"admission:{target_id}" for target_id in lifecycle.EXPECTED_TARGETS @@ -1144,7 +1146,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(active, {}) + self.assertEqual(len(active), 7) ledger = json.loads( (ROOT / "production-workflow-admissions.json").read_text(encoding="utf-8") ) @@ -1241,9 +1243,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["maximum_release_admission_days"], 30) - self.assertEqual(policy["maximum_workflow_admission_days"], 7) - self.assertNotIn("admission_validity", policy) + self.assertEqual(policy["admission_validity"], "until_revoked") + self.assertIsNone(policy["maximum_release_admission_days"]) + self.assertIsNone(policy["maximum_workflow_admission_days"]) self.assertNotIn("summary_authority", policy) self.assertNotIn("maximum_admission_days", policy) self.assertEqual( @@ -1273,14 +1275,15 @@ def test_retained_ledger_carries_the_v1_policy_digest(self) -> None: class AdmissionWindowTests(unittest.TestCase): - """Live release and workflow admissions are expiring and revocable.""" + """Live admissions stay valid until revoked. The retained ledger keeps 30 days.""" - def test_live_policy_has_closed_expiry_windows(self) -> None: + def test_live_policy_is_until_revoked(self) -> None: policy = load_policy() - 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) + 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) def test_retained_ledger_still_uses_the_historical_thirty_day_bound(self) -> None: self.assertEqual(lifecycle.RETAINED_RELEASE_ADMISSION_MAXIMUM_DAYS, 30) @@ -1311,10 +1314,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_changes_an_admission_day_maximum_is_refused(self) -> None: + def test_policy_that_declares_an_admission_day_maximum_is_refused(self) -> None: for key, value in ( ("maximum_release_admission_days", 3), - ("maximum_workflow_admission_days", 8), + ("maximum_workflow_admission_days", 7), ("maximum_release_admission_days", 31), ): with self.subTest(key=key, value=value): @@ -1322,7 +1325,7 @@ def test_policy_that_changes_an_admission_day_maximum_is_refused(self) -> None: policy[key] = value with self.assertRaisesRegex( lifecycle.LifecycleError, - "must be", + "must be null; admissions stay valid until revoked", ): lifecycle.validate( policy, @@ -1332,15 +1335,25 @@ def test_policy_that_changes_an_admission_day_maximum_is_refused(self) -> None: now=NOW, ) - def test_non_expiring_admission_is_inactive(self) -> None: - self.assertFalse( + def test_until_revoked_admission_is_active(self) -> None: + self.assertTrue( lifecycle._has_policy_window( { "issued_at": "2026-08-18T12:00:00Z", "expires_at": None, }, label="workflow admission", - maximum_days=7, + maximum_days=None, + ) + ) + self.assertFalse( + lifecycle._has_policy_window( + { + "issued_at": "2026-08-18T12:00:00Z", + "expires_at": "2026-08-25T12:00:00Z", + }, + label="workflow admission", + maximum_days=None, ) ) diff --git a/tests/test_public_trust_schemas.py b/tests/test_public_trust_schemas.py index d04bc7b..61951f0 100644 --- a/tests/test_public_trust_schemas.py +++ b/tests/test_public_trust_schemas.py @@ -437,6 +437,7 @@ "$schema", "schema_version", "revision", + "admission_validity", "maximum_release_admission_days", "maximum_workflow_admission_days", "object_reference_schema_version", diff --git a/tests/test_qualification_software_ed25519.py b/tests/test_qualification_software_ed25519.py index 31cc6cc..3f7e312 100644 --- a/tests/test_qualification_software_ed25519.py +++ b/tests/test_qualification_software_ed25519.py @@ -99,7 +99,8 @@ def test_registry_candidate_refuses_expiry_before_generated_at(self) -> None: def test_sign_receipt_round_trip(self) -> None: from test_qualification_issuer import trust_fixture - fixture = trust_fixture(decision_origin="software") + # sign_receipt verifies against wall-clock; until-revoked stays active. + fixture = trust_fixture(decision_origin="software", expires_at=None) unsigned = dict(fixture["receipt"]) unsigned["signature"] = "" signed = software.sign_receipt(