diff --git a/.github/workflows/platform-manifest.yml b/.github/workflows/platform-manifest.yml
index 96ed10dcb..bb69ab85d 100644
--- a/.github/workflows/platform-manifest.yml
+++ b/.github/workflows/platform-manifest.yml
@@ -12,8 +12,8 @@ on:
branches:
- main
schedule:
- # DAILY drift check. The manifest goes stale because openadapt-flow,
- # -capture, or -desktop released in ANOTHER repository, so no commit
+ # DAILY drift check. The manifest goes stale when a component releases in
+ # ANOTHER repository, so no commit
# lands here and only the schedule can notice. This was weekly, which
# meant up to seven days of serving wrong sha256 digests from
# raw.githubusercontent; flow 1.24.0 published on 2026-07-27 and the
@@ -24,8 +24,8 @@ on:
# Receiver for a component repository to announce its release immediately
# rather than waiting for the daily sweep.
#
- # NOT YET WIRED: as of 2026-07-28 no component repository sends this. None of
- # openadapt-flow, -capture, or -desktop has a dispatch step in its release
+ # NOT YET WIRED: as of 2026-07-28 no component repository sends this. No
+ # component has a dispatch step in its release
# workflow, so the daily cron above is in practice the ONLY thing that
# notices a component release. That is how flow 1.25.0 and 1.25.1 published
# on 2026-07-27 after the manifest was regenerated at 15:42 that day and the
@@ -50,7 +50,9 @@ on:
types: [component-released]
concurrency:
- group: platform-manifest-${{ github.ref }}
+ group: >-
+ platform-manifest-${{ github.event_name }}-${{
+ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
@@ -75,6 +77,8 @@ jobs:
# Standard library only: no dependency install, no lockfile, no cache.
- name: Validate platform manifest against published artifacts
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
run: python scripts/validate_platform_manifest.py
- name: Prove the drift guard fails when it should
@@ -84,7 +88,9 @@ jobs:
if: github.event_name == 'pull_request' || github.event_name == 'push'
run: |
python -m pip install --quiet 'pytest>=8.0.0'
- python -m pytest tests/test_platform_manifest_drift.py -q
+ python -m pytest \
+ tests/test_platform_manifest_drift.py \
+ tests/test_platform_version_display.py -q
report-manifest-drift:
# A red scheduled run in a repository nobody has open is not a signal.
diff --git a/docs/platform-compatibility-report.md b/docs/platform-compatibility-report.md
new file mode 100644
index 000000000..2f3af3902
--- /dev/null
+++ b/docs/platform-compatibility-report.md
@@ -0,0 +1,83 @@
+# OpenAdapt platform compatibility report
+
+
+
+- Generated: `2026-07-28T11:01:07+00:00`
+- Release channel: **beta**
+- Selection: `latest-published`
+- Compatibility: **dependency-compatible**
+- Signature: `unsigned (signing infrastructure pending)`
+
+## Published components
+
+| Role | Package | Version | Release source reference | Published artifacts |
+|---|---|---:|---|---|
+| launcher | `openadapt` | `1.10.2` | [`v1.10.2`](https://github.com/OpenAdaptAI/OpenAdapt/tree/v1.10.2)
commit `9cddb87ca5f78a47ed5d379a57e2f53ca4484c71`
tree `98464eb5f9d16afb0af32de7c585a3155843a5a0` | `openadapt-1.10.2-py3-none-any.whl` `717adffae146a3dea22f82a57317e45d15c98b1f115285e60a93b01fe45464d6`
`openadapt-1.10.2.tar.gz` `cae5a1421213521c7d60f613024d8683b2cb0c118186202279c5cb03d2731a38` |
+| flow | `openadapt-flow` | `1.25.1` | [`v1.25.1`](https://github.com/OpenAdaptAI/openadapt-flow/tree/v1.25.1)
commit `b3966d01f0b574c07d7a46429f60769f859b6a23`
tree `c3ee13d43a56df9d394a60a5f9fc5f18114daedd` | `openadapt_flow-1.25.1-py3-none-any.whl` `375c516837a44bc8bea87ba0429967f92561d6df92eef41e624f55c281bed9ae`
`openadapt_flow-1.25.1.tar.gz` `15393bb978283e87f043e6fa5f05d7b1d3fc0f9a497a4cf9561a23e8fc5f76ac` |
+| capture | `openadapt-capture` | `1.2.2` | [`v1.2.2`](https://github.com/OpenAdaptAI/openadapt-capture/tree/v1.2.2)
commit `06ade22948c12dcc6608bd54b5cca5c9c306e77c`
tree `b0a3b5b53f23536e01314f36aa46fd5b2d8baba6` | `openadapt_capture-1.2.2-py3-none-any.whl` `550fffe50990fa8431d332bdd5fe4755976d83baa7ad62553f50ad9e4ddcd1ae`
`openadapt_capture-1.2.2.tar.gz` `6f4298d3daadf9cced8994207e34b3116a79209ef792e98f778a0770e2f3406c` |
+| privacy | `openadapt-privacy` | `1.0.1` | [`v1.0.1`](https://github.com/OpenAdaptAI/openadapt-privacy/tree/v1.0.1)
commit `741e1546aab65676f5e5811c306fc77f6131824d`
tree `ca7db739ca7c14f0b25078c13ed5e1c7566d12a6` | `openadapt_privacy-1.0.1-py3-none-any.whl` `0109127838e854b22a2e90a911f0b83812d32dd1e7b8340faf8ebdb69ef2a478`
`openadapt_privacy-1.0.1.tar.gz` `e9bc90d7a8306088341d83432faf547c16f6bc141c4776e96359571d93923a27` |
+| types | `openadapt-types` | `0.6.4` | [`v0.6.4`](https://github.com/OpenAdaptAI/openadapt-types/tree/v0.6.4)
commit `a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c`
tree `abdaad39abe87521168c26b12fab5c2ad2fe5717` | `openadapt_types-0.6.4-py3-none-any.whl` `115e5040bf5a03cbe68a6f5d9b5a412d48432b46e546cded9f5e977d76687837`
`openadapt_types-0.6.4.tar.gz` `3e4752c90daf989485f9d9f5f364643a8a6bcca9e06a934b7b7598cdf145b8d3` |
+| desktop | `openadapt-desktop` | `0.14.0` | [`desktop-v0.14.0`](https://github.com/OpenAdaptAI/openadapt-desktop/tree/desktop-v0.14.0)
commit `778c8384369c75b31c24c1de92c9597c0e09b469`
tree `f8b7260f9d459f1d4a3a9640c501d8125d8f353a` | `openadapt_desktop-0.14.0-py3-none-any.whl` `7ee5a1d2e00f5cbd887edd070916e53530c9548b04713a1aa7d9960f2d313082`
`openadapt_desktop-0.14.0.tar.gz` `44a50bd2c126524a76b4d0c0eb0bed9f24667940f735eed1dfed03dce7e9862b` |
+| agent | `openadapt-agent` | `2.0.1` | [`v2.0.1`](https://github.com/OpenAdaptAI/openadapt-agent/tree/v2.0.1)
commit `d9f16b2946dfdd0228eeb2660047347df9dce8d8`
tree `26606c89b4294603b628f1b3aaa8283c45765326` | `openadapt_agent-2.0.1-py3-none-any.whl` `5c5f6e3eca4c127d87b3affb8944223c0836916e0df57d0c6b8ba737b3bc8b5a`
`openadapt_agent-2.0.1.tar.gz` `67e05b299a4f035a0482b3032decc151fafc22de9a12bb3d8f884f37ea4cc937` |
+
+## Runtime selections
+
+| Unit | Version | Selected or locked OpenAdapt packages |
+|---|---:|---|
+| launcher_environment | `1.10.2` | `openadapt==1.10.2`
`openadapt-flow==1.25.1` |
+| customer_runner | `1.25.1` | `openadapt-flow==1.25.1`
`openadapt-types==0.6.4` |
+| desktop_sidecar | `0.14.0` | `openadapt-capture==1.1.1`
`openadapt-flow==1.23.0`
`openadapt-privacy==1.0.0`
`openadapt-types==0.5.0` |
+| agent_bridge | `2.0.1` | `openadapt-agent==2.0.1`
`openadapt-flow==1.25.1` |
+
+## Selected-platform dependency compatibility
+
+These edges show whether each selected integration version satisfies its published dependency constraint. Optional markers do not mean that every package is installed in every runtime unit.
+
+| Scope | Source | Target | Constraint | Selected | Activation |
+|---|---|---|---|---:|---|
+| desktop-sidecar-lock | `openadapt-desktop==0.14.0` | `openadapt-capture` | `>=1.1.0` | `1.1.1` | `always` |
+| desktop-sidecar-lock | `openadapt-desktop==0.14.0` | `openadapt-flow` | `==1.23.0` | `1.23.0` | `extra == "build"` |
+| desktop-sidecar-lock | `openadapt-desktop==0.14.0` | `openadapt-privacy` | `>=1.0.0` | `1.0.0` | `always` |
+| desktop-sidecar-lock | `openadapt-desktop==0.14.0` | `openadapt-types` | `==0.5.0` | `0.5.0` | `always` |
+| selected-platform | `openadapt-agent==2.0.1` | `openadapt-flow` | `<2,>=1.18.1` | `1.25.1` | `always` |
+| selected-platform | `openadapt-capture==1.2.2` | `openadapt-privacy` | `>=0.1.0` | `1.0.1` | `extra == "all"`
`extra == "privacy"` |
+| selected-platform | `openadapt-flow==1.25.1` | `openadapt-capture` | `>=1.2.0` | `1.2.2` | `extra == "capture"` |
+| selected-platform | `openadapt-flow==1.25.1` | `openadapt-privacy` | `>=1.0.0` | `1.0.1` | `extra == "privacy"` |
+| selected-platform | `openadapt-flow==1.25.1` | `openadapt-types` | `<0.7.0,>=0.6.0` | `0.6.4` | `extra == "console"`
`extra == "dev"`
`extra == "interop"` |
+| selected-platform | `openadapt==1.10.2` | `openadapt-capture` | `<2.0.0,>=1.0.4` | `1.2.2` | `extra == "all"`
`extra == "capture"`
`extra == "core"` |
+| selected-platform | `openadapt==1.10.2` | `openadapt-flow` | `<2.0.0,>=1.20.1` | `1.25.1` | `always`
`extra == "all"`
`extra == "capture"`
`extra == "core"`
`extra == "flow"`
`extra == "privacy"`
`extra == "rdp"`
`extra == "windows"`
`sys_platform == "darwin" and extra == "all"`
`sys_platform == "darwin" and extra == "macos"`
`sys_platform == "linux" and extra == "all"`
`sys_platform == "linux" and extra == "linux"` |
+
+## Cross-component schemas
+
+| Contract | Owner | Accepted versions | Exact source |
+|---|---|---|---|
+| `workflow_bundle` | `flow` | `schema_version` [1, 2] | [`openadapt_flow/bundle_validation.py`](https://github.com/OpenAdaptAI/openadapt-flow/blob/b3966d01f0b574c07d7a46429f60769f859b6a23/openadapt_flow/bundle_validation.py) `1163fe77d786b0a5481d4d4f5f82d5bd9e95c0808586951b1fcd50e6f8a8eb63`
[`openadapt_flow/ir.py`](https://github.com/OpenAdaptAI/openadapt-flow/blob/b3966d01f0b574c07d7a46429f60769f859b6a23/openadapt_flow/ir.py) `f55d9a078b208a5204e7b72ddf1c90228a5ce772bb5350323525e6f6593bc614` |
+| `capture_structural_observation` | `capture` | `openadapt.capture.structural-observation/v` [1] | [`openadapt_capture/structural.py`](https://github.com/OpenAdaptAI/openadapt-capture/blob/06ade22948c12dcc6608bd54b5cca5c9c306e77c/openadapt_capture/structural.py) `8f8bce37148a3357ace147c46a7c45e6d5501b70622b24b1ac2490f2def304fc` |
+| `control_overlay_frame` | `types` | `openadapt.control-overlay-frame/v` [1, 2] | [`openadapt_types/control_overlay.py`](https://github.com/OpenAdaptAI/openadapt-types/blob/a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c/openadapt_types/control_overlay.py) `3aa01f89b43e12478c9e2ff1a9e61689397370691d15247ded9dcd5b05242790`
[`openadapt_types/control_overlay_tracking.py`](https://github.com/OpenAdaptAI/openadapt-types/blob/a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c/openadapt_types/control_overlay_tracking.py) `af8d934f0572c5f4e9c63ba720d4a1d0e73c8cdf3f7b87cc4562c01a8a551755` |
+| `control_overlay_timeline` | `types` | `openadapt.control-overlay-timeline/v` [1, 2] | [`openadapt_types/control_overlay.py`](https://github.com/OpenAdaptAI/openadapt-types/blob/a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c/openadapt_types/control_overlay.py) `3aa01f89b43e12478c9e2ff1a9e61689397370691d15247ded9dcd5b05242790`
[`openadapt_types/control_overlay_tracking.py`](https://github.com/OpenAdaptAI/openadapt-types/blob/a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c/openadapt_types/control_overlay_tracking.py) `af8d934f0572c5f4e9c63ba720d4a1d0e73c8cdf3f7b87cc4562c01a8a551755` |
+| `human_decision_task` | `types` | `openadapt.human-decision-task/v` [1] | [`openadapt_types/human_decision.py`](https://github.com/OpenAdaptAI/openadapt-types/blob/a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c/openadapt_types/human_decision.py) `30aedcdb7bf7291d7b0f061703a1feecd672c9a0529c539bdb09c47d2bb1271b` |
+| `human_decision_receipt` | `types` | `openadapt.human-decision-receipt/v` [1] | [`openadapt_types/human_decision.py`](https://github.com/OpenAdaptAI/openadapt-types/blob/a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c/openadapt_types/human_decision.py) `30aedcdb7bf7291d7b0f061703a1feecd672c9a0529c539bdb09c47d2bb1271b` |
+| `remote_decision_projection` | `flow` | `openadapt.remote-decision-projection/v` [1] | [`openadapt_flow/console/human_decisions.py`](https://github.com/OpenAdaptAI/openadapt-flow/blob/b3966d01f0b574c07d7a46429f60769f859b6a23/openadapt_flow/console/human_decisions.py) `5956b9677dfe6d9fca43be8b0201ea707c7e303ac9c1987b1ef24336452dfaf5` |
+| `runtime_validation` | `flow` | `openadapt.runtime-validation/v` [1, 2] | [`openadapt_flow/runtime_validation.py`](https://github.com/OpenAdaptAI/openadapt-flow/blob/b3966d01f0b574c07d7a46429f60769f859b6a23/openadapt_flow/runtime_validation.py) `5f6e81e1c170419f0b755b8cb1cbdd61064a4b02b37439c65fbd027285d6a4b2` |
+
+## Host operating systems
+
+`windows`, `macos`, `linux`
+
+## Substrate delivery
+
+| Substrate | Public label | Delivery |
+|---|---|---|
+| Browser | Available | Local, managed OpenAdapt Cloud, or customer-controlled |
+| Windows | Available | Local or customer-controlled |
+| macOS | Available | Local or customer-controlled |
+| Linux | Available | Local or customer-controlled |
+| RDP | Available | Local or customer-controlled |
+| Citrix / VDI | Available | Local or customer-controlled |
+| Hosted Cloud | Beta | Managed browser runner and control plane |
+
+## Qualification evidence
+
+- [openadapt-public-status](https://openadapt.ai/status.json): Canonical machine-readable release, capability, and substrate qualification status, maintained in openadapt-web (public/status.json).
+- [flow-public-demo-evidence-packs](https://github.com/OpenAdaptAI/openadapt-flow/tree/b3966d01f0b574c07d7a46429f60769f859b6a23/public-demo/evidence-packs): Versioned public evidence packs with per-pack manifest files, replay evidence, and fault-case results.
+- [flow-effectbench-task-pack](https://github.com/OpenAdaptAI/openadapt-flow/blob/b3966d01f0b574c07d7a46429f60769f859b6a23/benchmark/effectbench/task_pack/manifest.json): Effect-verification benchmark task pack manifest used by the flow qualification harness.
diff --git a/docs/platform-manifest.md b/docs/platform-manifest.md
index 1a3f07247..e6c5789d9 100644
--- a/docs/platform-manifest.md
+++ b/docs/platform-manifest.md
@@ -2,10 +2,11 @@
`platform-manifest.json` at the repository root is the single authoritative,
machine-readable statement of what constitutes an OpenAdapt platform release:
-which launcher, flow, capture, and desktop versions belong together, where
-their published artifacts live (with sha256 digests), which operating systems
-and substrate drivers are supported, what qualification evidence backs the
-release, and what release channel it is on.
+which Launcher, Flow, Capture, Privacy, Types, Desktop, and Agent versions
+belong together. It also names the selected launcher, customer runner, and
+Agent bridge packages, plus the exact frozen Desktop sidecar closure. The
+manifest includes release-derived schema versions, dependency compatibility,
+release source references, artifact digests, and the release channel.
## Why it lives here
@@ -16,6 +17,10 @@ platform manifest is therefore generated and versioned here, next to the pins
it must agree with. Other repositories contribute source data only:
- PyPI is the authority for published versions, artifact URLs, and digests.
+- Each exact public release tag is the authority for its release source commit
+ and tree. This reference is not, by itself, a build-provenance attestation.
+- The exact `desktop-vX.Y.Z` tag and its `uv.lock` are the authority for the
+ package versions frozen into `openadapt-engine`.
- `https://openadapt.ai/status.json` (maintained in `openadapt-web`,
`public/status.json`) is the authority for substrate availability, release
channel, and the public qualification summary.
@@ -29,10 +34,12 @@ it must agree with. Other repositories contribute source data only:
python scripts/generate_platform_manifest.py
```
-The generator reads the real published state (PyPI JSON API, the live
-status.json) plus this repository's `pyproject.toml`. It never invents
-numbers: if a source is unreachable, empty, or disagrees with the repository,
-it fails loudly. If a launcher release train is in flight (pyproject.toml
+The generator reads the real published state from PyPI, exact GitHub release
+commits, the Desktop lock, and the live status document. It also reads this
+repository's `pyproject.toml`. It derives protocol versions from the exact
+selected source files. An optional protocol does not appear until a published
+release contains its source. If a source is unreachable, empty, or
+inconsistent, generation fails. If a launcher release train is in flight (`pyproject.toml`
ahead of PyPI), pass `--allow-unreleased-launcher`; the manifest still records
the published version.
@@ -43,8 +50,45 @@ cannot truthfully name a launcher release before its immutable artifacts exist.
If publication or reconciliation fails, `main` remains red and the release
workflow opens or updates a failure issue rather than weakening validation.
-Regenerate and commit the manifest manually after other component releases;
-the daily scheduled validator catches component or public-status drift.
+Regenerate and commit the manifest manually after other component releases.
+The daily validator catches component, sidecar, and public-status drift.
+
+The manifest can truthfully describe an incompatible published set. Use the
+strict promotion gate only when all required runtime packages exist:
+
+```bash
+python scripts/validate_platform_manifest.py \
+ --require-compatible --require-network --strict-status
+```
+
+The ordinary validator confirms that the recorded dependency result is
+correct. It does not rewrite an incompatible result.
+
+An ordered release train does not require a guessed future version in source.
+Select exact versions only after PyPI and the release tag contain them:
+
+```bash
+python scripts/generate_platform_manifest.py \
+ --component-version flow= \
+ --component-version desktop=
+```
+
+The generator refuses an input until it can bind the exact PyPI files, hashes,
+dependency metadata, release source commit, release tree, and Desktop lock.
+The next Flow and Desktop train can use the same contract without a temporary
+hard-coded version or a false release claim.
+
+Generate a release display from the manifest. Do not copy version values into
+another source file:
+
+```bash
+python scripts/render_platform_versions.py
+python scripts/render_platform_versions.py --format json
+python scripts/render_platform_versions.py --format markdown
+```
+
+`docs/platform-compatibility-report.md` is the generated human-readable report.
+The offline validator fails if it differs from the machine-readable BOM.
## How it is validated
@@ -58,10 +102,9 @@ manifest and the actually published artifacts fails loudly.
The workflow also accepts a `component-released` `repository_dispatch` so a
component repository can trigger the check immediately. **No component
-repository sends it yet.** `openadapt-flow`, `openadapt-capture`, and
-`openadapt-desktop` have no dispatch step in their release workflows, so the
-daily cron is in practice the only automatic detector, and a component release
-can serve stale digests for up to a day. Wiring the sender needs a token with
+repository sends it yet.** The component release workflows have no dispatch
+step, so the daily cron is in practice the only automatic detector. A component
+release can serve stale digests for up to a day. Wiring the sender needs a token with
`contents: write` on `OpenAdaptAI/OpenAdapt` stored as a secret in each
component repository; the component's own `GITHUB_TOKEN` cannot dispatch
across repositories.
@@ -99,8 +142,10 @@ conditions above.
Validated today:
-- Structure: manifest kind, schema major version, required fields, artifacts
- with sha256 digests per component.
+- Structure: manifest kind, schema major version, UTC generation time,
+ generation inputs, seven required published components, four runtime units,
+ the complete dependency graph, exact schema-source bindings, operating
+ systems, substrates, evidence objects, and artifact SHA-256 digests.
- Signature honesty: while `signature.value` is null, `signature.status` must
read `unsigned (signing infrastructure pending)`. A non-null signature
value fails validation because no verification path exists yet.
@@ -108,31 +153,54 @@ Validated today:
the manifest's launcher version is not ahead of `pyproject.toml`'s. A
`pyproject.toml` ahead of the manifest is the normal in-flight-release state
and only warns.
-- Published-artifact agreement: component versions, artifact filenames, URLs,
- and sha256 digests match PyPI exactly (`--offline` skips this class). A
+- Published-artifact agreement: component versions, Python ranges, dependency
+ ranges and markers, and the exact artifact filename/type/URL/SHA-256 set
+ match version-specific PyPI metadata (`--offline` skips this class). A
manifest version behind PyPI's latest, or naming a release PyPI never
published, fails; one ahead of `info.version` but already present in
`releases` is index propagation lag and warns, with digests still verified.
+ An exact version supplied through `--component-version` can remain behind
+ the latest release; the validator records a warning and still verifies all
+ selected files and source provenance.
An unreachable PyPI warns rather than fails — it is not evidence of drift —
unless `--require-network` is passed.
+- Release source references: every component binds its exact public release
+ ref, commit, and tree. Network validation resolves each ref again and
+ refuses a mismatch.
+- Schema agreement: schema versions and source-file hashes are derived again
+ from the exact selected release commits. A future schema cannot appear in
+ the BOM before its published source exists.
+- Evidence agreement: each repository evidence path is bound to an exact Git
+ blob or tree and verified against the selected release tree.
- Status skew: disagreement with `status.json` versions is a warning by
default, because that file lives in another repository and a release here
must not be blocked by an edit nobody in this repo can make;
- `--strict-status` escalates it to a failure. The warning is **not**
+ `--strict-status` escalates it to a failure and also fails when the status
+ source is unreachable. Operating-system and substrate-table drift always
+ fails when the source is reachable. The warning is **not**
self-healing: `status.json` is hand-maintained in `openadapt-web` and
- nothing generates it from PyPI or from this manifest. On 2026-07-28 it
- advertised flow 1.24.0 and launcher 1.10.0 against PyPI's 1.25.1 and 1.10.1.
+ nothing generates it from PyPI or from this manifest.
`openadapt-web` runs a daily guard
(`scripts/check_published_version_claims.mjs`) that fails on exactly this,
but that detects after the fact and still needs a person to open the
corrective pull request. Skew reported here means someone must edit
`public/status.json` and `data/published-version-claims.json` in
`openadapt-web`.
+- Native sidecar agreement: the Desktop sidecar's resolved OpenAdapt package
+ versions and lock digest match the exact `desktop-vX.Y.Z` tag's `uv.lock`.
+- Compatibility honesty: the validator recomputes every dependency edge among
+ the seven selected components. Optional-extra markers remain visible. The
+ Desktop edges resolve against its exact sidecar lock; other edges resolve
+ against the selected platform versions.
+- Promotion: `--require-compatible` fails while any selected dependency edge
+ falls outside its published package range.
Not validated yet (planned):
- Cryptographic signature verification (see the signing plan below).
-- Desktop OS installer artifacts (MSI/DMG); today only PyPI artifacts exist.
+- Desktop installer signature and notarization verification. Desktop release
+ jobs verify their own installer digests and attestations, but this manifest
+ does not fetch or verify those large files yet.
## Schema (v1)
@@ -143,9 +211,15 @@ Top-level fields:
| `manifest_kind` | Always `openadapt-platform-release-manifest`. |
| `schema_version` | Semver of this schema; validators reject unknown majors. |
| `generated_at` | UTC timestamp of generation. |
+| `generation` | SHA-256 bindings for the generator and report renderer. |
| `release_channel` | Lowercased product lifecycle from status.json (currently `beta`). |
-| `components` | `launcher`, `flow`, `capture`, `desktop`: package name, published version, `requires_python`, and artifacts (`type`, `filename`, `url`, `sha256`). |
-| `compatibility` | The launcher's supported Python range and its real openadapt-* dependency specifiers, extracted from `pyproject.toml`. |
+| `release_selection` | Latest-published selection or exact published version inputs supplied for an ordered release train. |
+| `components` | `launcher`, `flow`, `capture`, `privacy`, `types`, `desktop`, and `agent`: package name, published version, Python range, dependency constraints and markers, release source commit/tree, and the exact artifact set. |
+| `runtime_units` | Launcher, runner, Desktop, and Agent deployment views. Non-locked units name selected packages. The Desktop sidecar names the exact lock-resolved closure. |
+| `dependency_edges` | Every selected-component dependency edge, its activation marker, version constraint, resolution scope, and exact selected or locked target. |
+| `schema_compatibility` | Explicit accepted versions derived from exact selected source files, with source commit, blob, and SHA-256 bindings. Optional schemas are omitted until published. |
+| `compatibility` | The launcher's supported Python range and its OpenAdapt dependency specifiers, extracted from `pyproject.toml`. |
+| `compatibility_status` | `dependency-compatible` or `dependency-incompatible`, plus the exact failed package edges. The generator computes this field. |
| `supported_os` | Operating systems the launcher supports. |
| `substrate_drivers` | Substrate table (name, public label, delivery) read from status.json. |
| `qualification_evidence` | Stable evidence IDs pointing at the public status document and the flow evidence-pack manifests. |
@@ -174,10 +248,15 @@ intended rollout, in order:
ever emitted. Note that PyPI publish attestations (PEP 740, also
sigstore-backed) already exist for recent `openadapt-desktop` uploads and
provide per-artifact provenance independent of this manifest.
-2. Windows Authenticode signing for desktop installers (MSI/EXE) once those
- installers are produced.
-3. Apple Developer ID signing plus notarization for macOS app and DMG
- artifacts.
+2. Windows Authenticode signing for the MSI and EXE installers after the
+ signing credentials are active.
+3. Apple Developer ID signing plus notarization for the macOS app and DMG
+ artifacts after the Apple credentials are active.
+
+The current Desktop release workflow supports Authenticode and Developer ID
+credentials. The current public installer set still uses ad-hoc macOS signing
+and unsigned Windows and Linux packages. The platform promotion gate must stay
+separate from a claim that the installers have trusted OS signatures.
Until step 1 lands, the validator enforces that the manifest claims nothing:
a manifest with a non-null `signature.value` fails validation.
diff --git a/platform-manifest.json b/platform-manifest.json
index c1cf95a96..5685cc1f3 100644
--- a/platform-manifest.json
+++ b/platform-manifest.json
@@ -1,14 +1,254 @@
{
"manifest_kind": "openadapt-platform-release-manifest",
- "schema_version": "1.0.0",
- "generated_at": "2026-07-28T06:27:45+00:00",
+ "schema_version": "1.3.0",
+ "generated_at": "2026-07-28T11:01:07+00:00",
+ "generation": {
+ "files": [
+ {
+ "path": "scripts/generate_platform_manifest.py",
+ "sha256": "d901843ea4af60fae19794b503eeeac05611abfd521ad9a74c2d925b4458d432"
+ },
+ {
+ "path": "scripts/render_platform_versions.py",
+ "sha256": "d4e6f9f3bd297276f0ae409c80e32e05b3bbeba1ae497bc49d65c245a98a7900"
+ }
+ ]
+ },
"release_channel": "beta",
+ "release_selection": {
+ "mode": "latest-published",
+ "component_versions": {}
+ },
"components": {
"launcher": {
"package": "openadapt",
"version": "1.10.2",
"source": "pypi",
"requires_python": "<3.13,>=3.10",
+ "requires": {
+ "openadapt-capture": [
+ "<2.0.0,>=1.0.4"
+ ],
+ "openadapt-evals": [
+ ">=0.1.0"
+ ],
+ "openadapt-flow": [
+ "<2.0.0,>=1.20.1"
+ ],
+ "openadapt-grounding": [
+ ">=0.1.0"
+ ],
+ "openadapt-ml": [
+ ">=0.2.0"
+ ],
+ "openadapt-retrieval": [
+ ">=0.1.0"
+ ],
+ "openadapt-viewer": [
+ ">=0.1.0"
+ ]
+ },
+ "dependency_constraints": [
+ {
+ "package": "openadapt-capture",
+ "requires": "<2.0.0,>=1.0.4",
+ "extras": null,
+ "marker": "extra == \"all\""
+ },
+ {
+ "package": "openadapt-capture",
+ "requires": "<2.0.0,>=1.0.4",
+ "extras": null,
+ "marker": "extra == \"capture\""
+ },
+ {
+ "package": "openadapt-capture",
+ "requires": "<2.0.0,>=1.0.4",
+ "extras": null,
+ "marker": "extra == \"core\""
+ },
+ {
+ "package": "openadapt-evals",
+ "requires": ">=0.1.0",
+ "extras": null,
+ "marker": "extra == \"all\""
+ },
+ {
+ "package": "openadapt-evals",
+ "requires": ">=0.1.0",
+ "extras": null,
+ "marker": "extra == \"core\""
+ },
+ {
+ "package": "openadapt-evals",
+ "requires": ">=0.1.0",
+ "extras": null,
+ "marker": "extra == \"evals\""
+ },
+ {
+ "package": "openadapt-flow",
+ "requires": "<2.0.0,>=1.20.1",
+ "extras": null,
+ "marker": "extra == \"all\""
+ },
+ {
+ "package": "openadapt-flow",
+ "requires": "<2.0.0,>=1.20.1",
+ "extras": null,
+ "marker": "extra == \"flow\""
+ },
+ {
+ "package": "openadapt-flow",
+ "requires": "<2.0.0,>=1.20.1",
+ "extras": "capture",
+ "marker": "extra == \"all\""
+ },
+ {
+ "package": "openadapt-flow",
+ "requires": "<2.0.0,>=1.20.1",
+ "extras": "capture",
+ "marker": "extra == \"capture\""
+ },
+ {
+ "package": "openadapt-flow",
+ "requires": "<2.0.0,>=1.20.1",
+ "extras": "capture",
+ "marker": "extra == \"core\""
+ },
+ {
+ "package": "openadapt-flow",
+ "requires": "<2.0.0,>=1.20.1",
+ "extras": "hosted",
+ "marker": null
+ },
+ {
+ "package": "openadapt-flow",
+ "requires": "<2.0.0,>=1.20.1",
+ "extras": "linux",
+ "marker": "sys_platform == \"linux\" and extra == \"all\""
+ },
+ {
+ "package": "openadapt-flow",
+ "requires": "<2.0.0,>=1.20.1",
+ "extras": "linux",
+ "marker": "sys_platform == \"linux\" and extra == \"linux\""
+ },
+ {
+ "package": "openadapt-flow",
+ "requires": "<2.0.0,>=1.20.1",
+ "extras": "macos",
+ "marker": "sys_platform == \"darwin\" and extra == \"all\""
+ },
+ {
+ "package": "openadapt-flow",
+ "requires": "<2.0.0,>=1.20.1",
+ "extras": "macos",
+ "marker": "sys_platform == \"darwin\" and extra == \"macos\""
+ },
+ {
+ "package": "openadapt-flow",
+ "requires": "<2.0.0,>=1.20.1",
+ "extras": "privacy",
+ "marker": "extra == \"all\""
+ },
+ {
+ "package": "openadapt-flow",
+ "requires": "<2.0.0,>=1.20.1",
+ "extras": "privacy",
+ "marker": "extra == \"privacy\""
+ },
+ {
+ "package": "openadapt-flow",
+ "requires": "<2.0.0,>=1.20.1",
+ "extras": "rdp",
+ "marker": "extra == \"all\""
+ },
+ {
+ "package": "openadapt-flow",
+ "requires": "<2.0.0,>=1.20.1",
+ "extras": "rdp",
+ "marker": "extra == \"rdp\""
+ },
+ {
+ "package": "openadapt-flow",
+ "requires": "<2.0.0,>=1.20.1",
+ "extras": "windows",
+ "marker": "extra == \"all\""
+ },
+ {
+ "package": "openadapt-flow",
+ "requires": "<2.0.0,>=1.20.1",
+ "extras": "windows",
+ "marker": "extra == \"windows\""
+ },
+ {
+ "package": "openadapt-grounding",
+ "requires": ">=0.1.0",
+ "extras": null,
+ "marker": "extra == \"all\""
+ },
+ {
+ "package": "openadapt-grounding",
+ "requires": ">=0.1.0",
+ "extras": null,
+ "marker": "extra == \"grounding\""
+ },
+ {
+ "package": "openadapt-ml",
+ "requires": ">=0.2.0",
+ "extras": null,
+ "marker": "extra == \"all\""
+ },
+ {
+ "package": "openadapt-ml",
+ "requires": ">=0.2.0",
+ "extras": null,
+ "marker": "extra == \"core\""
+ },
+ {
+ "package": "openadapt-ml",
+ "requires": ">=0.2.0",
+ "extras": null,
+ "marker": "extra == \"ml\""
+ },
+ {
+ "package": "openadapt-retrieval",
+ "requires": ">=0.1.0",
+ "extras": null,
+ "marker": "extra == \"all\""
+ },
+ {
+ "package": "openadapt-retrieval",
+ "requires": ">=0.1.0",
+ "extras": null,
+ "marker": "extra == \"retrieval\""
+ },
+ {
+ "package": "openadapt-viewer",
+ "requires": ">=0.1.0",
+ "extras": null,
+ "marker": "extra == \"all\""
+ },
+ {
+ "package": "openadapt-viewer",
+ "requires": ">=0.1.0",
+ "extras": null,
+ "marker": "extra == \"core\""
+ },
+ {
+ "package": "openadapt-viewer",
+ "requires": ">=0.1.0",
+ "extras": null,
+ "marker": "extra == \"viewer\""
+ }
+ ],
+ "provenance": {
+ "repository": "OpenAdaptAI/OpenAdapt",
+ "release_ref": "v1.10.2",
+ "commit": "9cddb87ca5f78a47ed5d379a57e2f53ca4484c71",
+ "tree": "98464eb5f9d16afb0af32de7c585a3155843a5a0",
+ "url": "https://github.com/OpenAdaptAI/OpenAdapt/tree/v1.10.2"
+ },
"artifacts": [
{
"type": "bdist_wheel",
@@ -29,6 +269,65 @@
"version": "1.25.1",
"source": "pypi",
"requires_python": "<3.13,>=3.10",
+ "requires": {
+ "openadapt-capture": [
+ ">=1.2.0"
+ ],
+ "openadapt-grounding": [
+ ">=0.1.0"
+ ],
+ "openadapt-privacy": [
+ ">=1.0.0"
+ ],
+ "openadapt-types": [
+ "<0.7.0,>=0.6.0"
+ ]
+ },
+ "dependency_constraints": [
+ {
+ "package": "openadapt-capture",
+ "requires": ">=1.2.0",
+ "extras": null,
+ "marker": "extra == \"capture\""
+ },
+ {
+ "package": "openadapt-grounding",
+ "requires": ">=0.1.0",
+ "extras": null,
+ "marker": "extra == \"grounding\""
+ },
+ {
+ "package": "openadapt-privacy",
+ "requires": ">=1.0.0",
+ "extras": "presidio",
+ "marker": "extra == \"privacy\""
+ },
+ {
+ "package": "openadapt-types",
+ "requires": "<0.7.0,>=0.6.0",
+ "extras": null,
+ "marker": "extra == \"console\""
+ },
+ {
+ "package": "openadapt-types",
+ "requires": "<0.7.0,>=0.6.0",
+ "extras": null,
+ "marker": "extra == \"dev\""
+ },
+ {
+ "package": "openadapt-types",
+ "requires": "<0.7.0,>=0.6.0",
+ "extras": null,
+ "marker": "extra == \"interop\""
+ }
+ ],
+ "provenance": {
+ "repository": "OpenAdaptAI/openadapt-flow",
+ "release_ref": "v1.25.1",
+ "commit": "b3966d01f0b574c07d7a46429f60769f859b6a23",
+ "tree": "c3ee13d43a56df9d394a60a5f9fc5f18114daedd",
+ "url": "https://github.com/OpenAdaptAI/openadapt-flow/tree/v1.25.1"
+ },
"artifacts": [
{
"type": "bdist_wheel",
@@ -49,6 +348,32 @@
"version": "1.2.2",
"source": "pypi",
"requires_python": ">=3.10",
+ "requires": {
+ "openadapt-privacy": [
+ ">=0.1.0"
+ ]
+ },
+ "dependency_constraints": [
+ {
+ "package": "openadapt-privacy",
+ "requires": ">=0.1.0",
+ "extras": null,
+ "marker": "extra == \"all\""
+ },
+ {
+ "package": "openadapt-privacy",
+ "requires": ">=0.1.0",
+ "extras": null,
+ "marker": "extra == \"privacy\""
+ }
+ ],
+ "provenance": {
+ "repository": "OpenAdaptAI/openadapt-capture",
+ "release_ref": "v1.2.2",
+ "commit": "06ade22948c12dcc6608bd54b5cca5c9c306e77c",
+ "tree": "b0a3b5b53f23536e01314f36aa46fd5b2d8baba6",
+ "url": "https://github.com/OpenAdaptAI/openadapt-capture/tree/v1.2.2"
+ },
"artifacts": [
{
"type": "bdist_wheel",
@@ -64,11 +389,116 @@
}
]
},
+ "privacy": {
+ "package": "openadapt-privacy",
+ "version": "1.0.1",
+ "source": "pypi",
+ "requires_python": ">=3.10",
+ "requires": {},
+ "dependency_constraints": [],
+ "provenance": {
+ "repository": "OpenAdaptAI/openadapt-privacy",
+ "release_ref": "v1.0.1",
+ "commit": "741e1546aab65676f5e5811c306fc77f6131824d",
+ "tree": "ca7db739ca7c14f0b25078c13ed5e1c7566d12a6",
+ "url": "https://github.com/OpenAdaptAI/openadapt-privacy/tree/v1.0.1"
+ },
+ "artifacts": [
+ {
+ "type": "bdist_wheel",
+ "filename": "openadapt_privacy-1.0.1-py3-none-any.whl",
+ "url": "https://files.pythonhosted.org/packages/1d/55/548738d41ea72a3607bdfa7f335318a0d4943eef901181fb1ed94f4a206b/openadapt_privacy-1.0.1-py3-none-any.whl",
+ "sha256": "0109127838e854b22a2e90a911f0b83812d32dd1e7b8340faf8ebdb69ef2a478"
+ },
+ {
+ "type": "sdist",
+ "filename": "openadapt_privacy-1.0.1.tar.gz",
+ "url": "https://files.pythonhosted.org/packages/1e/78/5a5b73d6541ecda9dce6cf96e8eedd9c43aa0ab4140011d0a089eb9d882e/openadapt_privacy-1.0.1.tar.gz",
+ "sha256": "e9bc90d7a8306088341d83432faf547c16f6bc141c4776e96359571d93923a27"
+ }
+ ]
+ },
+ "types": {
+ "package": "openadapt-types",
+ "version": "0.6.4",
+ "source": "pypi",
+ "requires_python": ">=3.10",
+ "requires": {},
+ "dependency_constraints": [],
+ "provenance": {
+ "repository": "OpenAdaptAI/openadapt-types",
+ "release_ref": "v0.6.4",
+ "commit": "a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c",
+ "tree": "abdaad39abe87521168c26b12fab5c2ad2fe5717",
+ "url": "https://github.com/OpenAdaptAI/openadapt-types/tree/v0.6.4"
+ },
+ "artifacts": [
+ {
+ "type": "bdist_wheel",
+ "filename": "openadapt_types-0.6.4-py3-none-any.whl",
+ "url": "https://files.pythonhosted.org/packages/cb/f1/420b62271a662b1ccab7a11b28e0f7ab51919a3deeab175161467e0926a7/openadapt_types-0.6.4-py3-none-any.whl",
+ "sha256": "115e5040bf5a03cbe68a6f5d9b5a412d48432b46e546cded9f5e977d76687837"
+ },
+ {
+ "type": "sdist",
+ "filename": "openadapt_types-0.6.4.tar.gz",
+ "url": "https://files.pythonhosted.org/packages/d8/c4/9ae01c9ffad380554a8ab349296c35cbf7995625417a334f86806965f0d7/openadapt_types-0.6.4.tar.gz",
+ "sha256": "3e4752c90daf989485f9d9f5f364643a8a6bcca9e06a934b7b7598cdf145b8d3"
+ }
+ ]
+ },
"desktop": {
"package": "openadapt-desktop",
"version": "0.14.0",
"source": "pypi",
"requires_python": ">=3.11",
+ "requires": {
+ "openadapt-capture": [
+ ">=1.1.0"
+ ],
+ "openadapt-flow": [
+ "==1.23.0"
+ ],
+ "openadapt-privacy": [
+ ">=1.0.0"
+ ],
+ "openadapt-types": [
+ "==0.5.0"
+ ]
+ },
+ "dependency_constraints": [
+ {
+ "package": "openadapt-capture",
+ "requires": ">=1.1.0",
+ "extras": null,
+ "marker": null
+ },
+ {
+ "package": "openadapt-flow",
+ "requires": "==1.23.0",
+ "extras": null,
+ "marker": "extra == \"build\""
+ },
+ {
+ "package": "openadapt-privacy",
+ "requires": ">=1.0.0",
+ "extras": null,
+ "marker": null
+ },
+ {
+ "package": "openadapt-types",
+ "requires": "==0.5.0",
+ "extras": null,
+ "marker": null
+ }
+ ],
+ "provenance": {
+ "repository": "OpenAdaptAI/openadapt-desktop",
+ "release_ref": "desktop-v0.14.0",
+ "commit": "778c8384369c75b31c24c1de92c9597c0e09b469",
+ "tree": "f8b7260f9d459f1d4a3a9640c501d8125d8f353a",
+ "url": "https://github.com/OpenAdaptAI/openadapt-desktop/tree/desktop-v0.14.0"
+ },
"artifacts": [
{
"type": "bdist_wheel",
@@ -83,6 +513,538 @@
"sha256": "44a50bd2c126524a76b4d0c0eb0bed9f24667940f735eed1dfed03dce7e9862b"
}
]
+ },
+ "agent": {
+ "package": "openadapt-agent",
+ "version": "2.0.1",
+ "source": "pypi",
+ "requires_python": "<3.13,>=3.10",
+ "requires": {
+ "openadapt-flow": [
+ "<2,>=1.18.1"
+ ]
+ },
+ "dependency_constraints": [
+ {
+ "package": "openadapt-flow",
+ "requires": "<2,>=1.18.1",
+ "extras": null,
+ "marker": null
+ }
+ ],
+ "provenance": {
+ "repository": "OpenAdaptAI/openadapt-agent",
+ "release_ref": "v2.0.1",
+ "commit": "d9f16b2946dfdd0228eeb2660047347df9dce8d8",
+ "tree": "26606c89b4294603b628f1b3aaa8283c45765326",
+ "url": "https://github.com/OpenAdaptAI/openadapt-agent/tree/v2.0.1"
+ },
+ "artifacts": [
+ {
+ "type": "bdist_wheel",
+ "filename": "openadapt_agent-2.0.1-py3-none-any.whl",
+ "url": "https://files.pythonhosted.org/packages/af/3a/5cf115ab9077a5ddd99e9e798e203c486dfcf009bbc39ab0d2b653a23a75/openadapt_agent-2.0.1-py3-none-any.whl",
+ "sha256": "5c5f6e3eca4c127d87b3affb8944223c0836916e0df57d0c6b8ba737b3bc8b5a"
+ },
+ {
+ "type": "sdist",
+ "filename": "openadapt_agent-2.0.1.tar.gz",
+ "url": "https://files.pythonhosted.org/packages/8c/71/f717f61f27878e0331b58e6f518d938964dab42a8a4dc1704417562a911a/openadapt_agent-2.0.1.tar.gz",
+ "sha256": "67e05b299a4f035a0482b3032decc151fafc22de9a12bb3d8f884f37ea4cc937"
+ }
+ ]
+ }
+ },
+ "runtime_units": {
+ "launcher_environment": {
+ "kind": "launcher-environment",
+ "version": "1.10.2",
+ "source_component": "launcher",
+ "entry_point": "openadapt",
+ "requirements": {
+ "openadapt": "==1.10.2",
+ "openadapt-flow": "<2.0.0,>=1.20.1"
+ },
+ "selected": {
+ "openadapt": "1.10.2",
+ "openadapt-flow": "1.25.1"
+ }
+ },
+ "customer_runner": {
+ "kind": "customer-controlled-runner",
+ "version": "1.25.1",
+ "source_component": "flow",
+ "entry_point": "openadapt-flow",
+ "requirements": {
+ "openadapt-flow": "==1.25.1",
+ "openadapt-types": "<0.7.0,>=0.6.0"
+ },
+ "selected": {
+ "openadapt-flow": "1.25.1",
+ "openadapt-types": "0.6.4"
+ }
+ },
+ "desktop_sidecar": {
+ "kind": "frozen-desktop-sidecar",
+ "version": "0.14.0",
+ "source_component": "desktop",
+ "source_ref": "desktop-v0.14.0",
+ "lock_url": "https://raw.githubusercontent.com/OpenAdaptAI/openadapt-desktop/desktop-v0.14.0/uv.lock",
+ "lock_sha256": "7561dc49b05f6181a38ae59b399f8162bd4ea10c05f0275b0bed9b5c072464cb",
+ "binary": "openadapt-engine",
+ "requirements": {
+ "openadapt-flow": "==1.23.0",
+ "openadapt-capture": ">=1.1.0",
+ "openadapt-privacy": ">=1.0.0",
+ "openadapt-types": "==0.5.0"
+ },
+ "resolved": {
+ "openadapt-capture": "1.1.1",
+ "openadapt-flow": "1.23.0",
+ "openadapt-privacy": "1.0.0",
+ "openadapt-types": "0.5.0"
+ }
+ },
+ "agent_bridge": {
+ "kind": "governed-agent-bridge",
+ "version": "2.0.1",
+ "source_component": "agent",
+ "entry_point": "openadapt-agent",
+ "requirements": {
+ "openadapt-agent": "==2.0.1",
+ "openadapt-flow": "<2,>=1.18.1"
+ },
+ "selected": {
+ "openadapt-agent": "2.0.1",
+ "openadapt-flow": "1.25.1"
+ }
+ }
+ },
+ "dependency_edges": [
+ {
+ "scope": "desktop-sidecar-lock",
+ "source_role": "desktop",
+ "source_package": "openadapt-desktop",
+ "source_version": "0.14.0",
+ "target_role": "capture",
+ "target_package": "openadapt-capture",
+ "requires": ">=1.1.0",
+ "resolved": "1.1.1",
+ "activations": [
+ {
+ "extras": null,
+ "marker": null
+ }
+ ]
+ },
+ {
+ "scope": "desktop-sidecar-lock",
+ "source_role": "desktop",
+ "source_package": "openadapt-desktop",
+ "source_version": "0.14.0",
+ "target_role": "flow",
+ "target_package": "openadapt-flow",
+ "requires": "==1.23.0",
+ "resolved": "1.23.0",
+ "activations": [
+ {
+ "extras": null,
+ "marker": "extra == \"build\""
+ }
+ ]
+ },
+ {
+ "scope": "desktop-sidecar-lock",
+ "source_role": "desktop",
+ "source_package": "openadapt-desktop",
+ "source_version": "0.14.0",
+ "target_role": "privacy",
+ "target_package": "openadapt-privacy",
+ "requires": ">=1.0.0",
+ "resolved": "1.0.0",
+ "activations": [
+ {
+ "extras": null,
+ "marker": null
+ }
+ ]
+ },
+ {
+ "scope": "desktop-sidecar-lock",
+ "source_role": "desktop",
+ "source_package": "openadapt-desktop",
+ "source_version": "0.14.0",
+ "target_role": "types",
+ "target_package": "openadapt-types",
+ "requires": "==0.5.0",
+ "resolved": "0.5.0",
+ "activations": [
+ {
+ "extras": null,
+ "marker": null
+ }
+ ]
+ },
+ {
+ "scope": "selected-platform",
+ "source_role": "agent",
+ "source_package": "openadapt-agent",
+ "source_version": "2.0.1",
+ "target_role": "flow",
+ "target_package": "openadapt-flow",
+ "requires": "<2,>=1.18.1",
+ "resolved": "1.25.1",
+ "activations": [
+ {
+ "extras": null,
+ "marker": null
+ }
+ ]
+ },
+ {
+ "scope": "selected-platform",
+ "source_role": "capture",
+ "source_package": "openadapt-capture",
+ "source_version": "1.2.2",
+ "target_role": "privacy",
+ "target_package": "openadapt-privacy",
+ "requires": ">=0.1.0",
+ "resolved": "1.0.1",
+ "activations": [
+ {
+ "extras": null,
+ "marker": "extra == \"all\""
+ },
+ {
+ "extras": null,
+ "marker": "extra == \"privacy\""
+ }
+ ]
+ },
+ {
+ "scope": "selected-platform",
+ "source_role": "flow",
+ "source_package": "openadapt-flow",
+ "source_version": "1.25.1",
+ "target_role": "capture",
+ "target_package": "openadapt-capture",
+ "requires": ">=1.2.0",
+ "resolved": "1.2.2",
+ "activations": [
+ {
+ "extras": null,
+ "marker": "extra == \"capture\""
+ }
+ ]
+ },
+ {
+ "scope": "selected-platform",
+ "source_role": "flow",
+ "source_package": "openadapt-flow",
+ "source_version": "1.25.1",
+ "target_role": "privacy",
+ "target_package": "openadapt-privacy",
+ "requires": ">=1.0.0",
+ "resolved": "1.0.1",
+ "activations": [
+ {
+ "extras": "presidio",
+ "marker": "extra == \"privacy\""
+ }
+ ]
+ },
+ {
+ "scope": "selected-platform",
+ "source_role": "flow",
+ "source_package": "openadapt-flow",
+ "source_version": "1.25.1",
+ "target_role": "types",
+ "target_package": "openadapt-types",
+ "requires": "<0.7.0,>=0.6.0",
+ "resolved": "0.6.4",
+ "activations": [
+ {
+ "extras": null,
+ "marker": "extra == \"console\""
+ },
+ {
+ "extras": null,
+ "marker": "extra == \"dev\""
+ },
+ {
+ "extras": null,
+ "marker": "extra == \"interop\""
+ }
+ ]
+ },
+ {
+ "scope": "selected-platform",
+ "source_role": "launcher",
+ "source_package": "openadapt",
+ "source_version": "1.10.2",
+ "target_role": "capture",
+ "target_package": "openadapt-capture",
+ "requires": "<2.0.0,>=1.0.4",
+ "resolved": "1.2.2",
+ "activations": [
+ {
+ "extras": null,
+ "marker": "extra == \"all\""
+ },
+ {
+ "extras": null,
+ "marker": "extra == \"capture\""
+ },
+ {
+ "extras": null,
+ "marker": "extra == \"core\""
+ }
+ ]
+ },
+ {
+ "scope": "selected-platform",
+ "source_role": "launcher",
+ "source_package": "openadapt",
+ "source_version": "1.10.2",
+ "target_role": "flow",
+ "target_package": "openadapt-flow",
+ "requires": "<2.0.0,>=1.20.1",
+ "resolved": "1.25.1",
+ "activations": [
+ {
+ "extras": null,
+ "marker": "extra == \"all\""
+ },
+ {
+ "extras": null,
+ "marker": "extra == \"flow\""
+ },
+ {
+ "extras": "capture",
+ "marker": "extra == \"all\""
+ },
+ {
+ "extras": "capture",
+ "marker": "extra == \"capture\""
+ },
+ {
+ "extras": "capture",
+ "marker": "extra == \"core\""
+ },
+ {
+ "extras": "hosted",
+ "marker": null
+ },
+ {
+ "extras": "linux",
+ "marker": "sys_platform == \"linux\" and extra == \"all\""
+ },
+ {
+ "extras": "linux",
+ "marker": "sys_platform == \"linux\" and extra == \"linux\""
+ },
+ {
+ "extras": "macos",
+ "marker": "sys_platform == \"darwin\" and extra == \"all\""
+ },
+ {
+ "extras": "macos",
+ "marker": "sys_platform == \"darwin\" and extra == \"macos\""
+ },
+ {
+ "extras": "privacy",
+ "marker": "extra == \"all\""
+ },
+ {
+ "extras": "privacy",
+ "marker": "extra == \"privacy\""
+ },
+ {
+ "extras": "rdp",
+ "marker": "extra == \"all\""
+ },
+ {
+ "extras": "rdp",
+ "marker": "extra == \"rdp\""
+ },
+ {
+ "extras": "windows",
+ "marker": "extra == \"all\""
+ },
+ {
+ "extras": "windows",
+ "marker": "extra == \"windows\""
+ }
+ ]
+ }
+ ],
+ "schema_compatibility": {
+ "workflow_bundle": {
+ "source_component": "flow",
+ "schema_field": "schema_version",
+ "format": "integer",
+ "accepted_versions": [
+ 1,
+ 2
+ ],
+ "minimum": 1,
+ "maximum": 2,
+ "source_commit": "b3966d01f0b574c07d7a46429f60769f859b6a23",
+ "source_files": [
+ {
+ "path": "openadapt_flow/bundle_validation.py",
+ "url": "https://github.com/OpenAdaptAI/openadapt-flow/blob/b3966d01f0b574c07d7a46429f60769f859b6a23/openadapt_flow/bundle_validation.py",
+ "git_blob": "1c85315df8ed877695ad37c97be1a6b10a594bda",
+ "sha256": "1163fe77d786b0a5481d4d4f5f82d5bd9e95c0808586951b1fcd50e6f8a8eb63"
+ },
+ {
+ "path": "openadapt_flow/ir.py",
+ "url": "https://github.com/OpenAdaptAI/openadapt-flow/blob/b3966d01f0b574c07d7a46429f60769f859b6a23/openadapt_flow/ir.py",
+ "git_blob": "e494d72ed0a58ce884ea7726d79c59e52e2a33ec",
+ "sha256": "f55d9a078b208a5204e7b72ddf1c90228a5ce772bb5350323525e6f6593bc614"
+ }
+ ]
+ },
+ "capture_structural_observation": {
+ "source_component": "capture",
+ "schema_prefix": "openadapt.capture.structural-observation/v",
+ "accepted_versions": [
+ 1
+ ],
+ "minimum": 1,
+ "maximum": 1,
+ "source_commit": "06ade22948c12dcc6608bd54b5cca5c9c306e77c",
+ "source_files": [
+ {
+ "path": "openadapt_capture/structural.py",
+ "url": "https://github.com/OpenAdaptAI/openadapt-capture/blob/06ade22948c12dcc6608bd54b5cca5c9c306e77c/openadapt_capture/structural.py",
+ "git_blob": "8301cf13eebf5762d69d1a187b9b7d15e1a0d14f",
+ "sha256": "8f8bce37148a3357ace147c46a7c45e6d5501b70622b24b1ac2490f2def304fc"
+ }
+ ]
+ },
+ "control_overlay_frame": {
+ "source_component": "types",
+ "schema_prefix": "openadapt.control-overlay-frame/v",
+ "accepted_versions": [
+ 1,
+ 2
+ ],
+ "minimum": 1,
+ "maximum": 2,
+ "source_commit": "a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c",
+ "source_files": [
+ {
+ "path": "openadapt_types/control_overlay.py",
+ "url": "https://github.com/OpenAdaptAI/openadapt-types/blob/a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c/openadapt_types/control_overlay.py",
+ "git_blob": "8eaa7b82ae2606fa59943218e3839efe19ebc54b",
+ "sha256": "3aa01f89b43e12478c9e2ff1a9e61689397370691d15247ded9dcd5b05242790"
+ },
+ {
+ "path": "openadapt_types/control_overlay_tracking.py",
+ "url": "https://github.com/OpenAdaptAI/openadapt-types/blob/a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c/openadapt_types/control_overlay_tracking.py",
+ "git_blob": "222b9bade823332df869f496bc035aa31faed5a2",
+ "sha256": "af8d934f0572c5f4e9c63ba720d4a1d0e73c8cdf3f7b87cc4562c01a8a551755"
+ }
+ ]
+ },
+ "control_overlay_timeline": {
+ "source_component": "types",
+ "schema_prefix": "openadapt.control-overlay-timeline/v",
+ "accepted_versions": [
+ 1,
+ 2
+ ],
+ "minimum": 1,
+ "maximum": 2,
+ "source_commit": "a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c",
+ "source_files": [
+ {
+ "path": "openadapt_types/control_overlay.py",
+ "url": "https://github.com/OpenAdaptAI/openadapt-types/blob/a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c/openadapt_types/control_overlay.py",
+ "git_blob": "8eaa7b82ae2606fa59943218e3839efe19ebc54b",
+ "sha256": "3aa01f89b43e12478c9e2ff1a9e61689397370691d15247ded9dcd5b05242790"
+ },
+ {
+ "path": "openadapt_types/control_overlay_tracking.py",
+ "url": "https://github.com/OpenAdaptAI/openadapt-types/blob/a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c/openadapt_types/control_overlay_tracking.py",
+ "git_blob": "222b9bade823332df869f496bc035aa31faed5a2",
+ "sha256": "af8d934f0572c5f4e9c63ba720d4a1d0e73c8cdf3f7b87cc4562c01a8a551755"
+ }
+ ]
+ },
+ "human_decision_task": {
+ "source_component": "types",
+ "schema_prefix": "openadapt.human-decision-task/v",
+ "accepted_versions": [
+ 1
+ ],
+ "minimum": 1,
+ "maximum": 1,
+ "source_commit": "a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c",
+ "source_files": [
+ {
+ "path": "openadapt_types/human_decision.py",
+ "url": "https://github.com/OpenAdaptAI/openadapt-types/blob/a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c/openadapt_types/human_decision.py",
+ "git_blob": "69927111405189197d7e01bbddc5694e3235e081",
+ "sha256": "30aedcdb7bf7291d7b0f061703a1feecd672c9a0529c539bdb09c47d2bb1271b"
+ }
+ ]
+ },
+ "human_decision_receipt": {
+ "source_component": "types",
+ "schema_prefix": "openadapt.human-decision-receipt/v",
+ "accepted_versions": [
+ 1
+ ],
+ "minimum": 1,
+ "maximum": 1,
+ "source_commit": "a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c",
+ "source_files": [
+ {
+ "path": "openadapt_types/human_decision.py",
+ "url": "https://github.com/OpenAdaptAI/openadapt-types/blob/a93c12e29d9cb50d7c5b8e0e4c816cf3f4fe305c/openadapt_types/human_decision.py",
+ "git_blob": "69927111405189197d7e01bbddc5694e3235e081",
+ "sha256": "30aedcdb7bf7291d7b0f061703a1feecd672c9a0529c539bdb09c47d2bb1271b"
+ }
+ ]
+ },
+ "remote_decision_projection": {
+ "source_component": "flow",
+ "schema_prefix": "openadapt.remote-decision-projection/v",
+ "accepted_versions": [
+ 1
+ ],
+ "minimum": 1,
+ "maximum": 1,
+ "source_commit": "b3966d01f0b574c07d7a46429f60769f859b6a23",
+ "source_files": [
+ {
+ "path": "openadapt_flow/console/human_decisions.py",
+ "url": "https://github.com/OpenAdaptAI/openadapt-flow/blob/b3966d01f0b574c07d7a46429f60769f859b6a23/openadapt_flow/console/human_decisions.py",
+ "git_blob": "3bea46b0b122d2288b15678ee399cf2e2d1b11ed",
+ "sha256": "5956b9677dfe6d9fca43be8b0201ea707c7e303ac9c1987b1ef24336452dfaf5"
+ }
+ ]
+ },
+ "runtime_validation": {
+ "source_component": "flow",
+ "schema_prefix": "openadapt.runtime-validation/v",
+ "accepted_versions": [
+ 1,
+ 2
+ ],
+ "minimum": 1,
+ "maximum": 2,
+ "source_commit": "b3966d01f0b574c07d7a46429f60769f859b6a23",
+ "source_files": [
+ {
+ "path": "openadapt_flow/runtime_validation.py",
+ "url": "https://github.com/OpenAdaptAI/openadapt-flow/blob/b3966d01f0b574c07d7a46429f60769f859b6a23/openadapt_flow/runtime_validation.py",
+ "git_blob": "dde82daa748fcf05e974f4f6b0700233e266796f",
+ "sha256": "5f6e81e1c170419f0b755b8cb1cbdd61064a4b02b37439c65fbd027285d6a4b2"
+ }
+ ]
}
},
"compatibility": {
@@ -111,6 +1073,11 @@
]
}
},
+ "compatibility_status": {
+ "status": "dependency-compatible",
+ "basis": "published-metadata-and-desktop-lock",
+ "failures": []
+ },
"supported_os": [
"windows",
"macos",
@@ -163,16 +1130,26 @@
{
"id": "flow-public-demo-evidence-packs",
"kind": "evidence-pack-collection",
+ "source_component": "flow",
"repository": "OpenAdaptAI/openadapt-flow",
"path": "public-demo/evidence-packs",
- "description": "Versioned public evidence packs (for example mockmed-triage-v3) with per-pack manifest.json files: synthetic-patient replay evidence including fault-injection trials."
+ "description": "Versioned public evidence packs with per-pack manifest files, replay evidence, and fault-case results.",
+ "source_commit": "b3966d01f0b574c07d7a46429f60769f859b6a23",
+ "source_object_type": "tree",
+ "source_object_sha": "2eac2252f62349e873b461866546dd0295169a19",
+ "url": "https://github.com/OpenAdaptAI/openadapt-flow/tree/b3966d01f0b574c07d7a46429f60769f859b6a23/public-demo/evidence-packs"
},
{
"id": "flow-effectbench-task-pack",
"kind": "benchmark-manifest",
+ "source_component": "flow",
"repository": "OpenAdaptAI/openadapt-flow",
"path": "benchmark/effectbench/task_pack/manifest.json",
- "description": "Effect-verification benchmark task pack manifest used by the flow qualification harness."
+ "description": "Effect-verification benchmark task pack manifest used by the flow qualification harness.",
+ "source_commit": "b3966d01f0b574c07d7a46429f60769f859b6a23",
+ "source_object_type": "blob",
+ "source_object_sha": "1c568d2289342e10e3556c5c46cd08a0e370ac84",
+ "url": "https://github.com/OpenAdaptAI/openadapt-flow/blob/b3966d01f0b574c07d7a46429f60769f859b6a23/benchmark/effectbench/task_pack/manifest.json"
}
],
"signature": {
diff --git a/scripts/generate_platform_manifest.py b/scripts/generate_platform_manifest.py
index 1ce294316..7b1b0b1e2 100644
--- a/scripts/generate_platform_manifest.py
+++ b/scripts/generate_platform_manifest.py
@@ -8,10 +8,12 @@
Design rules (see docs/platform-manifest.md):
-* Versions, artifact URLs, and sha256 digests are read from the REAL published
- sources (the PyPI JSON API). Nothing is invented or hand-typed. If a source
- is unreachable or disagrees with this repository's ``pyproject.toml``, the
- script fails loudly instead of guessing.
+* Versions, dependency ranges, artifact URLs, and sha256 digests are read from
+ the REAL published sources (the PyPI JSON API). The frozen Desktop sidecar
+ closure comes from the exact native release tag's locked environment.
+ Nothing is invented or hand-typed. If a source is unreachable or disagrees
+ with this repository's ``pyproject.toml``, the script fails loudly instead
+ of guessing.
* Substrate/driver availability and the release channel are read from the
canonical public status document, https://openadapt.ai/status.json, which is
maintained in openadapt-web and already drives status-aware public surfaces.
@@ -31,13 +33,20 @@
from __future__ import annotations
import argparse
+import ast
+import base64
import datetime as _dt
+import hashlib
import json
+import os
+import re
import sys
import urllib.error
import urllib.request
from pathlib import Path
+from render_platform_versions import render_markdown
+
try:
import tomllib
except ModuleNotFoundError: # pragma: no cover - Python < 3.11
@@ -45,20 +54,117 @@
ROOT = Path(__file__).resolve().parents[1]
-SCHEMA_VERSION = "1.0.0"
+SCHEMA_VERSION = "1.3.0"
MANIFEST_KIND = "openadapt-platform-release-manifest"
STATUS_URL = "https://openadapt.ai/status.json"
PYPI_URL_TEMPLATE = "https://pypi.org/pypi/{package}/json"
+PYPI_VERSION_URL_TEMPLATE = "https://pypi.org/pypi/{package}/{version}/json"
+GITHUB_COMMIT_URL_TEMPLATE = (
+ "https://api.github.com/repos/{repository}/commits/{release_ref}"
+)
+GITHUB_CONTENT_URL_TEMPLATE = (
+ "https://api.github.com/repos/{repository}/contents/{path}?ref={commit}"
+)
+GITHUB_TREE_URL_TEMPLATE = (
+ "https://api.github.com/repos/{repository}/git/trees/{tree}?recursive=1"
+)
+DESKTOP_NATIVE_LOCK_URL_TEMPLATE = (
+ "https://raw.githubusercontent.com/OpenAdaptAI/openadapt-desktop/"
+ "desktop-v{version}/uv.lock"
+)
HTTP_TIMEOUT_SECONDS = 30
# The platform components this manifest pins. The launcher (this repository)
-# is the integration point; the others are the runtime, the native recorder,
-# and the desktop shell. All four publish to PyPI.
+# is the integration point; the others are the runtime, native recorder,
+# privacy boundary, shared schemas, desktop shell, and governed agent bridge.
+# All seven publish to PyPI.
COMPONENTS: dict[str, str] = {
"launcher": "openadapt",
"flow": "openadapt-flow",
"capture": "openadapt-capture",
+ "privacy": "openadapt-privacy",
+ "types": "openadapt-types",
"desktop": "openadapt-desktop",
+ "agent": "openadapt-agent",
+}
+
+COMPONENT_REPOSITORIES = {
+ role: f"OpenAdaptAI/{package}"
+ for role, package in COMPONENTS.items()
+}
+COMPONENT_REPOSITORIES["launcher"] = "OpenAdaptAI/OpenAdapt"
+
+RUNTIME_UNIT_NAMES = {
+ "launcher_environment",
+ "customer_runner",
+ "desktop_sidecar",
+ "agent_bridge",
+}
+
+# Cross-component schema probes. The paths and prefixes identify public
+# protocol surfaces. Accepted versions are extracted from the exact selected
+# release commits; this table never declares a version itself. An optional
+# probe appears in the BOM only after its source file exists in a published
+# release. This permits an ordered release train without a false future claim.
+SCHEMA_PROBES = {
+ "workflow_bundle": {
+ "source_component": "flow",
+ "schema_field": "schema_version",
+ "format": "integer",
+ "sources": {
+ "openadapt_flow/ir.py": [r"(?m)^SCHEMA_VERSION\s*=\s*(\d+)\s*$"],
+ "openadapt_flow/bundle_validation.py": [
+ r'data\.get\("schema_version",\s*(\d+)\)'
+ ],
+ },
+ },
+ "capture_structural_observation": {
+ "source_component": "capture",
+ "schema_prefix": "openadapt.capture.structural-observation/v",
+ "sources": {"openadapt_capture/structural.py": []},
+ },
+ "control_overlay_frame": {
+ "source_component": "types",
+ "schema_prefix": "openadapt.control-overlay-frame/v",
+ "sources": {
+ "openadapt_types/control_overlay.py": [],
+ "openadapt_types/control_overlay_tracking.py": [],
+ },
+ },
+ "control_overlay_timeline": {
+ "source_component": "types",
+ "schema_prefix": "openadapt.control-overlay-timeline/v",
+ "sources": {
+ "openadapt_types/control_overlay.py": [],
+ "openadapt_types/control_overlay_tracking.py": [],
+ },
+ },
+ "human_decision_task": {
+ "source_component": "types",
+ "schema_prefix": "openadapt.human-decision-task/v",
+ "sources": {"openadapt_types/human_decision.py": []},
+ },
+ "human_decision_receipt": {
+ "source_component": "types",
+ "schema_prefix": "openadapt.human-decision-receipt/v",
+ "sources": {"openadapt_types/human_decision.py": []},
+ },
+ "human_decision_relay": {
+ "source_component": "flow",
+ "schema_prefix": "openadapt.human-decision-relay/v",
+ "optional": True,
+ "sources": {"openadapt_flow/console/decision_relay.py": []},
+ },
+ "remote_decision_projection": {
+ "source_component": "flow",
+ "schema_prefix": "openadapt.remote-decision-projection/v",
+ "sources": {"openadapt_flow/console/human_decisions.py": []},
+ },
+ "runtime_validation": {
+ "source_component": "flow",
+ "schema_prefix": "openadapt.runtime-validation/v",
+ "sources": {"openadapt_flow/runtime_validation.py": []},
+ },
}
# Operating systems the launcher supports, mirroring pyproject extras
@@ -82,17 +188,18 @@
{
"id": "flow-public-demo-evidence-packs",
"kind": "evidence-pack-collection",
+ "source_component": "flow",
"repository": "OpenAdaptAI/openadapt-flow",
"path": "public-demo/evidence-packs",
"description": (
- "Versioned public evidence packs (for example mockmed-triage-v3) "
- "with per-pack manifest.json files: synthetic-patient replay "
- "evidence including fault-injection trials."
+ "Versioned public evidence packs with per-pack manifest files, "
+ "replay evidence, and fault-case results."
),
},
{
"id": "flow-effectbench-task-pack",
"kind": "benchmark-manifest",
+ "source_component": "flow",
"repository": "OpenAdaptAI/openadapt-flow",
"path": "benchmark/effectbench/task_pack/manifest.json",
"description": (
@@ -118,8 +225,12 @@ class DriftError(RuntimeError):
def _fetch_json(url: str) -> dict:
+ headers = {"User-Agent": "openadapt-platform-manifest-generator"}
+ token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
+ if token and url.startswith("https://api.github.com/"):
+ headers["Authorization"] = f"Bearer {token}"
request = urllib.request.Request(
- url, headers={"User-Agent": "openadapt-platform-manifest-generator"}
+ url, headers=headers
)
try:
with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_SECONDS) as resp:
@@ -132,9 +243,278 @@ def _fetch_json(url: str) -> dict:
) from exc
-def _pypi_component(package: str) -> dict:
- doc = _fetch_json(PYPI_URL_TEMPLATE.format(package=package))
+def _fetch_text(url: str) -> str:
+ request = urllib.request.Request(
+ url, headers={"User-Agent": "openadapt-platform-manifest-generator"}
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_SECONDS) as resp:
+ return resp.read().decode("utf-8")
+ except (urllib.error.URLError, UnicodeDecodeError, TimeoutError) as exc:
+ raise DriftError(
+ f"FATAL: could not fetch {url}: {exc}. The frozen sidecar closure "
+ "must come from its exact public release tag; refusing to guess."
+ ) from exc
+
+
+def _fetch_json_optional(url: str) -> dict | list | None:
+ """Fetch JSON, returning ``None`` only for a real HTTP 404."""
+
+ headers = {"User-Agent": "openadapt-platform-manifest-generator"}
+ token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
+ if token and url.startswith("https://api.github.com/"):
+ headers["Authorization"] = f"Bearer {token}"
+ request = urllib.request.Request(url, headers=headers)
+ try:
+ with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_SECONDS) as resp:
+ return json.load(resp)
+ except urllib.error.HTTPError as exc:
+ if exc.code == 404:
+ return None
+ raise DriftError(f"FATAL: could not fetch {url}: {exc}") from exc
+ except (urllib.error.URLError, TimeoutError) as exc:
+ raise DriftError(f"FATAL: could not fetch {url}: {exc}") from exc
+
+
+def _github_source_file(
+ repository: str,
+ commit: str,
+ path: str,
+ *,
+ optional: bool = False,
+) -> tuple[str, dict] | None:
+ """Read and bind one source file at an exact public commit."""
+
+ url = GITHUB_CONTENT_URL_TEMPLATE.format(
+ repository=repository, path=path, commit=commit
+ )
+ doc = _fetch_json_optional(url)
+ if doc is None:
+ if optional:
+ return None
+ raise DriftError(
+ f"FATAL: required schema source {repository}@{commit}:{path} is missing."
+ )
+ if not isinstance(doc, dict) or doc.get("type") != "file":
+ raise DriftError(
+ f"FATAL: schema source {repository}@{commit}:{path} is not a file."
+ )
+ try:
+ raw = base64.b64decode(doc["content"], validate=False)
+ text = raw.decode("utf-8")
+ except (KeyError, ValueError, UnicodeDecodeError) as exc:
+ raise DriftError(
+ f"FATAL: schema source {repository}@{commit}:{path} has no valid "
+ "UTF-8 GitHub content."
+ ) from exc
+ blob_sha = doc.get("sha")
+ if not isinstance(blob_sha, str) or not re.fullmatch(r"[0-9a-f]{40}", blob_sha):
+ raise DriftError(
+ f"FATAL: schema source {repository}@{commit}:{path} has no blob SHA."
+ )
+ return text, {
+ "path": path,
+ "url": f"https://github.com/{repository}/blob/{commit}/{path}",
+ "git_blob": blob_sha,
+ "sha256": hashlib.sha256(raw).hexdigest(),
+ }
+
+
+def _literal_schema_strings(source: str) -> set[str]:
+ """Return literal strings from source without importing remote code."""
+
+ try:
+ tree = ast.parse(source)
+ except SyntaxError as exc:
+ raise DriftError(f"FATAL: schema source is not valid Python: {exc}") from exc
+ return {
+ node.value
+ for node in ast.walk(tree)
+ if isinstance(node, ast.Constant) and isinstance(node.value, str)
+ }
+
+
+def _schema_compatibility(components: dict[str, dict]) -> dict[str, dict]:
+ """Derive schema versions from exact selected public release sources."""
+
+ schemas: dict[str, dict] = {}
+ cache: dict[tuple[str, str, str], tuple[str, dict] | None] = {}
+ for name, probe in SCHEMA_PROBES.items():
+ role = str(probe["source_component"])
+ component = components[role]
+ repository = component["provenance"]["repository"]
+ commit = component["provenance"]["commit"]
+ source_rows = []
+ versions: set[int] = set()
+ optional = bool(probe.get("optional"))
+ missing_optional = False
+ for path, patterns in probe["sources"].items():
+ key = (repository, commit, path)
+ if key not in cache:
+ cache[key] = _github_source_file(
+ repository, commit, path, optional=optional
+ )
+ source_file = cache[key]
+ if source_file is None:
+ missing_optional = True
+ continue
+ source, source_row = source_file
+ source_rows.append(source_row)
+ if patterns:
+ for pattern in patterns:
+ versions.update(int(value) for value in re.findall(pattern, source))
+ else:
+ prefix = str(probe["schema_prefix"])
+ for value in _literal_schema_strings(source):
+ match = re.fullmatch(re.escape(prefix) + r"(\d+)", value)
+ if match:
+ versions.add(int(match.group(1)))
+ if missing_optional and not versions:
+ continue
+ if not versions:
+ raise DriftError(
+ f"FATAL: exact {role} release sources declare no versions for "
+ f"schema contract {name}."
+ )
+ accepted = sorted(versions)
+ row = {
+ key: value
+ for key, value in probe.items()
+ if key not in {"sources", "optional"}
+ }
+ row.update(
+ {
+ "accepted_versions": accepted,
+ "minimum": accepted[0],
+ "maximum": accepted[-1],
+ "source_commit": commit,
+ "source_files": sorted(source_rows, key=lambda item: item["path"]),
+ }
+ )
+ schemas[name] = row
+ return schemas
+
+
+def _openadapt_requirements(requirements: list[str] | None) -> dict[str, list[str]]:
+ """Return normalized openadapt-* dependency specifiers from PEP 508 text."""
+
+ pins: dict[str, set[str]] = {}
+ for requirement in requirements or []:
+ bare = requirement.split(";", 1)[0].strip()
+ for index, char in enumerate(bare):
+ if char in "><=!~ [":
+ name = bare[:index]
+ rest = bare[index:]
+ break
+ else:
+ name, rest = bare, ""
+ if not name.startswith("openadapt"):
+ continue
+ specifier = rest
+ if specifier.startswith("["):
+ close = specifier.find("]")
+ if close < 0:
+ raise DriftError(
+ f"FATAL: malformed dependency requirement: {requirement}"
+ )
+ specifier = specifier[close + 1 :]
+ specifier = specifier.strip()
+ if specifier:
+ pins.setdefault(name, set()).add(specifier)
+ return {name: sorted(specs) for name, specs in sorted(pins.items())}
+
+
+def _openadapt_dependency_constraints(
+ requirements: list[str] | None,
+) -> list[dict[str, str | None]]:
+ """Preserve the activation marker on each OpenAdapt dependency edge."""
+
+ constraints: list[dict[str, str | None]] = []
+ for requirement in requirements or []:
+ bare, separator, marker = requirement.partition(";")
+ bare = bare.strip()
+ for index, char in enumerate(bare):
+ if char in "><=!~ [":
+ name = bare[:index]
+ rest = bare[index:]
+ break
+ else:
+ name, rest = bare, ""
+ if not name.startswith("openadapt"):
+ continue
+ extras = None
+ if rest.startswith("["):
+ close = rest.find("]")
+ if close < 0:
+ raise DriftError(
+ f"FATAL: malformed dependency requirement: {requirement}"
+ )
+ extras = rest[1:close]
+ rest = rest[close + 1 :]
+ specifier = rest.strip()
+ if not specifier:
+ continue
+ constraints.append(
+ {
+ "package": name,
+ "requires": specifier,
+ "extras": extras,
+ "marker": marker.strip() if separator else None,
+ }
+ )
+ return sorted(
+ constraints,
+ key=lambda row: (
+ str(row["package"]),
+ str(row["requires"]),
+ str(row["extras"]),
+ str(row["marker"]),
+ ),
+ )
+
+
+def _release_ref(role: str, version: str) -> str:
+ prefix = "desktop-v" if role == "desktop" else "v"
+ return f"{prefix}{version}"
+
+
+def _source_provenance(role: str, version: str) -> dict:
+ repository = COMPONENT_REPOSITORIES[role]
+ release_ref = _release_ref(role, version)
+ url = GITHUB_COMMIT_URL_TEMPLATE.format(
+ repository=repository, release_ref=release_ref
+ )
+ doc = _fetch_json(url)
+ commit = doc.get("sha")
+ tree = doc.get("commit", {}).get("tree", {}).get("sha")
+ if not isinstance(commit, str) or not isinstance(tree, str):
+ raise DriftError(
+ f"FATAL: {url} did not resolve an exact commit and tree for "
+ f"{repository}@{release_ref}."
+ )
+ return {
+ "repository": repository,
+ "release_ref": release_ref,
+ "commit": commit,
+ "tree": tree,
+ "url": f"https://github.com/{repository}/tree/{release_ref}",
+ }
+
+
+def _pypi_component(role: str, requested_version: str | None = None) -> dict:
+ package = COMPONENTS[role]
+ url = (
+ PYPI_VERSION_URL_TEMPLATE.format(package=package, version=requested_version)
+ if requested_version
+ else PYPI_URL_TEMPLATE.format(package=package)
+ )
+ doc = _fetch_json(url)
version = doc["info"]["version"]
+ if requested_version is not None and version != requested_version:
+ raise DriftError(
+ f"FATAL: PyPI returned {package}=={version} for requested "
+ f"version {requested_version}."
+ )
artifacts = []
for url_entry in doc.get("urls", []):
artifacts.append(
@@ -154,6 +534,11 @@ def _pypi_component(package: str) -> dict:
"version": version,
"source": "pypi",
"requires_python": doc["info"].get("requires_python"),
+ "requires": _openadapt_requirements(doc["info"].get("requires_dist")),
+ "dependency_constraints": _openadapt_dependency_constraints(
+ doc["info"].get("requires_dist")
+ ),
+ "provenance": _source_provenance(role, version),
"artifacts": sorted(artifacts, key=lambda a: a["filename"]),
}
@@ -195,6 +580,269 @@ def _compatibility_from_pyproject() -> dict:
}
+def _release_tuple(version: str) -> tuple[int, ...] | None:
+ parts = version.split(".")
+ if not parts or not all(part.isdigit() for part in parts):
+ return None
+ return tuple(int(part) for part in parts)
+
+
+def _version_satisfies(version: str, specifier: str) -> bool:
+ actual = _release_tuple(version)
+ if actual is None:
+ return False
+ for raw_term in specifier.split(","):
+ term = raw_term.strip()
+ operator = next(
+ (
+ candidate
+ for candidate in (">=", "<=", "==", ">", "<")
+ if term.startswith(candidate)
+ ),
+ None,
+ )
+ if operator is None:
+ return False
+ expected = _release_tuple(term[len(operator) :].strip())
+ if expected is None:
+ return False
+ if operator == "==" and actual != expected:
+ return False
+ if operator == ">=" and actual < expected:
+ return False
+ if operator == "<=" and actual > expected:
+ return False
+ if operator == ">" and actual <= expected:
+ return False
+ if operator == "<" and actual >= expected:
+ return False
+ return True
+
+
+def _dependency_spec(component: dict, package: str) -> str:
+ specs = component.get("requires", {}).get(package, [])
+ if len(specs) != 1:
+ raise DriftError(
+ f"FATAL: {component.get('package')}=={component.get('version')} must "
+ f"declare exactly one {package} compatibility range; got {specs!r}."
+ )
+ return specs[0]
+
+
+def _runtime_unit_requirements(components: dict[str, dict]) -> dict[str, dict[str, str]]:
+ """Build compatibility contracts from the selected published metadata."""
+
+ return {
+ "launcher_environment": {
+ "openadapt": f"=={components['launcher']['version']}",
+ "openadapt-flow": _dependency_spec(
+ components["launcher"], "openadapt-flow"
+ ),
+ },
+ "customer_runner": {
+ "openadapt-flow": f"=={components['flow']['version']}",
+ "openadapt-types": _dependency_spec(
+ components["flow"], "openadapt-types"
+ ),
+ },
+ "desktop_sidecar": {
+ package: _dependency_spec(components["desktop"], package)
+ for package in (
+ "openadapt-flow",
+ "openadapt-capture",
+ "openadapt-privacy",
+ "openadapt-types",
+ )
+ },
+ "agent_bridge": {
+ "openadapt-agent": f"=={components['agent']['version']}",
+ "openadapt-flow": _dependency_spec(
+ components["agent"], "openadapt-flow"
+ ),
+ },
+ }
+
+
+def _desktop_sidecar_lock(
+ version: str, required_packages: set[str]
+) -> tuple[str, str, str, dict[str, str]]:
+ """Read the immutable package closure used by the native sidecar build."""
+
+ source_ref = f"desktop-v{version}"
+ url = DESKTOP_NATIVE_LOCK_URL_TEMPLATE.format(version=version)
+ lock_text = _fetch_text(url)
+ try:
+ lock = tomllib.loads(lock_text)
+ except tomllib.TOMLDecodeError as exc:
+ raise DriftError(f"FATAL: {url} is not valid TOML: {exc}") from exc
+
+ wanted = required_packages
+ resolved: dict[str, str] = {}
+ for package in lock.get("package", []):
+ name = package.get("name")
+ package_version = package.get("version")
+ if name not in wanted or not isinstance(package_version, str):
+ continue
+ if name in resolved and resolved[name] != package_version:
+ raise DriftError(
+ f"FATAL: {source_ref} resolves multiple {name} versions; "
+ "the sidecar closure is not exact."
+ )
+ resolved[name] = package_version
+ missing = sorted(wanted - set(resolved))
+ if missing:
+ raise DriftError(
+ f"FATAL: {source_ref} uv.lock omits required sidecar packages: "
+ + ", ".join(missing)
+ )
+ lock_sha256 = hashlib.sha256(lock_text.encode("utf-8")).hexdigest()
+ return source_ref, url, lock_sha256, dict(sorted(resolved.items()))
+
+
+def _runtime_units(components: dict[str, dict]) -> dict[str, dict]:
+ requirements = _runtime_unit_requirements(components)
+ sidecar_ref, sidecar_lock_url, sidecar_lock_sha256, sidecar_resolved = (
+ _desktop_sidecar_lock(
+ components["desktop"]["version"],
+ set(requirements["desktop_sidecar"]),
+ )
+ )
+ return {
+ "launcher_environment": {
+ "kind": "launcher-environment",
+ "version": components["launcher"]["version"],
+ "source_component": "launcher",
+ "entry_point": "openadapt",
+ "requirements": requirements["launcher_environment"],
+ "selected": {
+ "openadapt": components["launcher"]["version"],
+ "openadapt-flow": components["flow"]["version"],
+ },
+ },
+ "customer_runner": {
+ "kind": "customer-controlled-runner",
+ "version": components["flow"]["version"],
+ "source_component": "flow",
+ "entry_point": "openadapt-flow",
+ "requirements": requirements["customer_runner"],
+ "selected": {
+ "openadapt-flow": components["flow"]["version"],
+ "openadapt-types": components["types"]["version"],
+ },
+ },
+ "desktop_sidecar": {
+ "kind": "frozen-desktop-sidecar",
+ "version": components["desktop"]["version"],
+ "source_component": "desktop",
+ "source_ref": sidecar_ref,
+ "lock_url": sidecar_lock_url,
+ "lock_sha256": sidecar_lock_sha256,
+ "binary": "openadapt-engine",
+ "requirements": requirements["desktop_sidecar"],
+ "resolved": sidecar_resolved,
+ },
+ "agent_bridge": {
+ "kind": "governed-agent-bridge",
+ "version": components["agent"]["version"],
+ "source_component": "agent",
+ "entry_point": "openadapt-agent",
+ "requirements": requirements["agent_bridge"],
+ "selected": {
+ "openadapt-agent": components["agent"]["version"],
+ "openadapt-flow": components["flow"]["version"],
+ },
+ },
+ }
+
+
+def _dependency_edges(
+ components: dict[str, dict], runtime_units: dict[str, dict]
+) -> list[dict]:
+ """Return every selected-platform dependency edge and its exact target."""
+
+ package_roles = {package: role for role, package in COMPONENTS.items()}
+ sidecar = runtime_units["desktop_sidecar"]["resolved"]
+ grouped: dict[tuple[str, str, str, str], dict] = {}
+ for source_role, component in components.items():
+ for constraint in component.get("dependency_constraints", []):
+ package = constraint["package"]
+ target_role = package_roles.get(package)
+ if target_role is None:
+ continue
+ scope = (
+ "desktop-sidecar-lock"
+ if source_role == "desktop"
+ else "selected-platform"
+ )
+ resolved = (
+ sidecar.get(package)
+ if scope == "desktop-sidecar-lock"
+ else components[target_role]["version"]
+ )
+ key = (scope, source_role, target_role, constraint["requires"])
+ edge = grouped.setdefault(
+ key,
+ {
+ "scope": scope,
+ "source_role": source_role,
+ "source_package": component["package"],
+ "source_version": component["version"],
+ "target_role": target_role,
+ "target_package": package,
+ "requires": constraint["requires"],
+ "resolved": resolved,
+ "activations": [],
+ },
+ )
+ activation = {
+ "extras": constraint.get("extras"),
+ "marker": constraint.get("marker"),
+ }
+ if activation not in edge["activations"]:
+ edge["activations"].append(activation)
+ edges = list(grouped.values())
+ for edge in edges:
+ edge["activations"].sort(
+ key=lambda row: (str(row["extras"]), str(row["marker"]))
+ )
+ return sorted(
+ edges,
+ key=lambda edge: (
+ edge["scope"],
+ edge["source_role"],
+ edge["target_role"],
+ edge["requires"],
+ ),
+ )
+
+
+def _compatibility_status(dependency_edges: list[dict]) -> dict:
+ failures = []
+ for edge in dependency_edges:
+ version = edge.get("resolved")
+ specifier = edge.get("requires")
+ if not isinstance(version, str) or not _version_satisfies(
+ version, str(specifier)
+ ):
+ failures.append(
+ {
+ "scope": edge.get("scope"),
+ "source_role": edge.get("source_role"),
+ "target_role": edge.get("target_role"),
+ "package": edge.get("target_package"),
+ "requires": specifier,
+ "resolved": version,
+ }
+ )
+ return {
+ "status": (
+ "dependency-compatible" if not failures else "dependency-incompatible"
+ ),
+ "basis": "published-metadata-and-desktop-lock",
+ "failures": failures,
+ }
+
+
def _substrates_from_status(status: dict) -> list[dict]:
substrates = []
for entry in status.get("substrates", []):
@@ -213,11 +861,121 @@ def _substrates_from_status(status: dict) -> list[dict]:
return substrates
-def generate(allow_unreleased_launcher: bool) -> dict:
+def _supported_os_from_status(status: dict) -> list[str]:
+ status_names = {
+ str(entry.get("name", "")).casefold() for entry in status.get("substrates", [])
+ }
+ supported = [name for name in SUPPORTED_OS if name.casefold() in status_names]
+ if supported != SUPPORTED_OS:
+ missing = sorted(set(SUPPORTED_OS) - set(supported))
+ raise DriftError(
+ f"FATAL: {STATUS_URL} omits required host operating systems: "
+ + ", ".join(missing)
+ )
+ return supported
+
+
+def _github_tree_entries(repository: str, tree: str) -> dict[str, dict]:
+ url = GITHUB_TREE_URL_TEMPLATE.format(repository=repository, tree=tree)
+ doc = _fetch_json(url)
+ if doc.get("truncated"):
+ raise DriftError(
+ f"FATAL: GitHub truncated {repository} tree {tree}; evidence paths "
+ "cannot be verified completely."
+ )
+ rows = doc.get("tree")
+ if not isinstance(rows, list):
+ raise DriftError(f"FATAL: {repository} tree {tree} has no entries.")
+ return {
+ str(row["path"]): row
+ for row in rows
+ if isinstance(row, dict) and isinstance(row.get("path"), str)
+ }
+
+
+def _qualification_evidence(components: dict[str, dict]) -> list[dict]:
+ evidence_rows = []
+ trees: dict[tuple[str, str], dict[str, dict]] = {}
+ for evidence in QUALIFICATION_EVIDENCE:
+ row = dict(evidence)
+ role = row.get("source_component")
+ if isinstance(role, str):
+ provenance = components[role]["provenance"]
+ repository = row["repository"]
+ if repository != provenance["repository"]:
+ raise DriftError(
+ f"FATAL: evidence {row['id']} repository does not match "
+ f"selected {role} provenance."
+ )
+ tree_key = (repository, provenance["tree"])
+ if tree_key not in trees:
+ trees[tree_key] = _github_tree_entries(*tree_key)
+ source_object = trees[tree_key].get(row["path"])
+ if source_object is None:
+ raise DriftError(
+ f"FATAL: evidence path {repository}@{provenance['commit']}:"
+ f"{row['path']} does not exist."
+ )
+ object_type = source_object.get("type")
+ object_sha = source_object.get("sha")
+ if object_type not in {"blob", "tree"} or not isinstance(
+ object_sha, str
+ ):
+ raise DriftError(
+ f"FATAL: evidence path {row['path']} has no Git object binding."
+ )
+ row["source_commit"] = provenance["commit"]
+ row["source_object_type"] = object_type
+ row["source_object_sha"] = object_sha
+ url_kind = "blob" if object_type == "blob" else "tree"
+ row["url"] = (
+ f"https://github.com/{repository}/{url_kind}/"
+ f"{provenance['commit']}/{row['path']}"
+ )
+ evidence_rows.append(row)
+ return evidence_rows
+
+
+def _generation_metadata() -> dict:
+ files = []
+ for relative_path in (
+ "scripts/generate_platform_manifest.py",
+ "scripts/render_platform_versions.py",
+ ):
+ content = (ROOT / relative_path).read_bytes()
+ files.append(
+ {
+ "path": relative_path,
+ "sha256": hashlib.sha256(content).hexdigest(),
+ }
+ )
+ return {"files": files}
+
+
+def _parse_component_versions(values: list[str]) -> dict[str, str]:
+ selected: dict[str, str] = {}
+ for value in values:
+ role, separator, version = value.partition("=")
+ if not separator or role not in COMPONENTS or not version:
+ raise DriftError(
+ "FATAL: --component-version must use ROLE=VERSION, where ROLE is "
+ + ", ".join(sorted(COMPONENTS))
+ )
+ if role in selected:
+ raise DriftError(f"FATAL: duplicate component selection for {role}.")
+ selected[role] = version
+ return selected
+
+
+def generate(
+ allow_unreleased_launcher: bool,
+ component_versions: dict[str, str] | None = None,
+) -> dict:
status = _fetch_json(STATUS_URL)
+ selected_versions = component_versions or {}
components = {
- role: _pypi_component(package) for role, package in COMPONENTS.items()
+ role: _pypi_component(role, selected_versions.get(role)) for role in COMPONENTS
}
# Drift guard: the in-repo launcher version must match the published one,
@@ -246,18 +1004,31 @@ def generate(allow_unreleased_launcher: bool) -> dict:
"the release channel."
)
+ runtime_units = _runtime_units(components)
+ dependency_edges = _dependency_edges(components, runtime_units)
return {
"manifest_kind": MANIFEST_KIND,
"schema_version": SCHEMA_VERSION,
"generated_at": _dt.datetime.now(_dt.timezone.utc)
.replace(microsecond=0)
.isoformat(),
+ "generation": _generation_metadata(),
"release_channel": release_channel,
+ "release_selection": {
+ "mode": (
+ "explicit-published" if selected_versions else "latest-published"
+ ),
+ "component_versions": dict(sorted(selected_versions.items())),
+ },
"components": components,
+ "runtime_units": runtime_units,
+ "dependency_edges": dependency_edges,
+ "schema_compatibility": _schema_compatibility(components),
"compatibility": _compatibility_from_pyproject(),
- "supported_os": SUPPORTED_OS,
+ "compatibility_status": _compatibility_status(dependency_edges),
+ "supported_os": _supported_os_from_status(status),
"substrate_drivers": _substrates_from_status(status),
- "qualification_evidence": QUALIFICATION_EVIDENCE,
+ "qualification_evidence": _qualification_evidence(components),
"signature": UNSIGNED_SIGNATURE,
}
@@ -270,6 +1041,23 @@ def main() -> int:
default=ROOT / "platform-manifest.json",
help="Where to write the manifest (default: repo root).",
)
+ parser.add_argument(
+ "--report-output",
+ type=Path,
+ default=ROOT / "docs" / "platform-compatibility-report.md",
+ help="Where to write the generated human report.",
+ )
+ parser.add_argument(
+ "--component-version",
+ action="append",
+ default=[],
+ metavar="ROLE=VERSION",
+ help=(
+ "Select an exact published component release instead of PyPI's "
+ "latest. Repeat for each release-train component. The generator "
+ "refuses a version until its immutable artifacts and release tag exist."
+ ),
+ )
parser.add_argument(
"--allow-unreleased-launcher",
action="store_true",
@@ -281,13 +1069,16 @@ def main() -> int:
args = parser.parse_args()
try:
- manifest = generate(args.allow_unreleased_launcher)
+ selected_versions = _parse_component_versions(args.component_version)
+ manifest = generate(args.allow_unreleased_launcher, selected_versions)
except DriftError as exc:
print(str(exc), file=sys.stderr)
return 1
args.output.write_text(json.dumps(manifest, indent=2) + "\n")
+ args.report_output.write_text(render_markdown(manifest), encoding="utf-8")
print(f"Wrote {args.output}")
+ print(f"Wrote {args.report_output}")
for role, component in manifest["components"].items():
print(f" {role}: {component['package']}=={component['version']}")
return 0
diff --git a/scripts/render_platform_versions.py b/scripts/render_platform_versions.py
new file mode 100644
index 000000000..edee03b1c
--- /dev/null
+++ b/scripts/render_platform_versions.py
@@ -0,0 +1,257 @@
+#!/usr/bin/env python3
+"""Render versions and compatibility from the exact platform BOM."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+COMPONENT_LABELS = {
+ "launcher": "Launcher",
+ "flow": "Flow",
+ "capture": "Capture",
+ "privacy": "Privacy",
+ "types": "Types",
+ "desktop": "Desktop",
+ "agent": "Agent",
+}
+RUNTIME_LABELS = {
+ "launcher_environment": "Launcher environment",
+ "customer_runner": "Runner",
+ "desktop_sidecar": "Desktop sidecar",
+ "agent_bridge": "Agent bridge",
+}
+
+
+def version_rows(manifest: dict) -> list[dict[str, str]]:
+ rows: list[dict[str, str]] = []
+ components = manifest.get("components", {})
+ for role, label in COMPONENT_LABELS.items():
+ component = components.get(role)
+ if not isinstance(component, dict) or not isinstance(
+ component.get("version"), str
+ ):
+ raise ValueError(f"manifest component {role!r} has no version")
+ rows.append({"role": role, "label": label, "version": component["version"]})
+
+ runtime_units = manifest.get("runtime_units", {})
+ for role, label in RUNTIME_LABELS.items():
+ unit = runtime_units.get(role)
+ if not isinstance(unit, dict) or not isinstance(unit.get("version"), str):
+ raise ValueError(f"manifest runtime unit {role!r} has no version")
+ row = {"role": role, "label": label, "version": unit["version"]}
+ closure = unit.get("resolved") or unit.get("selected")
+ if isinstance(closure, dict):
+ row["resolved"] = ", ".join(
+ f"{package}=={version}" for package, version in sorted(closure.items())
+ )
+ rows.append(row)
+ return rows
+
+
+def _artifact_lines(component: dict) -> str:
+ return "
".join(
+ f"`{artifact['filename']}` `{artifact['sha256']}`"
+ for artifact in component.get("artifacts", [])
+ )
+
+
+def render_markdown(manifest: dict) -> str:
+ """Render the complete human-readable report from the exact BOM."""
+
+ status = manifest.get("compatibility_status", {})
+ selection = manifest.get("release_selection", {})
+ lines = [
+ "# OpenAdapt platform compatibility report",
+ "",
+ "",
+ "",
+ f"- Generated: `{manifest.get('generated_at')}`",
+ f"- Release channel: **{manifest.get('release_channel')}**",
+ f"- Selection: `{selection.get('mode')}`",
+ f"- Compatibility: **{status.get('status')}**",
+ f"- Signature: `{manifest.get('signature', {}).get('status')}`",
+ "",
+ "## Published components",
+ "",
+ "| Role | Package | Version | Release source reference | Published artifacts |",
+ "|---|---|---:|---|---|",
+ ]
+ for role, component in manifest.get("components", {}).items():
+ provenance = component.get("provenance", {})
+ source = (
+ f"[`{provenance.get('release_ref')}`]({provenance.get('url')})
"
+ f"commit `{provenance.get('commit')}`
"
+ f"tree `{provenance.get('tree')}`"
+ )
+ lines.append(
+ f"| {role} | `{component.get('package')}` | "
+ f"`{component.get('version')}` | {source} | "
+ f"{_artifact_lines(component)} |"
+ )
+
+ lines.extend(
+ [
+ "",
+ "## Runtime selections",
+ "",
+ "| Unit | Version | Selected or locked OpenAdapt packages |",
+ "|---|---:|---|",
+ ]
+ )
+ for role, unit in manifest.get("runtime_units", {}).items():
+ closure = unit.get("resolved") or unit.get("selected") or {}
+ selected = "
".join(
+ f"`{package}=={version}`"
+ for package, version in sorted(closure.items())
+ )
+ lines.append(f"| {role} | `{unit.get('version')}` | {selected} |")
+
+ lines.extend(
+ [
+ "",
+ "## Selected-platform dependency compatibility",
+ "",
+ "These edges show whether each selected integration version satisfies "
+ "its published dependency constraint. Optional markers do not mean "
+ "that every package is installed in every runtime unit.",
+ "",
+ "| Scope | Source | Target | Constraint | Selected | Activation |",
+ "|---|---|---|---|---:|---|",
+ ]
+ )
+ for edge in manifest.get("dependency_edges", []):
+ activation = "
".join(
+ f"`{value}`"
+ for value in sorted(
+ {row.get("marker") or "always" for row in edge.get("activations", [])}
+ )
+ )
+ lines.append(
+ f"| {edge.get('scope')} | `{edge.get('source_package')}=="
+ f"{edge.get('source_version')}` | `{edge.get('target_package')}` | "
+ f"`{edge.get('requires')}` | `{edge.get('resolved')}` | "
+ f"{activation} |"
+ )
+
+ if status.get("failures"):
+ lines.extend(["", "### Compatibility failures", ""])
+ for failure in status["failures"]:
+ lines.append(
+ f"- `{failure.get('source_role')}` -> `{failure.get('package')}` "
+ f"requires `{failure.get('requires')}`; the resolved version is "
+ f"`{failure.get('resolved')}`."
+ )
+
+ lines.extend(
+ [
+ "",
+ "## Cross-component schemas",
+ "",
+ "| Contract | Owner | Accepted versions | Exact source |",
+ "|---|---|---|---|",
+ ]
+ )
+ for name, schema in manifest.get("schema_compatibility", {}).items():
+ prefix = schema.get("schema_prefix") or schema.get("schema_field")
+ source = "
".join(
+ f"[`{item.get('path')}`]({item.get('url')}) "
+ f"`{item.get('sha256')}`"
+ for item in schema.get("source_files", [])
+ )
+ lines.append(
+ f"| `{name}` | `{schema.get('source_component')}` | "
+ f"`{prefix}` {schema.get('accepted_versions')} | {source} |"
+ )
+
+ lines.extend(
+ [
+ "",
+ "## Host operating systems",
+ "",
+ ", ".join(f"`{name}`" for name in manifest.get("supported_os", [])),
+ "",
+ "## Substrate delivery",
+ "",
+ "| Substrate | Public label | Delivery |",
+ "|---|---|---|",
+ ]
+ )
+ for substrate in manifest.get("substrate_drivers", []):
+ lines.append(
+ f"| {substrate.get('name')} | {substrate.get('public_label')} | "
+ f"{substrate.get('delivery')} |"
+ )
+
+ lines.extend(["", "## Qualification evidence", ""])
+ for evidence in manifest.get("qualification_evidence", []):
+ location = evidence.get("url")
+ if not location:
+ location = (
+ f"https://github.com/{evidence.get('repository')}/tree/"
+ f"main/{evidence.get('path')}"
+ )
+ lines.append(
+ f"- [{evidence.get('id')}]({location}): "
+ f"{evidence.get('description')}"
+ )
+ lines.append("")
+ return "\n".join(lines)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--manifest", type=Path, default=ROOT / "platform-manifest.json"
+ )
+ parser.add_argument(
+ "--format", choices=("text", "json", "markdown"), default="text"
+ )
+ parser.add_argument("--output", type=Path)
+ args = parser.parse_args()
+ try:
+ manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
+ rows = version_rows(manifest)
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
+ print(f"FATAL: cannot render platform versions: {exc}", file=sys.stderr)
+ return 1
+
+ if args.format == "json":
+ rendered = json.dumps(
+ {
+ "release_channel": manifest.get("release_channel"),
+ "compatibility_status": manifest.get(
+ "compatibility_status", {}
+ ).get("status"),
+ "supported_os": manifest.get("supported_os", []),
+ "substrates": manifest.get("substrate_drivers", []),
+ "versions": rows,
+ },
+ indent=2,
+ )
+ elif args.format == "markdown":
+ rendered = render_markdown(manifest)
+ else:
+ text_lines = [
+ f"Release channel: {manifest.get('release_channel')}",
+ f"Compatibility: {manifest.get('compatibility_status', {}).get('status')}",
+ ]
+ for row in rows:
+ suffix = f" ({row['resolved']})" if row.get("resolved") else ""
+ text_lines.append(f"{row['label']}: {row['version']}{suffix}")
+ rendered = "\n".join(text_lines)
+
+ if args.output:
+ args.output.write_text(rendered.rstrip() + "\n", encoding="utf-8")
+ print(f"Wrote {args.output}")
+ else:
+ print(rendered)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/validate_platform_manifest.py b/scripts/validate_platform_manifest.py
index 4b4c19011..9d057fbbd 100644
--- a/scripts/validate_platform_manifest.py
+++ b/scripts/validate_platform_manifest.py
@@ -7,8 +7,9 @@
What it validates TODAY:
-* Structure: manifest kind, schema major version, required fields, and at
- least one artifact with a sha256 digest per component.
+* Structure: manifest kind, schema major version, all seven public components,
+ four runtime units, exact schema-source bindings, every selected dependency
+ edge, and the complete artifact set for each published component.
* Signature honesty: while the signature value is null the status must say
"unsigned (signing infrastructure pending)". A non-null signature value
FAILS validation, because no verification infrastructure exists yet and a
@@ -19,8 +20,10 @@
manifest is the normal in-flight-release state and only warns; the PyPI
comparison below is what authoritatively detects staleness.
* Published-artifact agreement (network): for every component, the manifest
- version equals the latest version on PyPI, and every artifact filename,
- URL, and sha256 digest matches what PyPI reports. A manifest version that
+ version equals the latest version on PyPI unless an exact published version
+ was selected explicitly. Every artifact filename,
+ type, URL, sha256 digest, Python range, and openadapt-* dependency metadata
+ match the exact version-specific PyPI record. A manifest version that
is BEHIND PyPI's latest is drift and fails; one that is AHEAD but already
published is PyPI index propagation lag and warns (its digests are still
verified); one PyPI never published fails. An unreachable PyPI warns rather
@@ -33,6 +36,8 @@ class of check (for example in an airgapped environment).
source of truth. It stays a warning so a release here cannot be blocked by an
edit nobody in this repo can make. Pass --strict-status to escalate the skew
to a failure.
+* Exact-source agreement (network): release refs, schema source files, and
+ qualification evidence Git objects match the selected release commit/tree.
Do NOT read that warning as self-healing. status.json is HAND-MAINTAINED in
openadapt-web; nothing generates it from PyPI or from this manifest. On
@@ -46,17 +51,23 @@ class of check (for example in an airgapped environment).
* Cryptographic signature verification (sigstore for the manifest itself,
Authenticode / Apple Developer ID for OS installers).
-* Desktop OS installer artifacts (MSI/DMG); only PyPI artifacts exist today.
+* Desktop OS installer signatures and notarization. The sidecar package
+ closure is checked against the exact native release tag, but installer bytes
+ are still covered by the Desktop release's own SHA256SUMS and attestation.
Usage:
python scripts/validate_platform_manifest.py [--manifest PATH]
- [--offline] [--strict-status]
+ [--offline] [--strict-status] [--require-compatible]
"""
from __future__ import annotations
import argparse
+import datetime as _dt
+import hashlib
import json
+import os
+import re
import sys
import urllib.error
import urllib.request
@@ -67,6 +78,29 @@ class of check (for example in an airgapped environment).
except ModuleNotFoundError: # pragma: no cover - Python < 3.11
import tomli as tomllib # type: ignore[no-redef]
+from generate_platform_manifest import (
+ COMPONENT_REPOSITORIES,
+ COMPONENTS,
+ DESKTOP_NATIVE_LOCK_URL_TEMPLATE,
+ GITHUB_COMMIT_URL_TEMPLATE,
+ PYPI_VERSION_URL_TEMPLATE,
+ QUALIFICATION_EVIDENCE,
+ RUNTIME_UNIT_NAMES,
+ DriftError,
+ _compatibility_status,
+ _dependency_edges,
+ _generation_metadata,
+ _github_tree_entries,
+ _openadapt_dependency_constraints,
+ _openadapt_requirements,
+ _release_ref,
+ _runtime_unit_requirements,
+ _schema_compatibility,
+ _substrates_from_status,
+ _supported_os_from_status,
+)
+from render_platform_versions import render_markdown
+
ROOT = Path(__file__).resolve().parents[1]
EXPECTED_KIND = "openadapt-platform-release-manifest"
@@ -75,14 +109,23 @@ class of check (for example in an airgapped environment).
STATUS_URL = "https://openadapt.ai/status.json"
PYPI_URL_TEMPLATE = "https://pypi.org/pypi/{package}/json"
HTTP_TIMEOUT_SECONDS = 30
+REPORT_PATH = ROOT / "docs" / "platform-compatibility-report.md"
+RELEASE_CHANNELS = {"stable", "beta", "preview", "experimental", "research"}
+EXPECTED_SIGNATURE_PLAN = "docs/platform-manifest.md#signing-plan"
REQUIRED_TOP_LEVEL_FIELDS = (
"manifest_kind",
"schema_version",
"generated_at",
+ "generation",
"release_channel",
+ "release_selection",
"components",
+ "runtime_units",
+ "dependency_edges",
+ "schema_compatibility",
"compatibility",
+ "compatibility_status",
"supported_os",
"substrate_drivers",
"qualification_evidence",
@@ -91,13 +134,25 @@ class of check (for example in an airgapped environment).
def _fetch_json(url: str) -> dict:
+ headers = {"User-Agent": "openadapt-platform-manifest-validator"}
+ token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
+ if token and url.startswith("https://api.github.com/"):
+ headers["Authorization"] = f"Bearer {token}"
request = urllib.request.Request(
- url, headers={"User-Agent": "openadapt-platform-manifest-validator"}
+ url, headers=headers
)
with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_SECONDS) as resp:
return json.load(resp)
+def _fetch_text(url: str) -> str:
+ request = urllib.request.Request(
+ url, headers={"User-Agent": "openadapt-platform-manifest-validator"}
+ )
+ with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_SECONDS) as resp:
+ return resp.read().decode("utf-8")
+
+
class Report:
def __init__(self) -> None:
self.errors: list[str] = []
@@ -126,10 +181,76 @@ def check_structure(manifest: dict, report: Report) -> None:
f"schema_version {schema_version!r} is not major version "
f"{EXPECTED_SCHEMA_MAJOR}; this validator cannot interpret it"
)
- for role, component in manifest.get("components", {}).items():
+ generated_at = manifest.get("generated_at")
+ try:
+ generated = _dt.datetime.fromisoformat(str(generated_at))
+ except ValueError:
+ generated = None
+ if generated is None or generated.tzinfo != _dt.timezone.utc:
+ report.error("generated_at must be an ISO 8601 UTC timestamp")
+ release_channel = manifest.get("release_channel")
+ if release_channel not in RELEASE_CHANNELS:
+ report.error(
+ f"release_channel must be one of {sorted(RELEASE_CHANNELS)!r}, "
+ f"got {release_channel!r}"
+ )
+ if manifest.get("generation") != _generation_metadata():
+ report.error(
+ "generation metadata does not match the exact generator and renderer"
+ )
+ components = manifest.get("components", {})
+ if not isinstance(components, dict):
+ report.error("components must be an object")
+ return
+ expected_components = set(COMPONENTS)
+ if set(components) != expected_components:
+ report.error(
+ "components must be exactly "
+ f"{sorted(expected_components)!r}, got {sorted(components)!r}"
+ )
+ for role, component in components.items():
+ for key in (
+ "package",
+ "version",
+ "source",
+ "requires_python",
+ "requires",
+ "dependency_constraints",
+ "provenance",
+ ):
+ if key not in component:
+ report.error(f"component {role} missing {key}")
+ if component.get("package") != COMPONENTS.get(role):
+ report.error(
+ f"component {role} package is {component.get('package')!r}, "
+ f"expected {COMPONENTS.get(role)!r}"
+ )
+ if component.get("source") != "pypi":
+ report.error(f"component {role} source must be 'pypi'")
+ provenance = component.get("provenance", {})
+ expected_repository = COMPONENT_REPOSITORIES.get(role)
+ expected_ref = _release_ref(role, str(component.get("version")))
+ if provenance.get("repository") != expected_repository:
+ report.error(
+ f"component {role} provenance repository is "
+ f"{provenance.get('repository')!r}, expected {expected_repository!r}"
+ )
+ if provenance.get("release_ref") != expected_ref:
+ report.error(
+ f"component {role} release_ref is "
+ f"{provenance.get('release_ref')!r}, expected {expected_ref!r}"
+ )
+ for digest in ("commit", "tree"):
+ if not re.fullmatch(r"[0-9a-f]{40}", str(provenance.get(digest, ""))):
+ report.error(f"component {role} provenance {digest} is invalid")
+ if not str(provenance.get("url", "")).startswith("https://github.com/"):
+ report.error(f"component {role} provenance URL must use GitHub HTTPS")
artifacts = component.get("artifacts") or []
if not artifacts:
report.error(f"component {role} lists no artifacts")
+ filenames = [artifact.get("filename") for artifact in artifacts]
+ if len(filenames) != len(set(filenames)):
+ report.error(f"component {role} has duplicate artifact filenames")
for artifact in artifacts:
for key in ("type", "filename", "url", "sha256"):
if not artifact.get(key):
@@ -137,19 +258,330 @@ def check_structure(manifest: dict, report: Report) -> None:
f"component {role} artifact "
f"{artifact.get('filename', '')} missing {key}"
)
- if not manifest.get("qualification_evidence"):
+ if not re.fullmatch(r"[0-9a-f]{64}", str(artifact.get("sha256", ""))):
+ report.error(f"component {role} artifact has an invalid sha256")
+ if not str(artifact.get("url", "")).startswith("https://"):
+ report.error(f"component {role} artifact URL must use HTTPS")
+ constraints = component.get("dependency_constraints")
+ if not isinstance(constraints, list):
+ report.error(f"component {role} dependency_constraints must be a list")
+ for constraint in constraints or []:
+ if set(constraint) != {"package", "requires", "extras", "marker"}:
+ report.error(
+ f"component {role} dependency constraint has invalid fields"
+ )
+ if not str(constraint.get("package", "")).startswith("openadapt"):
+ report.error(
+ f"component {role} dependency constraint has invalid package"
+ )
+ if not constraint.get("requires"):
+ report.error(
+ f"component {role} dependency constraint has no version range"
+ )
+ evidence_rows = manifest.get("qualification_evidence")
+ if not evidence_rows:
report.error("qualification_evidence is empty")
+ expected_evidence = {row["id"]: row for row in QUALIFICATION_EVIDENCE}
+ actual_evidence = {
+ row.get("id"): row for row in evidence_rows or [] if isinstance(row, dict)
+ }
+ if (
+ set(actual_evidence) != set(expected_evidence)
+ or len(evidence_rows or []) != len(expected_evidence)
+ ):
+ report.error(
+ "qualification_evidence IDs must be exactly "
+ f"{sorted(expected_evidence)!r}"
+ )
+ for evidence in evidence_rows or []:
+ if not isinstance(evidence, dict):
+ report.error("qualification_evidence rows must be objects")
+ continue
+ expected = expected_evidence.get(evidence.get("id"))
+ if expected is not None:
+ for field in ("kind", "source_component", "repository", "path"):
+ if expected.get(field) != evidence.get(field):
+ report.error(
+ f"qualification evidence {evidence.get('id')!r} "
+ f"{field} drift"
+ )
+ if expected.get("source_component") is None and evidence.get(
+ "url"
+ ) != expected.get("url"):
+ report.error(
+ f"qualification evidence {evidence.get('id')!r} URL drift"
+ )
+ role = evidence.get("source_component")
+ if role is None:
+ continue
+ component = components.get(role, {})
+ source_commit = component.get("provenance", {}).get("commit")
+ if evidence.get("source_commit") != source_commit:
+ report.error(
+ f"qualification evidence {evidence.get('id')!r} is not bound "
+ f"to the selected {role} source commit"
+ )
+ if source_commit not in str(evidence.get("url", "")):
+ report.error(
+ f"qualification evidence {evidence.get('id')!r} URL is not "
+ "commit-pinned"
+ )
+ object_type = evidence.get("source_object_type")
+ if object_type not in {"blob", "tree"}:
+ report.error(
+ f"qualification evidence {evidence.get('id')!r} has invalid "
+ "source_object_type"
+ )
+ if not re.fullmatch(
+ r"[0-9a-f]{40}", str(evidence.get("source_object_sha", ""))
+ ):
+ report.error(
+ f"qualification evidence {evidence.get('id')!r} has invalid "
+ "source_object_sha"
+ )
+ url_kind = "blob" if object_type == "blob" else "tree"
+ expected_url = (
+ f"https://github.com/{evidence.get('repository')}/{url_kind}/"
+ f"{source_commit}/{evidence.get('path')}"
+ )
+ if evidence.get("url") != expected_url:
+ report.error(
+ f"qualification evidence {evidence.get('id')!r} URL is not "
+ "canonical for its object type"
+ )
+
+ selection = manifest.get("release_selection", {})
+ if not isinstance(selection, dict):
+ report.error("release_selection must be an object")
+ selection = {}
+ if set(selection) != {"mode", "component_versions"}:
+ report.error("release_selection must contain only mode and component_versions")
+ if selection.get("mode") not in {"latest-published", "explicit-published"}:
+ report.error("release_selection.mode is invalid")
+ selected_versions = selection.get("component_versions")
+ if not isinstance(selected_versions, dict):
+ report.error("release_selection.component_versions must be an object")
+ else:
+ for role, version in selected_versions.items():
+ if role not in COMPONENTS:
+ report.error(f"release_selection has unknown component {role!r}")
+ elif components.get(role, {}).get("version") != version:
+ report.error(
+ f"release_selection requests {role}=={version}, but the "
+ f"component records {components.get(role, {}).get('version')!r}"
+ )
+ if selection.get("mode") == "latest-published" and selected_versions:
+ report.error("latest-published selection cannot contain version overrides")
+ if selection.get("mode") == "explicit-published" and not selected_versions:
+ report.error("explicit-published selection needs a version override")
+
+ runtime_units = manifest.get("runtime_units", {})
+ if not isinstance(runtime_units, dict):
+ report.error("runtime_units must be an object")
+ return
+ expected_units = RUNTIME_UNIT_NAMES
+ if set(runtime_units) != expected_units:
+ report.error(
+ f"runtime_units must be exactly {sorted(expected_units)!r}, "
+ f"got {sorted(runtime_units)!r}"
+ )
+ try:
+ expected_requirements = _runtime_unit_requirements(components)
+ except DriftError as exc:
+ report.error(f"cannot derive runtime requirements: {exc}")
+ expected_requirements = {}
+ for name, unit in runtime_units.items():
+ closure_key = "resolved" if name == "desktop_sidecar" else "selected"
+ for key in (
+ "kind",
+ "version",
+ "source_component",
+ "requirements",
+ closure_key,
+ ):
+ if key not in unit:
+ report.error(f"runtime unit {name} missing {key}")
+ if unit.get("requirements") != expected_requirements.get(name):
+ report.error(
+ f"runtime unit {name} requirements drift: {unit.get('requirements')!r}"
+ )
+ source_role = unit.get("source_component")
+ source = components.get(source_role, {})
+ if unit.get("version") != source.get("version"):
+ report.error(
+ f"runtime unit {name} version {unit.get('version')!r} does not "
+ f"match source component {source_role!r} version "
+ f"{source.get('version')!r}"
+ )
+
+ runner = runtime_units.get("customer_runner", {})
+ expected_runner_selected = {
+ "openadapt-flow": components.get("flow", {}).get("version"),
+ "openadapt-types": components.get("types", {}).get("version"),
+ }
+ if runner.get("selected") != expected_runner_selected:
+ report.error(
+ f"customer_runner selection is {runner.get('selected')!r}, expected "
+ f"{expected_runner_selected!r}"
+ )
+
+ launcher = runtime_units.get("launcher_environment", {})
+ expected_launcher_selected = {
+ "openadapt": components.get("launcher", {}).get("version"),
+ "openadapt-flow": components.get("flow", {}).get("version"),
+ }
+ if launcher.get("selected") != expected_launcher_selected:
+ report.error(
+ f"launcher_environment selection is {launcher.get('selected')!r}, "
+ f"expected {expected_launcher_selected!r}"
+ )
+
+ agent = runtime_units.get("agent_bridge", {})
+ expected_agent_selected = {
+ "openadapt-agent": components.get("agent", {}).get("version"),
+ "openadapt-flow": components.get("flow", {}).get("version"),
+ }
+ if agent.get("selected") != expected_agent_selected:
+ report.error(
+ f"agent_bridge selection is {agent.get('selected')!r}, expected "
+ f"{expected_agent_selected!r}"
+ )
+
+ sidecar = runtime_units.get("desktop_sidecar", {})
+ expected_sidecar_ref = f"desktop-v{sidecar.get('version')}"
+ if sidecar.get("source_ref") != expected_sidecar_ref:
+ report.error(
+ f"desktop_sidecar source_ref is {sidecar.get('source_ref')!r}, "
+ f"expected {expected_sidecar_ref!r}"
+ )
+ expected_lock_url = DESKTOP_NATIVE_LOCK_URL_TEMPLATE.format(
+ version=sidecar.get("version")
+ )
+ if sidecar.get("lock_url") != expected_lock_url:
+ report.error(
+ f"desktop_sidecar lock_url is {sidecar.get('lock_url')!r}, "
+ f"expected {expected_lock_url!r}"
+ )
+ if not re.fullmatch(r"[0-9a-f]{64}", str(sidecar.get("lock_sha256", ""))):
+ report.error("desktop_sidecar lock_sha256 is invalid")
+
+ dependency_edges = manifest.get("dependency_edges")
+ if not isinstance(dependency_edges, list):
+ report.error("dependency_edges must be a list")
+ else:
+ try:
+ expected_edges = _dependency_edges(components, runtime_units)
+ except (DriftError, KeyError, TypeError) as exc:
+ report.error(f"cannot derive dependency edges: {exc}")
+ else:
+ if dependency_edges != expected_edges:
+ report.error(
+ "dependency_edges drift: the selected-platform graph does "
+ "not match the published component metadata and Desktop lock"
+ )
+
+ schemas = manifest.get("schema_compatibility")
+ if not isinstance(schemas, dict) or not schemas:
+ report.error("schema_compatibility must be a non-empty object")
+ for name, schema in (schemas or {}).items():
+ role = schema.get("source_component")
+ accepted = schema.get("accepted_versions")
+ if role not in components:
+ report.error(f"schema {name} has an unknown source_component")
+ continue
+ if (
+ not isinstance(accepted, list)
+ or not accepted
+ or accepted != sorted(set(accepted))
+ or not all(isinstance(version, int) and version > 0 for version in accepted)
+ ):
+ report.error(f"schema {name} accepted_versions is invalid")
+ elif schema.get("minimum") != accepted[0] or schema.get("maximum") != accepted[-1]:
+ report.error(f"schema {name} range does not match accepted_versions")
+ source_commit = components[role].get("provenance", {}).get("commit")
+ if schema.get("source_commit") != source_commit:
+ report.error(f"schema {name} is not bound to its selected release commit")
+ source_files = schema.get("source_files")
+ if not isinstance(source_files, list) or not source_files:
+ report.error(f"schema {name} has no source_files")
+ for source_file in source_files or []:
+ path = source_file.get("path")
+ if (
+ not isinstance(path, str)
+ or path.startswith("/")
+ or ".." in Path(path).parts
+ ):
+ report.error(f"schema {name} has an invalid source path")
+ if not re.fullmatch(r"[0-9a-f]{40}", str(source_file.get("git_blob", ""))):
+ report.error(f"schema {name} source has an invalid git_blob")
+ if not re.fullmatch(r"[0-9a-f]{64}", str(source_file.get("sha256", ""))):
+ report.error(f"schema {name} source has an invalid sha256")
+
+ if manifest.get("supported_os") != ["windows", "macos", "linux"]:
+ report.error("supported_os must be exactly windows, macos, and linux")
+ substrates = manifest.get("substrate_drivers")
+ if not isinstance(substrates, list) or not substrates:
+ report.error("substrate_drivers must be a non-empty list")
+ elif any(
+ set(row) != {"name", "public_label", "delivery"} for row in substrates
+ ):
+ report.error("substrate_drivers has an invalid row")
+
+
+def check_compatibility_status(manifest: dict, report: Report) -> None:
+ dependency_edges = manifest.get("dependency_edges")
+ if not isinstance(dependency_edges, list) or not all(
+ isinstance(edge, dict) for edge in dependency_edges
+ ):
+ report.error("cannot compute compatibility_status from invalid edges")
+ return
+ expected = _compatibility_status(dependency_edges)
+ actual = manifest.get("compatibility_status")
+ if actual != expected:
+ report.error(
+ f"compatibility_status is {actual!r}, but the selected dependency "
+ f"graph implies {expected!r}"
+ )
+
+
+def check_generated_report(
+ manifest: dict, report: Report, report_path: Path = REPORT_PATH
+) -> None:
+ try:
+ actual = report_path.read_text(encoding="utf-8")
+ except OSError as exc:
+ report.error(f"cannot read generated compatibility report: {exc}")
+ return
+ expected = render_markdown(manifest)
+ if actual != expected:
+ report.error(
+ f"{report_path} does not match the exact BOM; "
+ "rerun scripts/generate_platform_manifest.py"
+ )
def check_signature_honesty(manifest: dict, report: Report) -> None:
signature = manifest.get("signature") or {}
+ expected_unsigned = {
+ "algorithm": None,
+ "value": None,
+ "status": EXPECTED_UNSIGNED_STATUS,
+ "plan": EXPECTED_SIGNATURE_PLAN,
+ }
if signature.get("value") is None:
- if signature.get("status") != EXPECTED_UNSIGNED_STATUS:
+ if signature != expected_unsigned:
report.error(
- "signature.value is null but signature.status is "
- f"{signature.get('status')!r}; while unsigned it must be "
- f"{EXPECTED_UNSIGNED_STATUS!r}"
+ "unsigned signature block must match the exact null-signature "
+ "contract and signing-plan path"
)
+ plan_path = ROOT / EXPECTED_SIGNATURE_PLAN.split("#", 1)[0]
+ try:
+ plan_text = plan_path.read_text(encoding="utf-8")
+ except OSError as exc:
+ report.error(f"cannot read signature plan: {exc}")
+ else:
+ if "## Signing plan" not in plan_text:
+ report.error("signature plan anchor does not exist")
else:
report.error(
"signature.value is set, but no signature verification "
@@ -287,7 +719,13 @@ def _files_for_version(doc: dict, version: str) -> list[dict] | None:
def compare_component_to_pypi(
- role: str, component: dict, doc: dict, report: Report
+ role: str,
+ component: dict,
+ doc: dict,
+ report: Report,
+ *,
+ allow_pinned: bool = False,
+ latest_version_override: str | None = None,
) -> None:
"""Compare one manifest component against PyPI's published state.
@@ -309,7 +747,7 @@ def compare_component_to_pypi(
"""
package = component.get("package")
manifest_version = component.get("version")
- latest_version = doc.get("info", {}).get("version")
+ latest_version = latest_version_override or doc.get("info", {}).get("version")
files = _files_for_version(doc, manifest_version)
if not files:
@@ -337,6 +775,12 @@ def compare_component_to_pypi(
"propagation lag, not drift; digests are verified against "
f"{manifest_version!r}."
)
+ elif allow_pinned:
+ report.warning(
+ f"{role}: the explicit platform selection pins "
+ f"{package}=={manifest_version}; PyPI's latest is "
+ f"{latest_version}. The exact selected artifacts remain verified."
+ )
else:
report.error(
f"{role} version drift: manifest has {manifest_version!r} "
@@ -347,8 +791,21 @@ def compare_component_to_pypi(
return
published = {
- entry["filename"]: (entry["url"], entry["digests"]["sha256"]) for entry in files
+ entry["filename"]: (
+ entry["packagetype"],
+ entry["url"],
+ entry["digests"]["sha256"],
+ )
+ for entry in files
+ }
+ declared = {
+ artifact.get("filename") for artifact in component.get("artifacts", [])
}
+ if declared != set(published):
+ report.error(
+ f"{role} artifact filename-set drift: manifest has "
+ f"{sorted(declared, key=str)!r} but PyPI has {sorted(published)!r}"
+ )
for artifact in component.get("artifacts", []):
filename = artifact.get("filename")
if filename not in published:
@@ -357,7 +814,12 @@ def compare_component_to_pypi(
f"for {package}=={manifest_version}"
)
continue
- url, sha256 = published[filename]
+ artifact_type, url, sha256 = published[filename]
+ if artifact.get("type") != artifact_type:
+ report.error(
+ f"{role} artifact {filename} type drift: manifest has "
+ f"{artifact.get('type')} but PyPI has {artifact_type}"
+ )
if artifact.get("url") != url:
report.error(
f"{role} artifact {filename} URL drift: manifest has "
@@ -371,12 +833,47 @@ def compare_component_to_pypi(
"would fail."
)
+ expected_requirements = _openadapt_requirements(
+ doc.get("info", {}).get("requires_dist")
+ )
+ if component.get("requires") != expected_requirements:
+ report.error(
+ f"{role} dependency-range drift: manifest has "
+ f"{component.get('requires')!r} but PyPI metadata has "
+ f"{expected_requirements!r}"
+ )
+ expected_constraints = _openadapt_dependency_constraints(
+ doc.get("info", {}).get("requires_dist")
+ )
+ if component.get("dependency_constraints") != expected_constraints:
+ report.error(
+ f"{role} dependency-constraint drift: manifest has "
+ f"{component.get('dependency_constraints')!r} but PyPI metadata has "
+ f"{expected_constraints!r}"
+ )
+ expected_python = doc.get("info", {}).get("requires_python")
+ if component.get("requires_python") != expected_python:
+ report.error(
+ f"{role} requires_python drift: manifest has "
+ f"{component.get('requires_python')!r} but PyPI metadata has "
+ f"{expected_python!r}"
+ )
+
def check_against_pypi(manifest: dict, report: Report, require_network: bool) -> None:
+ selected = manifest.get("release_selection", {}).get("component_versions", {})
for role, component in manifest.get("components", {}).items():
package = component.get("package")
try:
- doc = _fetch_json(PYPI_URL_TEMPLATE.format(package=package))
+ latest_doc = _fetch_json(PYPI_URL_TEMPLATE.format(package=package))
+ if role in selected:
+ doc = _fetch_json(
+ PYPI_VERSION_URL_TEMPLATE.format(
+ package=package, version=component.get("version")
+ )
+ )
+ else:
+ doc = latest_doc
except (urllib.error.URLError, TimeoutError) as exc:
# An unreachable index is not evidence of drift. Failing here
# makes the guard flaky, and a flaky guard gets ignored -- which
@@ -386,14 +883,103 @@ def check_against_pypi(manifest: dict, report: Report, require_network: bool) ->
emit = report.error if require_network else report.warning
emit(f"could not fetch PyPI metadata for {package}: {exc}")
continue
- compare_component_to_pypi(role, component, doc, report)
+ compare_component_to_pypi(
+ role,
+ component,
+ doc,
+ report,
+ allow_pinned=role in selected,
+ latest_version_override=latest_doc.get("info", {}).get("version"),
+ )
+
+
+def check_source_provenance(
+ manifest: dict, report: Report, require_network: bool
+) -> None:
+ for role, component in manifest.get("components", {}).items():
+ provenance = component.get("provenance", {})
+ url = GITHUB_COMMIT_URL_TEMPLATE.format(
+ repository=COMPONENT_REPOSITORIES[role],
+ release_ref=_release_ref(role, str(component.get("version"))),
+ )
+ try:
+ doc = _fetch_json(url)
+ except (urllib.error.URLError, TimeoutError) as exc:
+ emit = report.error if require_network else report.warning
+ emit(f"could not validate source provenance for {role}: {exc}")
+ continue
+ commit = doc.get("sha")
+ tree = doc.get("commit", {}).get("tree", {}).get("sha")
+ if provenance.get("commit") != commit:
+ report.error(
+ f"{role} source commit drift: manifest has "
+ f"{provenance.get('commit')!r}, release ref resolves to {commit!r}"
+ )
+ if provenance.get("tree") != tree:
+ report.error(
+ f"{role} source tree drift: manifest has {provenance.get('tree')!r}, "
+ f"release ref resolves to {tree!r}"
+ )
+
+
+def check_schema_sources(
+ manifest: dict, report: Report, require_network: bool
+) -> None:
+ try:
+ expected = _schema_compatibility(manifest.get("components", {}))
+ except (DriftError, KeyError, TypeError) as exc:
+ emit = report.error if require_network else report.warning
+ emit(f"could not validate exact schema sources: {exc}")
+ return
+ if manifest.get("schema_compatibility") != expected:
+ report.error(
+ "schema_compatibility drift: it does not match the schemas derived "
+ "from the exact selected release commits"
+ )
+
+
+def check_qualification_evidence(
+ manifest: dict, report: Report, require_network: bool
+) -> None:
+ components = manifest.get("components", {})
+ trees: dict[tuple[str, str], dict[str, dict]] = {}
+ for evidence in manifest.get("qualification_evidence", []):
+ role = evidence.get("source_component")
+ if role is None:
+ continue
+ component = components.get(role, {})
+ provenance = component.get("provenance", {})
+ key = (str(provenance.get("repository")), str(provenance.get("tree")))
+ try:
+ if key not in trees:
+ trees[key] = _github_tree_entries(*key)
+ except DriftError as exc:
+ emit = report.error if require_network else report.warning
+ emit(f"could not validate qualification evidence: {exc}")
+ continue
+ source_object = trees[key].get(evidence.get("path"))
+ if source_object is None:
+ report.error(
+ f"qualification evidence {evidence.get('id')!r} path is absent "
+ "from the exact selected release tree"
+ )
+ continue
+ if evidence.get("source_object_type") != source_object.get("type"):
+ report.error(
+ f"qualification evidence {evidence.get('id')!r} object type drift"
+ )
+ if evidence.get("source_object_sha") != source_object.get("sha"):
+ report.error(
+ f"qualification evidence {evidence.get('id')!r} object SHA drift"
+ )
def check_against_status(manifest: dict, report: Report, strict: bool) -> None:
try:
status = _fetch_json(STATUS_URL)
except (urllib.error.URLError, TimeoutError) as exc:
- report.warning(f"could not fetch {STATUS_URL}: {exc}")
+ emit = report.error if strict else report.warning
+ emit(f"could not fetch {STATUS_URL}: {exc}")
return
status_versions = status.get("versions", {})
emit = report.error if strict else report.warning
@@ -417,6 +1003,78 @@ def check_against_status(manifest: dict, report: Report, strict: bool) -> None:
f"{manifest.get('release_channel')!r}, status.json lifecycle is "
f"{lifecycle!r}"
)
+ try:
+ expected_os = _supported_os_from_status(status)
+ expected_substrates = _substrates_from_status(status)
+ except DriftError as exc:
+ report.error(str(exc))
+ return
+ if manifest.get("supported_os") != expected_os:
+ report.error(
+ f"supported_os drift: manifest has {manifest.get('supported_os')!r}, "
+ f"status.json implies {expected_os!r}"
+ )
+ if manifest.get("substrate_drivers") != expected_substrates:
+ report.error(
+ "substrate_drivers drift: manifest does not match the complete "
+ "status.json substrate projection"
+ )
+
+
+def _locked_openadapt_versions(lock_text: str, packages: set[str]) -> dict[str, str]:
+ lock = tomllib.loads(lock_text)
+ resolved: dict[str, str] = {}
+ for package in lock.get("package", []):
+ name = package.get("name")
+ version = package.get("version")
+ if name not in packages or not isinstance(version, str):
+ continue
+ if name in resolved and resolved[name] != version:
+ raise ValueError(f"native lock resolves multiple {name} versions")
+ resolved[name] = version
+ return dict(sorted(resolved.items()))
+
+
+def check_desktop_sidecar_lock(
+ manifest: dict, report: Report, require_network: bool
+) -> None:
+ unit = manifest.get("runtime_units", {}).get("desktop_sidecar", {})
+ version = unit.get("version")
+ source_ref = unit.get("source_ref")
+ expected_ref = f"desktop-v{version}"
+ if source_ref != expected_ref:
+ report.error(
+ f"desktop_sidecar source_ref is {source_ref!r}, expected {expected_ref!r}"
+ )
+ return
+ url = DESKTOP_NATIVE_LOCK_URL_TEMPLATE.format(version=version)
+ try:
+ lock_text = _fetch_text(url)
+ resolved = _locked_openadapt_versions(
+ lock_text, set(unit.get("requirements", {}))
+ )
+ except (
+ urllib.error.URLError,
+ UnicodeDecodeError,
+ TimeoutError,
+ tomllib.TOMLDecodeError,
+ ValueError,
+ ) as exc:
+ emit = report.error if require_network else report.warning
+ emit(f"could not validate the Desktop sidecar lock at {source_ref}: {exc}")
+ return
+ actual_lock_sha256 = hashlib.sha256(lock_text.encode("utf-8")).hexdigest()
+ if actual_lock_sha256 != unit.get("lock_sha256"):
+ report.error(
+ f"desktop_sidecar lock sha256 drift: manifest has "
+ f"{unit.get('lock_sha256')!r} but {source_ref} has "
+ f"{actual_lock_sha256!r}"
+ )
+ if resolved != unit.get("resolved"):
+ report.error(
+ f"desktop_sidecar closure drift: manifest has {unit.get('resolved')!r} "
+ f"but {source_ref} uv.lock has {resolved!r}"
+ )
def main() -> int:
@@ -424,6 +1082,21 @@ def main() -> int:
parser.add_argument(
"--manifest", type=Path, default=ROOT / "platform-manifest.json"
)
+ parser.add_argument(
+ "--report",
+ type=Path,
+ default=REPORT_PATH,
+ help="Generated report paired with --manifest.",
+ )
+ parser.add_argument(
+ "--require-compatible",
+ action="store_true",
+ help=(
+ "Fail unless the exact runner and sidecar closures satisfy the "
+ "selected platform requirements. Use this at platform "
+ "promotion, signing, and installer release gates."
+ ),
+ )
parser.add_argument(
"--offline",
action="store_true",
@@ -458,9 +1131,32 @@ def main() -> int:
check_structure(manifest, report)
check_signature_honesty(manifest, report)
check_against_pyproject(manifest, report)
+ check_compatibility_status(manifest, report)
+ check_generated_report(manifest, report, args.report)
+ if (
+ args.require_compatible
+ and manifest.get("compatibility_status", {}).get("status")
+ != "dependency-compatible"
+ ):
+ report.error(
+ "the exact platform BOM is incompatible; resolve every "
+ "compatibility_status failure before platform promotion"
+ )
if not args.offline:
check_against_pypi(manifest, report, require_network=args.require_network)
+ check_source_provenance(
+ manifest, report, require_network=args.require_network
+ )
+ check_schema_sources(
+ manifest, report, require_network=args.require_network
+ )
+ check_qualification_evidence(
+ manifest, report, require_network=args.require_network
+ )
check_against_status(manifest, report, strict=args.strict_status)
+ check_desktop_sidecar_lock(
+ manifest, report, require_network=args.require_network
+ )
for warning in report.warnings:
print(f"WARNING: {warning}")
diff --git a/tests/test_platform_manifest_drift.py b/tests/test_platform_manifest_drift.py
index 35de2daff..066f2d135 100644
--- a/tests/test_platform_manifest_drift.py
+++ b/tests/test_platform_manifest_drift.py
@@ -30,8 +30,10 @@
from __future__ import annotations
+import hashlib
import json
import sys
+import urllib.error
from copy import deepcopy
from pathlib import Path
@@ -40,8 +42,23 @@
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
+from generate_platform_manifest import ( # noqa: E402
+ DriftError,
+ _compatibility_status,
+ _dependency_edges,
+ _parse_component_versions,
+ _runtime_unit_requirements,
+ _version_satisfies,
+)
from validate_platform_manifest import ( # noqa: E402
Report,
+ _locked_openadapt_versions,
+ check_against_pypi,
+ check_against_status,
+ check_compatibility_status,
+ check_desktop_sidecar_lock,
+ check_signature_honesty,
+ check_structure,
compare_component_to_pypi,
compare_launcher_to_pyproject,
)
@@ -74,8 +91,19 @@ def pypi_doc(version: str, component: dict, latest: str | None = None) -> dict:
}
for artifact in component["artifacts"]
]
+ requires_dist = []
+ for constraint in component.get("dependency_constraints", []):
+ extras = f"[{constraint['extras']}]" if constraint.get("extras") else ""
+ marker = f"; {constraint['marker']}" if constraint.get("marker") else ""
+ requires_dist.append(
+ f"{constraint['package']}{extras}{constraint['requires']}{marker}"
+ )
return {
- "info": {"version": latest or version},
+ "info": {
+ "version": latest or version,
+ "requires_dist": requires_dist,
+ "requires_python": component.get("requires_python"),
+ },
"urls": files,
"releases": {version: files},
}
@@ -136,6 +164,48 @@ def test_guard_fails_when_a_newer_release_supersedes_the_manifest(
assert any(future_version in error for error in errors)
+def test_explicit_published_selection_can_pin_an_older_exact_release(
+ flow_component: dict,
+) -> None:
+ future_version = "99.0.0"
+ doc = pypi_doc(flow_component["version"], flow_component, latest=future_version)
+ report = Report()
+
+ compare_component_to_pypi(
+ "flow", flow_component, doc, report, allow_pinned=True
+ )
+
+ assert report.errors == []
+ assert any("explicit platform selection pins" in item for item in report.warnings)
+
+
+def test_explicit_pin_uses_its_version_specific_metadata(
+ flow_component: dict, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ selected_doc = pypi_doc(flow_component["version"], flow_component)
+ latest_doc = pypi_doc(flow_component["version"], flow_component, latest="99.0.0")
+ latest_doc["info"]["requires_dist"] = ["openadapt-types>=99"]
+
+ monkeypatch.setattr(
+ "validate_platform_manifest._fetch_json",
+ lambda url: selected_doc if f"/{flow_component['version']}/" in url else latest_doc,
+ )
+ report = Report()
+ check_against_pypi(
+ {
+ "release_selection": {
+ "component_versions": {"flow": flow_component["version"]}
+ },
+ "components": {"flow": flow_component},
+ },
+ report,
+ require_network=True,
+ )
+
+ assert report.errors == []
+ assert report.warnings
+
+
def test_guard_fails_on_a_tampered_digest(flow_component: dict) -> None:
"""A correct version with a wrong digest must still fail.
@@ -159,6 +229,37 @@ def test_guard_fails_on_a_tampered_url(flow_component: dict) -> None:
assert any("URL drift" in error for error in errors)
+@pytest.mark.parametrize(
+ ("mutation", "message"),
+ [
+ (lambda component: component["artifacts"].pop(), "filename-set drift"),
+ (
+ lambda component: component["artifacts"][0].update(type="invented"),
+ "type drift",
+ ),
+ (
+ lambda component: component.update(requires_python=">=99"),
+ "requires_python drift",
+ ),
+ ],
+)
+def test_guard_checks_complete_artifact_metadata(
+ flow_component: dict, mutation, message: str
+) -> None:
+ doc = pypi_doc(flow_component["version"], flow_component)
+ mutation(flow_component)
+ assert any(message in error for error in errors_for(flow_component, doc))
+
+
+def test_guard_fails_on_a_tampered_dependency_range(flow_component: dict) -> None:
+ doc = pypi_doc(flow_component["version"], flow_component)
+ tampered = deepcopy(flow_component)
+ tampered["requires"]["openadapt-types"] = [">=0.1.0"]
+
+ errors = errors_for(tampered, doc)
+ assert any("dependency-range drift" in error for error in errors)
+
+
def test_guard_fails_on_a_version_pypi_never_published(
flow_component: dict,
) -> None:
@@ -240,15 +341,192 @@ def test_matching_launcher_versions_are_silent() -> None:
def test_every_manifest_component_is_covered(manifest: dict) -> None:
- """All four published components are guarded, not just flow."""
+ """All seven published components are guarded, not just flow."""
assert set(manifest["components"]) == {
"launcher",
"flow",
"capture",
+ "privacy",
+ "types",
"desktop",
+ "agent",
}
for role, component in manifest["components"].items():
doc = pypi_doc(component["version"], component)
report = Report()
compare_component_to_pypi(role, component, doc, report)
assert report.errors == [], f"{role} failed self-consistency"
+
+
+def test_runtime_compatibility_status_is_recomputed(manifest: dict) -> None:
+ truthful = Report()
+ check_compatibility_status(manifest, truthful)
+ assert truthful.errors == []
+
+ tampered = deepcopy(manifest)
+ tampered["compatibility_status"] = {
+ "status": "dependency-incompatible",
+ "basis": "published-metadata-and-desktop-lock",
+ "failures": [{"source_role": "invented"}],
+ }
+ report = Report()
+ check_compatibility_status(tampered, report)
+ assert any("compatibility_status" in error for error in report.errors)
+
+
+def test_runtime_requirements_come_from_selected_release_metadata(manifest: dict) -> None:
+ expected = _runtime_unit_requirements(manifest["components"])
+ actual = {
+ name: unit["requirements"]
+ for name, unit in manifest["runtime_units"].items()
+ }
+ assert actual == expected
+
+ assert len(manifest["dependency_edges"]) == 11
+ components = deepcopy(manifest["components"])
+ components["capture"]["version"] = "0.0.1"
+ edges = _dependency_edges(components, manifest["runtime_units"])
+ status = _compatibility_status(edges)
+ assert status["status"] == "dependency-incompatible"
+ assert any(
+ failure["source_role"] == "launcher"
+ and failure["package"] == "openadapt-capture"
+ for failure in status["failures"]
+ )
+
+
+def test_version_range_comparison_is_closed() -> None:
+ assert _version_satisfies("1.2.0", ">=1.2.0,<2.0.0")
+ assert _version_satisfies("1.9.9", ">=1.2.0,<2.0.0")
+ assert not _version_satisfies("1.1.99", ">=1.2.0,<2.0.0")
+ assert not _version_satisfies("2.0.0", ">=1.2.0,<2.0.0")
+
+
+def test_release_train_uses_explicit_published_version_inputs() -> None:
+ assert _parse_component_versions(
+ ["flow=9.8.7", "desktop=6.5.4"]
+ ) == {"flow": "9.8.7", "desktop": "6.5.4"}
+ with pytest.raises(DriftError, match="ROLE=VERSION"):
+ _parse_component_versions(["unknown=1.0.0"])
+
+
+def test_schema_ranges_are_closed_and_exact(manifest: dict) -> None:
+ schemas = manifest["schema_compatibility"]
+ assert schemas["workflow_bundle"]["accepted_versions"] == [1, 2]
+ assert schemas["capture_structural_observation"]["schema_prefix"] == (
+ "openadapt.capture.structural-observation/v"
+ )
+ assert schemas["human_decision_task"]["minimum"] == 1
+ assert schemas["human_decision_task"]["maximum"] == 1
+ assert schemas["human_decision_receipt"]["maximum"] == 1
+ assert schemas["control_overlay_frame"]["accepted_versions"] == [1, 2]
+ assert "human_decision_relay" not in schemas
+ assert schemas["remote_decision_projection"]["accepted_versions"] == [1]
+ assert schemas["runtime_validation"]["minimum"] == 1
+ assert schemas["runtime_validation"]["maximum"] == 2
+
+ report = Report()
+ check_structure(manifest, report)
+ assert report.errors == []
+
+ tampered = deepcopy(manifest)
+ tampered["schema_compatibility"]["human_decision_receipt"]["maximum"] = 2
+ report = Report()
+ check_structure(tampered, report)
+ assert any("range does not match" in error for error in report.errors)
+
+
+def test_status_contract_rejects_fabricated_substrates_and_strict_unreachable(
+ manifest: dict, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ status = {
+ "product_lifecycle": manifest["release_channel"],
+ "versions": {
+ role: component["version"]
+ for role, component in manifest["components"].items()
+ },
+ "substrates": deepcopy(manifest["substrate_drivers"]),
+ }
+ tampered = deepcopy(manifest)
+ tampered["substrate_drivers"][0]["public_label"] = "Invented"
+ monkeypatch.setattr("validate_platform_manifest._fetch_json", lambda _url: status)
+ report = Report()
+ check_against_status(tampered, report, strict=False)
+ assert any("substrate_drivers drift" in error for error in report.errors)
+
+ monkeypatch.setattr(
+ "validate_platform_manifest._fetch_json",
+ lambda _url: (_ for _ in ()).throw(urllib.error.URLError("offline")),
+ )
+ report = Report()
+ check_against_status(manifest, report, strict=True)
+ assert report.errors
+
+
+@pytest.mark.parametrize(
+ "mutate",
+ [
+ lambda value: value["qualification_evidence"][1].update(
+ source_object_sha="0" * 39
+ ),
+ lambda value: value["qualification_evidence"].pop(),
+ lambda value: value["schema_compatibility"]["runtime_validation"].update(
+ source_commit="0" * 40
+ ),
+ lambda value: value["signature"].update(algorithm="invented"),
+ lambda value: value.update(generated_at="not-a-time"),
+ lambda value: value.update(release_channel="invented"),
+ ],
+)
+def test_structural_provenance_claims_fail_closed(manifest: dict, mutate) -> None:
+ tampered = deepcopy(manifest)
+ mutate(tampered)
+ report = Report()
+ check_structure(tampered, report)
+ check_signature_honesty(tampered, report)
+ assert report.errors
+
+
+def test_native_lock_parser_returns_only_exact_platform_packages() -> None:
+ lock = """
+version = 1
+
+[[package]]
+name = "openadapt-flow"
+version = "1.2.3"
+
+[[package]]
+name = "openadapt-types"
+version = "0.4.5"
+
+[[package]]
+name = "unrelated"
+version = "9.9.9"
+"""
+ assert _locked_openadapt_versions(lock, {"openadapt-flow", "openadapt-types"}) == {
+ "openadapt-flow": "1.2.3",
+ "openadapt-types": "0.4.5",
+ }
+
+
+def test_native_lock_digest_is_verified(
+ manifest: dict, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ resolved = manifest["runtime_units"]["desktop_sidecar"]["resolved"]
+ lock = "version = 1\n" + "".join(
+ f'\n[[package]]\nname = "{package}"\nversion = "{version}"\n'
+ for package, version in resolved.items()
+ )
+ expected_hash = hashlib.sha256(lock.encode()).hexdigest()
+ manifest = deepcopy(manifest)
+ manifest["runtime_units"]["desktop_sidecar"]["lock_sha256"] = expected_hash
+ monkeypatch.setattr("validate_platform_manifest._fetch_text", lambda _url: lock)
+
+ report = Report()
+ check_desktop_sidecar_lock(manifest, report, require_network=True)
+ assert report.errors == []
+
+ manifest["runtime_units"]["desktop_sidecar"]["lock_sha256"] = "0" * 64
+ report = Report()
+ check_desktop_sidecar_lock(manifest, report, require_network=True)
+ assert any("lock sha256 drift" in error for error in report.errors)
diff --git a/tests/test_platform_version_display.py b/tests/test_platform_version_display.py
new file mode 100644
index 000000000..88092c9c2
--- /dev/null
+++ b/tests/test_platform_version_display.py
@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(REPO_ROOT / "scripts"))
+
+from render_platform_versions import render_markdown, version_rows # noqa: E402
+
+
+def test_version_display_is_derived_from_the_platform_bom() -> None:
+ manifest = json.loads((REPO_ROOT / "platform-manifest.json").read_text())
+ rows = version_rows(manifest)
+
+ assert [row["role"] for row in rows] == [
+ "launcher",
+ "flow",
+ "capture",
+ "privacy",
+ "types",
+ "desktop",
+ "agent",
+ "launcher_environment",
+ "customer_runner",
+ "desktop_sidecar",
+ "agent_bridge",
+ ]
+ by_role = {row["role"]: row for row in rows}
+ assert by_role["flow"]["version"] == manifest["components"]["flow"]["version"]
+ assert by_role["desktop_sidecar"]["resolved"] == ", ".join(
+ f"{package}=={version}"
+ for package, version in sorted(
+ manifest["runtime_units"]["desktop_sidecar"]["resolved"].items()
+ )
+ )
+
+
+def test_version_display_changes_when_the_manifest_changes() -> None:
+ manifest = json.loads((REPO_ROOT / "platform-manifest.json").read_text())
+ manifest["components"]["flow"]["version"] = "9.8.7"
+
+ by_role = {row["role"]: row for row in version_rows(manifest)}
+ assert by_role["flow"]["version"] == "9.8.7"
+
+
+def test_human_report_is_generated_from_the_exact_bom() -> None:
+ manifest = json.loads((REPO_ROOT / "platform-manifest.json").read_text())
+ report = REPO_ROOT / "docs" / "platform-compatibility-report.md"
+
+ text = report.read_text()
+ assert text == render_markdown(manifest)
+ assert f"`{manifest['components']['agent']['version']}`" in text
+ assert all(row["name"] in text for row in manifest["substrate_drivers"])
+ assert all(edge["target_package"] in text for edge in manifest["dependency_edges"])