From e5fdd850c4185e6c7eb12ea7f618160ef7283a0f Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 26 Aug 2026 15:40:51 -0400 Subject: [PATCH 1/2] fix: derive public product state from admissions --- .github/workflows/production-lifecycle.yml | 39 + README.md | 10 +- components/Pricing.js | 12 +- components/ProductStatus.js | 8 +- components/StructuredData.js | 2 +- data/workflowCatalog.js | 29 +- lib/productionLifecycleEndpoint.js | 88 ++ lib/publicJsonArtifacts.mjs | 20 + package.json | 1 + pages/api/production-lifecycle-state.js | 18 + pages/download.js | 13 +- pages/security.js | 76 +- pages/start.js | 2 +- pages/terms-of-service.js | 6 +- pages/workflows.js | 18 +- production-lifecycle-source.json | 47 ++ public/llms-full.txt | 32 +- public/llms.txt | 21 +- public/production-lifecycle.js | 765 ++++++++++++++++++ public/production-lifecycle.json | 201 +++++ public/robots.txt | 3 +- .../production-lifecycle-public.schema.json | 136 ++++ public/status.json | 44 +- scripts/render_production_lifecycle.py | 303 +++++++ tests/aiDiscoverability.test.js | 23 +- tests/desktopRelease.test.js | 8 - tests/jsonArtifacts.test.js | 6 +- tests/productionLifecycle.test.js | 599 ++++++++++++++ tests/productionLifecycleEndpoint.test.js | 159 ++++ tests/publicTruth.test.js | 2 +- tests/statusManifest.test.js | 86 +- tests/workflowCatalog.test.js | 28 +- 32 files changed, 2630 insertions(+), 175 deletions(-) create mode 100644 .github/workflows/production-lifecycle.yml create mode 100644 lib/productionLifecycleEndpoint.js create mode 100644 pages/api/production-lifecycle-state.js create mode 100644 production-lifecycle-source.json create mode 100644 public/production-lifecycle.js create mode 100644 public/production-lifecycle.json create mode 100644 public/schemas/production-lifecycle-public.schema.json create mode 100644 scripts/render_production_lifecycle.py create mode 100644 tests/productionLifecycle.test.js create mode 100644 tests/productionLifecycleEndpoint.test.js diff --git a/.github/workflows/production-lifecycle.yml b/.github/workflows/production-lifecycle.yml new file mode 100644 index 00000000..bddb4ba2 --- /dev/null +++ b/.github/workflows/production-lifecycle.yml @@ -0,0 +1,39 @@ +name: Production lifecycle projection + +# The local browser contract runs in npm test. This small network check runs +# daily and when the projection contract changes. It fetches exact pinned +# canonical inputs, verifies their hashes, runs the pinned organization +# validator, and refuses committed-output drift. + +on: + schedule: + - cron: '40 13 * * *' + workflow_dispatch: + pull_request: + paths: + - 'production-lifecycle-source.json' + - 'public/production-lifecycle.json' + - 'public/schemas/production-lifecycle-public.schema.json' + - 'public/production-lifecycle.js' + - 'scripts/render_production_lifecycle.py' + - 'tests/productionLifecycle.test.js' + - '.github/workflows/production-lifecycle.yml' + +permissions: + contents: read + +concurrency: + group: production-lifecycle-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.12' + - name: Verify the canonical projection + run: python -S scripts/render_production_lifecycle.py --check diff --git a/README.md b/README.md index 7f285ce7..ca287509 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # openadapt-web -**Lifecycle: Beta** +**Lifecycle: Support** Source for the [openadapt.ai](https://openadapt.ai) marketing site. @@ -19,10 +19,10 @@ product documentation lives at [docs.openadapt.ai](https://docs.openadapt.ai). ## Technical status data `public/status.json` is the canonical, machine-readable source of truth for -substrate lifecycle evidence and component versions, and -`tests/statusManifest.test.js` guards it. The marketing homepage deliberately -does not render this temporary maturity ledger; technical consumers can read the -manifest directly. +released component versions and substrate capability evidence. +`public/production-lifecycle.json` is the separate projection of the canonical +signed admission ledger. It derives Production at read time. The current ledger +is empty, so all seven product targets are not actively admitted. ## Tech stack diff --git a/components/Pricing.js b/components/Pricing.js index ea62170b..01b0f69c 100644 --- a/components/Pricing.js +++ b/components/Pricing.js @@ -4,7 +4,6 @@ import Link from 'next/link' import TierSelector from '@components/TierSelector' import { track, EVENTS } from 'utils/analytics' -import status from '../public/status.json' const { LAUNCH_MONTHLY_RUN_CAP, @@ -16,15 +15,10 @@ const { /* * Public offer terms come from the same code-owned launch contract that * validates Stripe. Checkout still remains unavailable unless the configured - * live Product and Price match that contract exactly. The hosted maturity label is - * read from the canonical status manifest (public/status.json) so it can never - * drift from the single source of truth. + * live Product and Price match that contract exactly. The badge describes the + * managed-browser offer. It does not assign a Production state. */ -const hostedStatusLabel = - status.substrates.find((substrate) => substrate.name === 'Hosted Cloud') - ?.public_label || 'Beta' - function Check() { return ( - {hostedStatusLabel} + Managed browser {runCapLabel && (

- Browser runs in production today; desktop, RDP, and Citrix - run through customer-controlled qualification. Each surface - carries its own measured acceptance evidence, published per - surface in the{' '} + Browser supports the complete record, compile, replay, and + verify loop. Desktop, RDP, and Citrix use the same workflow + contract through customer-controlled qualification. Each + surface carries its own measured acceptance evidence in the{' '} ['Windows', 'macOS', 'Linux'].includes(substrate.name) ) - .filter((substrate) => substrate.public_label === 'Available') + .filter((substrate) => substrate.capability_state === 'released') .map((substrate) => substrate.name) export const softwareApplicationNode = { diff --git a/data/workflowCatalog.js b/data/workflowCatalog.js index 68508dbf..dc8a1176 100644 --- a/data/workflowCatalog.js +++ b/data/workflowCatalog.js @@ -8,17 +8,16 @@ const FLOW_REPO = 'https://github.com/OpenAdaptAI/openadapt-flow' -// Canonical substrate availability labels, reconciled to public/status.json. -// Each released backend is available behind the same governed loop; its exact -// evidence scope and deployment boundary live in the status manifest. The -// catalog entries below all run through the browser substrate. -export const SUBSTRATE_MATURITY = { - browser: 'Available', - windows: 'Available', - macos: 'Available', - linux: 'Available', - rdp: 'Available', - citrix: 'Available', +// Canonical released-capability labels, reconciled to public/status.json. +// Production admission is a separate read-time decision. The catalog entries +// below all run through the browser substrate. +export const SUBSTRATE_AVAILABILITY = { + browser: 'Released', + windows: 'Released', + macos: 'Released', + linux: 'Released', + rdp: 'Released', + citrix: 'Released', } // Shared honesty envelope. Every catalog entry sets these explicitly so the @@ -44,7 +43,7 @@ export const CATALOG = [ 'official image sha256:0aa4d3d5…f19f3ab, MariaDB 11.8.', substrate: 'browser', substrateLabel: 'Browser (Playwright)', - maturity: SUBSTRATE_MATURITY.browser, + availability: SUBSTRATE_AVAILABILITY.browser, task: 'Create exactly one fictional patient (name, DOB, sex, address, ' + 'phone, and a reserved example.invalid email) and save it.', @@ -126,7 +125,7 @@ export const CATALOG = [ 'frappe_docker c004361e…a8960.', substrate: 'browser', substrateLabel: 'Browser (Playwright)', - maturity: SUBSTRATE_MATURITY.browser, + availability: SUBSTRATE_AVAILABILITY.browser, task: 'Create exactly one synthetic Loan Application — product ' + '“OpenAdapt Synthetic Term Loan”, amount 125000, 18 repayment ' + @@ -208,7 +207,7 @@ export const CATALOG = [ 'sha256:d4bc881e…adf29d; distribution commit cd6220d1…d0caa5.', substrate: 'browser', substrateLabel: 'Browser (Playwright)', - maturity: SUBSTRATE_MATURITY.browser, + availability: SUBSTRATE_AVAILABILITY.browser, // openIMIS is explicitly a reference/demo environment, not a benchmark. evidenceKind: 'reference demonstration (not a trial matrix)', task: @@ -283,7 +282,7 @@ export const REQUIRED_ENTRY_FIELDS = [ 'application', 'version', 'substrate', - 'maturity', + 'availability', 'task', 'recording', 'policy', diff --git a/lib/productionLifecycleEndpoint.js b/lib/productionLifecycleEndpoint.js new file mode 100644 index 00000000..53fb215b --- /dev/null +++ b/lib/productionLifecycleEndpoint.js @@ -0,0 +1,88 @@ +export const AUTHORITY_FETCH_TIMEOUT_MS = 4000 + +export function createServerFetch({ + fetchImpl = globalThis.fetch, + timeoutMs = AUTHORITY_FETCH_TIMEOUT_MS, + githubToken = process.env.GITHUB_TOKEN, +} = {}) { + if (typeof fetchImpl !== 'function') { + throw new TypeError('fetchImpl must be a function') + } + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new TypeError('timeoutMs must be a positive number') + } + + return async function serverFetch(url, options = {}) { + const parsed = new URL(url) + const headers = new Headers(options.headers || {}) + headers.set('User-Agent', 'OpenAdapt-Web/1.0 (https://openadapt.ai)') + if (parsed.hostname === 'api.github.com') { + headers.set('Accept', 'application/vnd.github+json') + if (githubToken) { + headers.set('Authorization', `Bearer ${githubToken}`) + } + } + + const controller = new AbortController() + const fetchPromise = fetchImpl(url, { + ...options, + headers, + signal: controller.signal, + }) + let timeoutId + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + const error = new Error('Lifecycle authority fetch timed out') + controller.abort(error) + reject(error) + }, timeoutMs) + }) + try { + return await Promise.race([fetchPromise, timeoutPromise]) + } finally { + clearTimeout(timeoutId) + } + } +} + +export function createSingleflightLoader(loadState) { + if (typeof loadState !== 'function') { + throw new TypeError('loadState must be a function') + } + + let inFlight = null + return function loadSingleflight() { + if (inFlight) return inFlight + + const derivation = Promise.resolve().then(loadState) + const settled = derivation.finally(() => { + if (inFlight === settled) inFlight = null + }) + inFlight = settled + return settled + } +} + +export function createProductionLifecycleHandler({ loadState, serializeState }) { + if (typeof loadState !== 'function' || typeof serializeState !== 'function') { + throw new TypeError('loadState and serializeState must be functions') + } + + return async function handler(request, response) { + response.setHeader('Cache-Control', 'private, no-store, max-age=0') + response.setHeader('Netlify-CDN-Cache-Control', 'no-store') + + if (request.method !== 'GET') { + response.setHeader('Allow', 'GET') + return response.status(405).json({ error: 'Method not allowed' }) + } + + let state = null + try { + state = await loadState() + } catch (_error) { + state = null + } + return response.status(200).json(serializeState(state)) + } +} diff --git a/lib/publicJsonArtifacts.mjs b/lib/publicJsonArtifacts.mjs index 4cd44dbb..c325319e 100644 --- a/lib/publicJsonArtifacts.mjs +++ b/lib/publicJsonArtifacts.mjs @@ -12,6 +12,26 @@ export const MAX_JSON_SEARCH_RESULTS = 100 * artifact requires an intentional registry update in the same review. */ export const PUBLIC_JSON_ARTIFACTS = Object.freeze({ + '/production-lifecycle.json': Object.freeze({ + source: '/production-lifecycle.json', + fileName: 'production-lifecycle.json', + format: 'json', + title: 'OpenAdapt Production admission projection', + description: + 'The read-time Production projection for all seven product targets, bound to exact canonical source commits and hashes.', + // The projection changes when the admission ledger changes. Its own + // source inventory binds every canonical input by SHA-256. + sha256: null, + }), + '/schemas/production-lifecycle-public.schema.json': Object.freeze({ + source: '/schemas/production-lifecycle-public.schema.json', + fileName: 'production-lifecycle-public.schema.json', + format: 'json', + title: 'Production lifecycle projection schema', + description: + 'The exact JSON Schema for the public Production admission projection.', + sha256: 'ac0ee5ca860aa264debe8f3abdbb3c67b214c9fdfae9a55bd21557ac9a0ab64f', + }), '/status.json': Object.freeze({ source: '/status.json', fileName: 'status.json', diff --git a/package.json b/package.json index c32bb275..2a499e82 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "scripts": { "dev": "next dev", + "check:lifecycle": "python3 -S scripts/render_production_lifecycle.py --check", "check:versions": "node scripts/check_published_version_claims.mjs", "fetch:paper": "node scripts/fetch-paper-pdf.js", "verify:presentations": "node scripts/verify-reference-presentations.mjs", diff --git a/pages/api/production-lifecycle-state.js b/pages/api/production-lifecycle-state.js new file mode 100644 index 00000000..44b91f38 --- /dev/null +++ b/pages/api/production-lifecycle-state.js @@ -0,0 +1,18 @@ +import projection from '../../public/production-lifecycle.json' +import { + createProductionLifecycleHandler, + createServerFetch, + createSingleflightLoader, +} from '../../lib/productionLifecycleEndpoint' + +const lifecycle = require('../../public/production-lifecycle.js') + +const serverFetch = createServerFetch() +const loadProductionLifecycleState = createSingleflightLoader(() => + lifecycle.loadCanonical(serverFetch, projection, Date.now()) +) + +export default createProductionLifecycleHandler({ + loadState: loadProductionLifecycleState, + serializeState: lifecycle.serializeState, +}) diff --git a/pages/download.js b/pages/download.js index 3191ca09..f3e0edc2 100644 --- a/pages/download.js +++ b/pages/download.js @@ -61,8 +61,7 @@ export default function DownloadPage({ release, fetchFailed }) { const assets = release && Array.isArray(release.assets) ? release.assets : [] const lifecycle = release?.lifecycle === 'beta' ? 'beta' : 'experimental' - const releaseLabel = - lifecycle === 'beta' ? 'Beta release' : 'Desktop release' + const releaseLabel = 'Desktop release' // Resolve a download for each platform card. const platformDownloads = useMemo( @@ -103,7 +102,7 @@ export default function DownloadPage({ release, fetchFailed }) { Install OpenAdapt | OpenAdapt {' '} installs the compiler and governed runtime under the unified{' '} openadapt flow command. Native OpenAdapt - Desktop Beta installers package that workflow engine for + Desktop installers package that workflow engine for Windows, macOS, and Linux.

@@ -164,7 +163,7 @@ export default function DownloadPage({ release, fetchFailed }) {
                 

Desktop app

- Native desktop Beta + Native desktop app

{status === 'ready' && recommended && (
@@ -267,7 +266,7 @@ export default function DownloadPage({ release, fetchFailed }) { No complete native desktop release yet

- The next complete Beta set has not been + The next complete installer set has not been published yet. Use{' '} pip install openadapt, or watch the desktop releases page for the native build. diff --git a/pages/security.js b/pages/security.js index 03867c3d..330d883f 100644 --- a/pages/security.js +++ b/pages/security.js @@ -1,5 +1,6 @@ import Head from 'next/head' import Link from 'next/link' +import Script from 'next/script' import Footer from '@components/Footer' import TrustProviderInventory from '@components/TrustProviderInventory' @@ -24,6 +25,16 @@ const CONTROL_OVERLAY_URL = `https://github.com/OpenAdaptAI/openadapt-desktop/bl // There is no security@openadapt.ai mailbox today — do not advertise one. const CONTACT_EMAIL = 'hello@openadapt.ai' +const productionTargets = [ + ['openadapt', 'OpenAdapt'], + ['flow', 'Flow'], + ['cloud', 'Cloud'], + ['desktop', 'Desktop'], + ['capture', 'Capture'], + ['agent', 'Agent'], + ['docs', 'Documentation'], +] + const webPageSchema = { '@context': 'https://schema.org', '@type': 'WebPage', @@ -353,7 +364,7 @@ const releaseIntegrity = [ { title: 'Build provenance & attestations', status: 'yes', - body: `The PyPI engine publishes wheel and sdist with PEP 740 publish attestations. Desktop Beta ${DESKTOP_VERSION} publishes a SHA256SUMS manifest and GitHub build-provenance attestations for every installer, metadata file, and SBOM. Provenance answers "was this built from our source by our CI"; it is not the same as code signing.`, + body: `The PyPI engine publishes wheel and sdist with PEP 740 publish attestations. Desktop ${DESKTOP_VERSION} publishes a SHA256SUMS manifest and GitHub build-provenance attestations for every installer, metadata file, and SBOM. Provenance answers "was this built from our source by our CI"; it is not the same as code signing.`, evidenceUrl: NATIVE_RELEASE_URL, evidenceLabel: 'Inspect the public checksums and attestations', }, @@ -365,14 +376,14 @@ const releaseIntegrity = [ { title: 'Software bill of materials (SBOM)', status: 'yes', - body: `Desktop Beta ${DESKTOP_VERSION} publishes a CycloneDX JSON SBOM generated from the exact smoke-tested installer set. The release workflow rejects an empty or malformed SBOM before publication, includes its hash in SHA256SUMS, and attests the SBOM alongside the installers.`, + body: `Desktop ${DESKTOP_VERSION} publishes a CycloneDX JSON SBOM generated from the exact smoke-tested installer set. The release workflow rejects an empty or malformed SBOM before publication, includes its hash in SHA256SUMS, and attests the SBOM alongside the installers.`, evidenceUrl: NATIVE_RELEASE_URL, evidenceLabel: 'Download the public CycloneDX SBOM', }, { title: 'Execution control overlay', status: 'yes', - body: `Desktop Beta ${DESKTOP_VERSION} provides a separate native status surface outside the target application. During active observation and execution it is click-through, non-focusable, and excluded from capture before it becomes visible; controls appear only at a safe paused or terminal boundary. Overlay presentation state is never used as target-resolution or verification evidence.`, + body: `Desktop ${DESKTOP_VERSION} provides a separate native status surface outside the target application. During active observation and execution it is click-through, non-focusable, and excluded from capture before it becomes visible; controls appear only at a safe paused or terminal boundary. Overlay presentation state is never used as target-resolution or verification evidence.`, evidenceUrl: CONTROL_OVERLAY_URL, evidenceLabel: 'Inspect the released overlay contract', }, @@ -400,6 +411,10 @@ const secureDev = [ export default function SecurityPage() { return (

+