Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 20 additions & 15 deletions REPOSITORY_LIFECYCLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
7 changes: 4 additions & 3 deletions production-lifecycle-policy.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
8 changes: 5 additions & 3 deletions schemas/production-lifecycle-policy.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"$schema",
"schema_version",
"revision",
"admission_validity",
"maximum_release_admission_days",
"maximum_workflow_admission_days",
"object_reference_schema_version",
Expand All @@ -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"},
Expand Down
66 changes: 37 additions & 29 deletions scripts/validate_production_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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.
Expand All @@ -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",
Expand All @@ -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),
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand Down Expand Up @@ -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}",
Expand Down Expand Up @@ -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
):
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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


Expand Down
57 changes: 35 additions & 22 deletions tests/test_production_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1311,18 +1314,18 @@ 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):
policy = load_policy()
policy[key] = value
with self.assertRaisesRegex(
lifecycle.LifecycleError,
"must be",
"must be null; admissions stay valid until revoked",
):
lifecycle.validate(
policy,
Expand All @@ -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,
)
)

Expand Down
1 change: 1 addition & 0 deletions tests/test_public_trust_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,7 @@
"$schema",
"schema_version",
"revision",
"admission_validity",
"maximum_release_admission_days",
"maximum_workflow_admission_days",
"object_reference_schema_version",
Expand Down
3 changes: 2 additions & 1 deletion tests/test_qualification_software_ed25519.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down