From 1293cc6c9099a6bb7942243a122871f0fd53fd30 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:07:22 -0500 Subject: [PATCH 01/56] docs: reserve Gate 13 replacement run --- docs/RELEASE_READINESS.md | 23 +-- .../gate13-20260831-a-cost-authorization.json | 162 ++++++++++++++++++ 2 files changed, 174 insertions(+), 11 deletions(-) create mode 100644 docs/evidence/gate13-20260831-a-cost-authorization.json diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md index 0891abce9..93ed05467 100644 --- a/docs/RELEASE_READINESS.md +++ b/docs/RELEASE_READINESS.md @@ -46,14 +46,14 @@ strict four-profile Qwen and Gemma matrices now pass, and Gate 7 passed the gene five-Machine provider recovery mechanism with TinyLlama. Per-model repetition of the same provider recovery gate is not required. -As of 2026-08-31, Gate 13 is `BLOCKED`: cleanup proved the Gate 11 product route, -its run-scoped firewall rules, and every Gate 13 client and disk absent, so the owner -explicitly authorized a cleanup-backed reset for the next run. The new combined -authorization epoch starts at USD 100 with no reservation recorded. The selected native -GCP account still requires interactive reauthentication, and every paid create still -requires a fresh exact source-bound conservative ledger reservation plus fail-closed -preflight. No later mandatory gate is unblocked until the live packaged route and -completed Gate 13 lifecycle evidence exist. +As of 2026-08-31, Gate 13 is `IN PROGRESS`: cleanup proved the prior Gate 11 route, +its run-scoped firewall rules, and every prior Gate 13 client and disk absent. Native GCP +token refresh and Compute access now pass, the protected bootstrap remains running, and +[run `gate13-20260831-a`](evidence/gate13-20260831-a-cost-authorization.json) reserves a +conservative USD 52 maximum from the new USD 100 epoch for one bounded replacement route +plus fresh CPU Windows/Linux clients. Exact targets, images, machine types, and the sole +required L4 quota passed read-only preflight. No later mandatory gate is unblocked until +the live packaged route and both completed 16-phase Gate 13 lifecycle records exist. Do not work on the post-alpha items in the deferred table while an alpha gate can progress. Missing Docker, snapshots, local GPU hardware, or local host capacity is not an external @@ -83,9 +83,9 @@ longer consume the new authorization; later billing should still be recorded for | 8 | Per-model duplicate separate-machine recovery | DEFERRED | Gates 5 and 6 already qualify Qwen and Gemma across the supported platform/device matrix; Gate 7 proves the model-independent provider recovery mechanism. Repeating the same Fly topology for each catalog model would test artifact transport rather than a new release property. | No public-alpha action. Model admission uses manifest/artifact and resource-envelope checks; product-level recovery is covered after automatic placement and catalog publication. | | 9 | Publish edge resource envelopes for selectable profiles | PASSED | [Run `gate9-20260830-e`](evidence/gate9-20260830-e-edge-resource-envelopes.json) publishes all four privacy-safe acquisition and schema-v3 steady-state records at exact runtime source `ba410f7`. Qwen selected 4,571,197,320 bytes and Gemma 10,278,818,149 bytes from empty caches on both Windows Server 2022 and Ubuntu 24.04; every artifact SHA-256 passed with zero resumptions. Qwen measured load/first-token/decode at 25.896 s/1.785 s/1.392 tok/s on Windows and 17.115 s/2.973 s/0.800 tok/s on Linux, with process-tree RSS peaks of 1,883,205,632 and 2,832,244,736 bytes. Gemma measured 64.286 s/1.386 s/1.949 tok/s on Windows and 38.808 s/2.025 s/0.868 tok/s on Linux, with peaks of 1,728,995,328 and 2,818,523,136 bytes. Every workload generated eight tokens without retaining prompts or outputs; Windows Job Objects and Linux process groups were empty, and route/DHT, accelerator, runtime-close, and provider cleanup all passed. All four temporary instances and auto-delete disks are absent; the protected bootstrap and separately authorized Gate 11 route remain running. The Windows Gemma cache-preserving in-place memory retry created no new resource and did not raise the USD 46 Gate 9 ceiling. | Proceed immediately to Gate 13 clean packaged install/inference on Windows and Linux using these envelopes and the live bounded product route. | | 10 | Implement automatic contributor model and block placement | PASSED | Signed bootstrap now installs one bounded `auto` worker. The local planner filters exact manifested candidates through owner policy and local resource ceilings, requires fresh authenticated replica coverage, targets the least-covered contiguous range with per-node jitter, reconciles exact-manifest launches through the existing artifact-verifying server and `WorkerSupervisor`, applies residency/cooldown/switch hysteresis, exposes placement reasons, and preserves an explicit operator pause across ineligibility or placement changes. A new or migrated worker must sign an expiring exact-manifest/range intent with fixed numeric resource claims and receive a remote DHT store acknowledgement (`exclude_self=True`) before entering the artifact path; invalid, rejected, or failed publication is fail-closed and cannot advance planner state, while a previously admitted placement is retained. Actual completed local generations feed exact-manifest demand, useful-throughput, and reliability through two bounded five-minute aggregate windows; no prompt, output, token ID, key, request ID, address, path, error, or per-request event is retained. Only a closed window with at least four completed routes may be signed by the separate router identity and published under the manifest-bound `demand-v1` DHT key with a 90-second lifetime and `exclude_self=True`. Consumers verify signature, exact schema/digest, lifetime, revocation, and replay ordering. The threshold-signed catalog may authorize 2–32 sorted RSA observer roots; missing or empty roots disable remote demand. Discovery discards unlisted identities before signature/replay work, excludes local and duplicate roots, isolates malformed records, requires two authorized roots, and medians at most 32 quantized observations. Observer keys are never generated or bundled: only a separately provisioned `route-demand.key` matching a signed root may publish, while ordinary nodes can consume without one. Any hot-edited root-list mismatch disables both publication and consumption until restart. Local utility is capped at 6 points and signed remote utility at 2, keeping the combined hint below the 10-point migration margin and 100-point replica step. Verified announcement and route-demand replay watermarks now survive restarts in one Windows-safe journal per raw manifest digest under the node data directory. Each strict journal is capped at 256 active identity scopes and 256 KiB, retains only public record kind, key ID, ordering tuple, record digest, and the bounded replay deadline, and is fsync-written through atomic replacement; malformed, duplicate, oversized, symlinked, non-regular, or unwritable state fails closed. The retained deadline prevents an older still-live record from returning after a short-lived newer record expires. The replay slice's 99-test focused protocol/discovery/planner/node-configuration matrix and 209-pass, 2-skip catalog/node/API superset pass. The Sybil slice's 122-test focused catalog/bootstrap/config/discovery matrix proves that 30 valid attacker keys plus one authorized root cannot reach threshold, two authorized roots aggregate without attacker weight, one high authorized vote cannot inflate a lower second vote, old catalogs remain signature-verifiable with remote demand disabled, and trust-epoch reload mismatches fail closed. A 190-pass, 1-skip catalog/protocol/planner/discovery/node/API superset also passes. Independent verification passed 146 focused tests and a 255-pass, 2-skip broader node/API superset, plus a native-Windows publication-boundary probe; formatting, import-order, import-smoke, and diff checks pass. The [explicit privacy review](AUTOMATIC_PLACEMENT_PRIVACY_V1.md) inventories collection, retention, public-key linkability, DHT/journal/API/log exposure, secure-deletion limits, and residual governance/host risks. Three executable privacy-contract tests fix the aggregate, intent, demand, replay, forbidden-field, and path-free warning schemas; the focused privacy/protocol/planner/discovery/node matrix passes 108 tests and the broader catalog/node/API matrix passes 258 tests with 2 skips. Independent privacy review passed 108 tests with 1 skip and a 225-pass, 2-skip broader subset; every caught observer-key exception and an unauthorized key produced no path, key ID, or exception detail, while prompt and identity-path schema injections failed closed. The [deterministic convergence and load acceptance](AUTOMATIC_PLACEMENT_ACCEPTANCE_V1.md) closes the remaining software gate: equal snapshots use node-specific 32-point model dispersion and range rendezvous ranks; a fixed 512-node cold cohort selects both models and every range below the 85% concentration boundary; two 4,096-node fresh-arrival cohorts remain below that boundary under maximum priority-aligned or standby demand; maximum demand causes zero incumbent migrations; one-replica loss migrates after residency without early reversal; rolling arrivals keep every model/block populated and repair an abrupt block loss. The alpha fails closed above 32 candidates or 512 blocks, permits one `auto` worker, clamps reconciliation to at least one second, and scans each candidate in one bounded pass. The focused planner/convergence/configuration matrix passes 78 tests and the broader catalog/protocol/discovery/node/API matrix passes 214 with 2 skips. A real Windows DHT round trip exposed and fixed a durable-replay multiprocessing regression: replay guards now omit/recreate their thread lock across serialization and reload persistent state; its 15-test protocol/network matrix passes. Independent verification reproduced the 78-test focus, passed an expanded 235-test matrix with 2 skips and the 15-test real-DHT probe, and exercised adversarial score, timing, 32-by-512 load, 1,000-case range-equivalence, and persistent replay-reload boundaries. This slice used no cloud resources and spent USD 0. | Gates 9–11 are passed. Gates 13–14 must now prove the packaged flow and real hardware ceilings using the published envelopes. | -| 11 | Operate initial public alpha routes | PASSED | [Product-node run `route-20260830-j`](evidence/gate11node-20260830-a-lifecycle.json) installed the generic CommunityAI wheel on a bounded G2/L4 VM, verified the signed catalog, downloaded both exact manifested models directly from Hugging Face into one persistent shared cache, and used the product node's automatic workers to expose complete Qwen 24/24 primary and Gemma 35/35 standby routes. No model-specific image, cache mirror, or operator-transferred model artifact was used. The privacy-safe acceptance passed one-token primary inference, deliberate primary pause, automatic Gemma selection in 58.073 seconds, standby inference, Qwen restoration in 32.042 seconds, and restored inference. Both workers were stable before the drill. After Gate 13 released the L4, the preserved route was restored without changing its model cache or source, its ephemeral endpoint was rebound, both product-node services became active, and a fresh acceptance reproved Qwen 24/24 primary inference, automatic Gemma 35/35 fallback/inference, Qwen restoration, and restored inference. The protected bootstrap remains running. A corrected 4,800-second provider DELETE backstop was set for `2026-08-31T05:28:16.516Z`, earlier than the original deadline. [Post-backstop cleanup evidence](evidence/gate11route-20260830-j-backstop-cleanup.json) and an independent recheck prove the route instance, named disk, and both exact run-scoped firewall rules absent, all Gate 13 clients/disks absent, zero remaining route availability, and the protected bootstrap still running. The same-host standby is a bounded alpha fallback, not independent infrastructure redundancy; independent redundancy remains post-alpha. | Gate 11 acceptance evidence remains complete, but no product route is live after the corrected DELETE backstop. The 2026-08-31 reset supplies a new USD 100 epoch, but any replacement route still requires refreshed native authentication, a fresh exact source-bound conservative reservation, and fail-closed preflight before provisioning. | -| 12 | Create, publish, and bundle the minimal signed alpha catalog/bootstrap | PASSED | [Run `gate12-20260829-a`](evidence/gate12-20260829-alpha-catalog-publication.json) published the deterministic [`communityai-public-alpha-v1` bundle](../public-alpha/catalog-v1/bundle.json) from source `26be579`. Its threshold-one Ed25519 root signs sequence 1 with the exact qualified Qwen primary and Gemma standby manifests, one pinned public HTTPS mirror, one public seed, a one-route best-effort policy, and no unprovisioned route-demand roots. The canonical bundle binds five members and retains `complete_release_qualification=false`. All three public objects returned HTTP 200 with exact sizes, and a fresh empty consumer fetched them remotely, verified the signature/digests, and created the two-model `auto` node configuration. The private signing key remained ignored and uncommitted. The focused publication suite passes 32 tests, the catalog/bootstrap/model/desktop superset passes 92, and the run spent USD 0. | Preserve the branch-scoped mirror until a newly signed catalog sequence and packaged bootstrap migrate it. The Gate 11 acceptance and Gate 9 envelopes exist, but no product route is currently live; Gate 13 awaits native reauthentication and fresh per-run reservations under the new epoch. Independent threshold holders and interchangeable mirror/seed governance are post-alpha. | -| 13 | Pass packaged clean-install inference on Windows and Linux | BLOCKED | [Prerequisite run `gate13-20260830-a-prerequisites`](evidence/gate13-20260830-a-prerequisites.json) established deterministic install archives, exact first-use bytes, strict provenance, and the canonical lifecycle contract. [Native-harness and production-package run `gate13-20260830-b`](evidence/gate13-20260830-b-native-harness-and-packages.json) now completes the native Windows Credential Manager/Job Object and Linux Secret Service/systemd-cgroup 16-phase adapters, exact worker and descendant cleanup proofs, 3,600-second acquisition bounds, and package/runtime/catalog cross-binding. Independent software review passed 134 focused tests plus a 113-pass broader matrix with 3 platform skips; the production-discovery correction passes 73 unittests, 4 pytest checks, self-test, formatting, and import checks. [Exact-source production run 33338872342](https://github.com/flujo-app/CommunityAI/actions/runs/33338872342) passed both jobs at source `1971f10` and published independently audited CUDA 12.4 archives: Windows `sha256:45e9cdb439bcb8a6d7ed67914a490f3bc2e12ea1981af944034d62d865f5adc6` (2,695,065,068 bytes) and Linux `sha256:f96d3ca651964380d4684855ab08682e8187b33386327ec3895cda25b43c2a00` (3,360,717,934 bytes). Pushed source `6787272` adds the fixed stdin-only artifact downloader and exact platform configs; its 42-test adversarial suite and independent race/special-member/live-wrapper audit pass. No cloud resource was created for these prerequisites. Real completed clean-host lifecycle evidence remains absent. Provider cleanup and the temporary Gate 11 restoration are proved, but the corrected backstop has since removed that route. | [Run `gate13-20260830-c` revision 13](evidence/gate13-20260830-c-cost-authorization.json) is stopped clean. The latest Windows host passed exact package audit, clean install, four desktop self-tests, and the packaged-node self-test, then failed before model acquisition because child stderr diagnostics contaminated strict JSON captured on stdout. [Attempt, cleanup, correction, and route-restoration evidence](evidence/gate13-20260830-c-windows-attempt-and-route-restore.json) proves zero cache bytes, no retained credential or product process, all four exact client instances/disks absent, the bootstrap running, and the temporary restored Qwen/Gemma product route. [Post-backstop cleanup evidence](evidence/gate11route-20260830-j-backstop-cleanup.json) now proves that route, its named disk, and both exact run-scoped firewall rules absent while every Gate 13 target remains absent and the protected bootstrap remains running. Pushed source `4818da3` separates captured stdout from a dedicated NUL stderr sink and passes 15 native tests plus independent high-volume, handle-leak, descendant, timeout, and Job Object probes, but it has not completed a paid clean-host lifecycle. The cleanup-backed 2026-08-31 owner reset releases the USD 98 historical maxima and opens a new USD 100 epoch for the next run; it does not authorize any particular resource or reuse the stopped record. A read-only native-auth check on 2026-08-31 found an active account selection, but provider requests could not refresh its token without interactive reauthentication; no provider mutation or resource creation occurred. Complete both 16-phase fresh-host lifecycles only after refreshing native authentication and recording fresh exact source-bound conservative reservations for the replacement route and Gate 13 clients; do not provision, restart FLUJO, or mark Gate 13 passed before then. | +| 11 | Operate initial public alpha routes | PASSED | [Product-node run `route-20260830-j`](evidence/gate11node-20260830-a-lifecycle.json) installed the generic CommunityAI wheel on a bounded G2/L4 VM, verified the signed catalog, downloaded both exact manifested models directly from Hugging Face into one persistent shared cache, and used the product node's automatic workers to expose complete Qwen 24/24 primary and Gemma 35/35 standby routes. No model-specific image, cache mirror, or operator-transferred model artifact was used. The privacy-safe acceptance passed one-token primary inference, deliberate primary pause, automatic Gemma selection in 58.073 seconds, standby inference, Qwen restoration in 32.042 seconds, and restored inference. Both workers were stable before the drill. After Gate 13 released the L4, the preserved route was restored without changing its model cache or source, its ephemeral endpoint was rebound, both product-node services became active, and a fresh acceptance reproved Qwen 24/24 primary inference, automatic Gemma 35/35 fallback/inference, Qwen restoration, and restored inference. The protected bootstrap remains running. A corrected 4,800-second provider DELETE backstop was set for `2026-08-31T05:28:16.516Z`, earlier than the original deadline. [Post-backstop cleanup evidence](evidence/gate11route-20260830-j-backstop-cleanup.json) and an independent recheck prove the route instance, named disk, and both exact run-scoped firewall rules absent, all Gate 13 clients/disks absent, zero remaining route availability, and the protected bootstrap still running. The same-host standby is a bounded alpha fallback, not independent infrastructure redundancy; independent redundancy remains post-alpha. | Gate 11 acceptance evidence remains complete, but no product route is live after the corrected DELETE backstop. [Gate 13 run `gate13-20260831-a`](evidence/gate13-20260831-a-cost-authorization.json) now binds refreshed native authentication, fail-closed preflight, and a fresh USD 52 reservation for the replacement route and packaged clients. | +| 12 | Create, publish, and bundle the minimal signed alpha catalog/bootstrap | PASSED | [Run `gate12-20260829-a`](evidence/gate12-20260829-alpha-catalog-publication.json) published the deterministic [`communityai-public-alpha-v1` bundle](../public-alpha/catalog-v1/bundle.json) from source `26be579`. Its threshold-one Ed25519 root signs sequence 1 with the exact qualified Qwen primary and Gemma standby manifests, one pinned public HTTPS mirror, one public seed, a one-route best-effort policy, and no unprovisioned route-demand roots. The canonical bundle binds five members and retains `complete_release_qualification=false`. All three public objects returned HTTP 200 with exact sizes, and a fresh empty consumer fetched them remotely, verified the signature/digests, and created the two-model `auto` node configuration. The private signing key remained ignored and uncommitted. The focused publication suite passes 32 tests, the catalog/bootstrap/model/desktop superset passes 92, and the run spent USD 0. | Preserve the branch-scoped mirror until a newly signed catalog sequence and packaged bootstrap migrate it. The Gate 11 acceptance and Gate 9 envelopes exist; [Gate 13 run `gate13-20260831-a`](evidence/gate13-20260831-a-cost-authorization.json) now authorizes the bounded replacement route and fresh packaged clients under the new epoch. Independent threshold holders and interchangeable mirror/seed governance are post-alpha. | +| 13 | Pass packaged clean-install inference on Windows and Linux | IN PROGRESS | [Prerequisite run `gate13-20260830-a-prerequisites`](evidence/gate13-20260830-a-prerequisites.json) established deterministic install archives, exact first-use bytes, strict provenance, and the canonical lifecycle contract. [Native-harness and production-package run `gate13-20260830-b`](evidence/gate13-20260830-b-native-harness-and-packages.json) now completes the native Windows Credential Manager/Job Object and Linux Secret Service/systemd-cgroup 16-phase adapters, exact worker and descendant cleanup proofs, 3,600-second acquisition bounds, and package/runtime/catalog cross-binding. Independent software review passed 134 focused tests plus a 113-pass broader matrix with 3 platform skips; the production-discovery correction passes 73 unittests, 4 pytest checks, self-test, formatting, and import checks. [Exact-source production run 33338872342](https://github.com/flujo-app/CommunityAI/actions/runs/33338872342) passed both jobs at source `1971f10` and published independently audited CUDA 12.4 archives: Windows `sha256:45e9cdb439bcb8a6d7ed67914a490f3bc2e12ea1981af944034d62d865f5adc6` (2,695,065,068 bytes) and Linux `sha256:f96d3ca651964380d4684855ab08682e8187b33386327ec3895cda25b43c2a00` (3,360,717,934 bytes). Pushed source `6787272` adds the fixed stdin-only artifact downloader and exact platform configs; its 42-test adversarial suite and independent race/special-member/live-wrapper audit pass. No cloud resource was created for these prerequisites. Real completed clean-host lifecycle evidence remains absent. Provider cleanup and the temporary Gate 11 restoration are proved, but the corrected backstop has since removed that route. | [Run `gate13-20260830-c` revision 13](evidence/gate13-20260830-c-cost-authorization.json) is stopped clean. The latest Windows host passed exact package audit, clean install, four desktop self-tests, and the packaged-node self-test, then failed before model acquisition because child stderr diagnostics contaminated strict JSON captured on stdout. [Attempt, cleanup, correction, and route-restoration evidence](evidence/gate13-20260830-c-windows-attempt-and-route-restore.json) proves zero cache bytes, no retained credential or product process, all four exact client instances/disks absent, the bootstrap running, and the temporary restored Qwen/Gemma product route. [Post-backstop cleanup evidence](evidence/gate11route-20260830-j-backstop-cleanup.json) now proves that route, its named disk, and both exact run-scoped firewall rules absent while every Gate 13 target remains absent and the protected bootstrap remains running. Pushed source `4818da3` separates captured stdout from a dedicated NUL stderr sink and passes 15 native tests plus independent high-volume, handle-leak, descendant, timeout, and Job Object probes, but it has not completed a paid clean-host lifecycle. The cleanup-backed 2026-08-31 owner reset releases the USD 98 historical maxima and opens a new USD 100 epoch for the next run; it does not authorize any particular resource or reuse the stopped record. A later native-auth recheck on 2026-08-31 obtained a real access token and passed Compute API access without exposing account or token material. [Run `gate13-20260831-a`](evidence/gate13-20260831-a-cost-authorization.json) records the fresh source-bound USD 52 reservation, exact route/client targets, current availability preflight, and cleanup boundary. Create only those bounded resources, complete both 16-phase fresh-host lifecycles, and prove exact cleanup before marking Gate 13 passed. | | 14 | Pass automatic-contribution and resource-control hardware checks | WAITING | [PR #11](https://github.com/flujo-app/CommunityAI/pull/11) and [PR #12](https://github.com/flujo-app/CommunityAI/pull/12) implemented the authenticated node-authoritative Sharing UI and atomic policy editing, but cross-model automatic placement and real packaged hardware evidence are absent. | After Gates 9–13, follow the [recovery runbook](RECOVERY_TEST_RUNBOOK.md) once for the clean-install product flow while validating model/block choice, exact selected-shard bytes, shared-cache affinity, download authorization, VRAM/storage/bandwidth/power limits, suspension, pause timing, cleanup, restart persistence, and unsupported telemetry on real packaged Windows/Linux hardware. | | 15 | Complete minimal alpha release engineering | WAITING | The desktop builder now emits a stable sorted `SHA256SUMS` inventory of exact regular-file bytes and safe relative in-bundle file symlinks, source/build/catalog-bound `provenance.json`, and `release-metadata.json` with explicit unsigned public-alpha, no-publisher-signature, no-authenticated-update, Windows/Linux-only, no-credits, and incomplete-qualification claims. Structural verification binds each safe file symlink to its canonical in-bundle target, digest, and size while rejecting changed, missing, extra, absolute, external, broken, cyclic, directory-linked/junction, special, traversal, or case-colliding payloads plus unsupported or noncanonical metadata. Exact-source builds also reject dirty relevant inputs, and the expected-input fresh-process check rejects rewritten commit/tree, workflow, platform, Python, PyInstaller, or catalog evidence. Production desktop CI is configured to verify and bundle the Gate 12 inputs, bind the exact clean Git commit/tree and workflow, revalidate every expected input separately, and upload all evidence on Windows/Linux. The focused release-input/artifact suite passes 15 tests, including fresh-process CLI, dirty-source, and canonical-rewrite checks, and the broader catalog/bootstrap/model/desktop subset passes 134. Independent verification reproduced all 134, passed 58 desktop unittests with two environment skips, formatting/import-order/YAML/diff checks, an expected Gate 12/workflow fresh-process probe, and real Windows junction rejection; no cloud was used. [The first PR #22 production-desktop run](https://github.com/flujo-app/CommunityAI/actions/runs/33273518744) reached packaging on both hosts and exposed two exact cross-platform defects: PyInstaller's legitimate relative internal Qt file symlink on Ubuntu and CRLF-transformed signed Gate 12 JSON on Windows. The follow-up binds safe internal file symlinks without accepting external or directory links, forces `public-alpha/**` to LF at checkout, and includes `.gitattributes` in the clean-source boundary. [The second run](https://github.com/flujo-app/CommunityAI/actions/runs/33274432423) proved the Ubuntu package and the Windows signed-bundle/provenance path, then exposed a stale desktop contribution-status schema 2 contract when the packaged node emitted schema 3 automatic-placement evidence. Source `fcd1f41` now strictly validates schema 3 placement and rejects stale schema 2 plus missing, extra, secret-bearing, or inconsistent placement data; its 50-test node/client/lifecycle/build focus and all 59 desktop unittests passed with two environment skips. [The final run](https://github.com/flujo-app/CommunityAI/actions/runs/33275216332) bound exact source `fcd1f417d1435557addb2d6cded9dac0827c7d8c` and completed both Windows and Ubuntu package jobs, including bundle build/smoke, independent checksum/provenance verification, the Windows packaged-node/native-credential/public-seed smoke, and artifact uploads; every PR style, test, and package check is green. Source `36d85d2` makes generic release-artifact fixtures select the supported Linux archive explicitly instead of inheriting the CI host platform; the 21-test local artifact suite and [PR #22 test run 33372581439](https://github.com/flujo-app/CommunityAI/actions/runs/33372581439) pass, without expanding the supported platform matrix. Clean-install lifecycle evidence remains absent. | Retain the verified Windows/Linux artifacts as engineering evidence, then test clean install, manual upgrade/reinstall, uninstall, retained-data choice for the persistent verified model cache, and recovery instructions on both platforms against a newly authorized live product-node route and the published Gate 9 envelopes. Do not mark passed from metadata/unit tests alone. Publisher signing and automatic authenticated update/rollback are post-alpha. | | 16 | Complete the bounded public-alpha safety canary | WAITING | [PR #13](https://github.com/flujo-app/CommunityAI/pull/13) and [PR #14](https://github.com/flujo-app/CommunityAI/pull/14) implemented bounded admission, privacy-safe aggregate health, training-off defaults, rollback procedures, and bounded routine rejection logs; no public canary has run. | After Gates 11–15, run a small monitored canary proving finite admission/timeouts, malformed-peer rejection, health reconstruction, privacy disclosure, route/catalog disable, and clean rollback. Exhaustive hostile-load, Sybil/collusion, partition, and long-soak campaigns are post-alpha. | @@ -121,6 +121,7 @@ new epoch. | Run | Provider | Purpose | Maximum estimate | Observed cost | Cleanup proof | State | | --- | --- | --- | ---: | ---: | --- | --- | +| gate13-20260831-a | GCP | Gate 13 replacement product-node route plus fresh CPU Windows/Linux packaged lifecycles at route source `f64a388a47b098ac7f69d2affc59816376b43bb1` and exact package source `1971f106cc5bf90724d938c986a719ce2744f3e7` [plan sha256:d3854d047beba36d1415382b296f90f2aaf0fc135d45c7613852c69b85ccf5a1] | USD 52.00 | — | [Cost authorization](evidence/gate13-20260831-a-cost-authorization.json) binds the one-L4 bounded route, fresh CPU client targets, exact packages/helpers/manifests, native-auth and provider preflight, automatic deletion deadlines, exact cleanup targets, protected bootstrap, and zero Fly/image/mirror/credits/macOS work. | RESERVED | | gate13-20260830-c | GCP | Gate 13 sequential clean packaged Qwen Windows and Gemma Linux lifecycles at exact package source `1971f106cc5bf90724d938c986a719ce2744f3e7`, temporarily suspending and later restoring the Gate 11 route while reusing its sole global L4 allocation on uniquely named fresh Windows and Linux clients [plan sha256:427bc1ed8a6645ad0650d91aaba7aa753d398fa84f56d57b50aca04c4e0cc955] | USD 26.00 | — | [Cost authorization](evidence/gate13-20260830-c-cost-authorization.json) binds the passed production archives/audits, pushed download-helper/config identities, exact Actions wrapper/inner archives, exact Qwen/Gemma manifests, no service accounts/scopes, direct model transfer, native credential stores, whole-tree containment, all 16 phases, exact cleanup targets, and zero Fly/image/mirror/credits/macOS work. Revision 13 records the final Windows pre-acquisition failure, pushed correction `4818da3`, complete native cleanup, all four exact client instance/disk absences, and successful Gate 11 route restoration. [Privacy-safe final state](evidence/gate13-20260830-c-windows-attempt-and-route-restore.json) proves the package audit and install boundary, zero model-cache bytes, no retained credential/process/path/endpoint/provider output, protected-bootstrap health, active Qwen/Gemma route services, and fresh primary/fallback/restoration inference. The two required 16-phase lifecycles remain incomplete. Complete cleanup permits the explicit owner reset on 2026-08-31; its USD 26 maximum is historical and delayed billing remains informational. This record authorizes no later provisioning. | CLEANED-RELEASED | | gate9-20260830-e | GCP | Gate 9 concurrent Qwen/Gemma Windows/Linux acquisition records and schema-v3 envelopes at pushed source `ba410f74f1cf625f1e1c34734b53e4514fa7c5ec`, reusing the separately authorized product route and using bounded isolated clients [plan sha256:04ba77ee68f4a895ae080a4ddcbf6805b502da6a95a4146734acbddff92de307] | USD 46.00 | — | [Passed envelopes and cleanup](evidence/gate9-20260830-e-edge-resource-envelopes.json) publish all four exact acquisition/envelope records and prove complete client cleanup; [cost authorization](evidence/gate9-20260830-e-cost-authorization.json) binds the exact wheel or exact-commit source archive, signed catalog/bootstrap, Qwen/Gemma manifests, owner-authorized parallel platform/model execution, 60-minute model windows, 90-minute client deletion backstops, exact cleanup targets, protected resources, and zero Fly/image/mirror operations. Native provider authentication was refreshed before the USD 18 Windows-client expansion and again before the zero-ceiling-increase Gemma memory retry; the exact plan permits one cache-preserving in-place resize to `e2-standard-8`. Complete cleanup permits the explicit owner reset on 2026-08-31; its USD 46 maximum is historical and delayed billing remains informational. | CLEANED-RELEASED | | route-20260830-j | GCP | Gate 11 signed-catalog product node route [workload gcp-product-node-route] [source e1d715fd47c852fa12ca50c76e8f4c6a0831fd78] [final runtime source 4cef141746705c3ee8bc8e017693855e0bc4871e] [plan sha256:1a0927e9d83a9a409ac2ea0232c4fceb14821d3f2c5eb87def88b8e7cdcb07d8] | USD 26.00 | — | [Passed live lifecycle](evidence/gate11node-20260830-a-lifecycle.json): generic runtime, signed catalog, direct Hugging Face artifacts, shared persistent cache, complete primary/standby routes, primary/fallback/restoration inference, stable workers, no model image, and protected-bootstrap health. [Gate 13 restoration evidence](evidence/gate13-20260830-c-windows-attempt-and-route-restore.json) proves the route was restored, both product services became active, fresh Qwen/Gemma primary/fallback/restoration inference passed, and a corrected 4,800-second DELETE backstop ended no later than the original deadline. [Post-backstop cleanup](evidence/gate11route-20260830-j-backstop-cleanup.json) proves the route instance, named disk, and both exact run-scoped firewall rules absent, all Gate 13 clients/disks absent, zero GPU use, and the protected bootstrap running; acceptance evidence is preserved but no product route is live. Complete cleanup permits the explicit owner reset on 2026-08-31; its USD 26 maximum is historical and delayed billing remains informational. | CLEANED-RELEASED | diff --git a/docs/evidence/gate13-20260831-a-cost-authorization.json b/docs/evidence/gate13-20260831-a-cost-authorization.json new file mode 100644 index 000000000..093ac4a66 --- /dev/null +++ b/docs/evidence/gate13-20260831-a-cost-authorization.json @@ -0,0 +1,162 @@ +{ + "schema_version": 1, + "scope": "gate13-packaged-lifecycle-cost-authorization", + "run_id": "gate13-20260831-a", + "gate": 13, + "result": "authorized", + "recorded_at": "2026-08-31", + "source": { + "reservation_commit": "f64a388a47b098ac7f69d2affc59816376b43bb1", + "route_runtime_commit": "f64a388a47b098ac7f69d2affc59816376b43bb1", + "package_commit": "1971f106cc5bf90724d938c986a719ce2744f3e7", + "windows_lifecycle_helper_commit": "4818da304f4eeafc81978873bcdfa8a41f6cad36", + "linux_lifecycle_helper_commit": "c3dc9234af7980bcaffd481c6f8e4e974ed117d4" + }, + "authorization": { + "combined_cloud_ceiling_usd": "100.00", + "ledger_committed_before_run_usd": "0.00", + "maximum_estimate_usd": "52.00", + "route_maximum_estimate_usd": "26.00", + "clients_maximum_estimate_usd": "26.00", + "remaining_after_run_maximum_usd": "48.00", + "reservation_recorded": true, + "provisioning_authorized_after_fail_closed_preflight": true, + "provider_calls_authorized_without_preflight": false, + "pricing_as_of": "2026-08-31", + "pricing_basis": "same-day previously accepted 14-hour G2/L4 route ceiling plus the same client ceiling; both new clients are CPU-only e2-standard-8 rather than the prior L4 client class" + }, + "immutable_inputs": { + "windows_package": { + "sha256": "45e9cdb439bcb8a6d7ed67914a490f3bc2e12ea1981af944034d62d865f5adc6", + "bytes": 2695065068 + }, + "linux_package": { + "sha256": "f96d3ca651964380d4684855ab08682e8187b33386327ec3895cda25b43c2a00", + "bytes": 3360717934 + }, + "qwen_manifest": "sha256:3ba8528cb3c0d85e1ed048e0438a0d64cfbbc298944ed674caa6950d415f8e33", + "gemma_manifest": "sha256:2f8debbe0fcdf5af8d4c56c982210fa50aa584314968ae2617e2ccc2de9eafdd", + "windows_helper": { + "path": "scripts/gate13_windows_packaged_lifecycle.ps1", + "sha256": "2272b3bd9a59ccbe66963558f2cee66b41f68403f611f8eff92362eebf8e22b8", + "bytes": 131385 + }, + "linux_helper": { + "path": "scripts/gate13_linux_packaged_lifecycle.py", + "sha256": "f50497fa06f465e8dba8b146e963faa11d102bc7c7fc8e5073e6c0b9e1beaf94", + "bytes": 113260 + } + }, + "provider_plan_digest": "sha256:d3854d047beba36d1415382b296f90f2aaf0fc135d45c7613852c69b85ccf5a1", + "provider_plan": { + "project": "community-ai-506321", + "route": { + "instance": "route-20260831-a-node", + "zone": "us-central1-a", + "machine_type": "g2-standard-8", + "accelerator": "1 x NVIDIA L4", + "image": "deeplearning-platform-release/common-cu129-ubuntu-2404-nvidia-580-v20260819", + "boot_disk_gib": 200, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 50400, + "termination_action": "DELETE", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "public_ports": [ + 31337, + 31338 + ], + "firewalls": [ + "route-20260831-a-dht", + "route-20260831-a-iap" + ], + "service_account": false, + "scopes": [] + }, + "clients": [ + { + "platform": "windows", + "model": "Qwen3.5 2B", + "instance": "gate13-20260831-a-win", + "zone": "us-central1-a", + "machine_type": "e2-standard-8", + "image_family": "windows-2025", + "image_project": "windows-cloud", + "boot_disk_gib": 120, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 21600, + "termination_action": "DELETE", + "lifecycle_run_id": "gate13-windows-qwen-e", + "device_profile": "cpu" + }, + { + "platform": "linux", + "model": "Gemma 4 E2B IT", + "instance": "gate13-20260831-a-linux", + "zone": "us-central1-a", + "machine_type": "e2-standard-8", + "image_family": "ubuntu-2404-lts-amd64", + "image_project": "ubuntu-os-cloud", + "boot_disk_gib": 120, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 21600, + "termination_action": "DELETE", + "lifecycle_run_id": "gate13-linux-gemma-e", + "device_profile": "cpu" + } + ], + "sequencing": { + "route_live_for_both_lifecycles": true, + "clients_may_run_concurrently": true, + "all_16_phases_required_per_platform": true, + "exact_cleanup_before_pass": true + } + }, + "preflight": { + "native_gcloud_token_refresh": true, + "compute_api_access": true, + "exact_target_instances_absent": true, + "exact_target_disks_absent": true, + "exact_target_firewalls_absent": true, + "machine_types_available": true, + "images_available": true, + "one_l4_quota_free": true, + "protected_bootstrap_running": true, + "focused_software_tests_required_before_create": true + }, + "cleanup": { + "delete_only_exact_run_resources": true, + "verify_absent": [ + "route-20260831-a-node", + "route-20260831-a-node boot disk", + "route-20260831-a-dht", + "route-20260831-a-iap", + "gate13-20260831-a-win", + "gate13-20260831-a-win boot disk", + "gate13-20260831-a-linux", + "gate13-20260831-a-linux boot disk" + ], + "protected_resources": [ + "communityai-bootstrap-1", + "all resources not named by this plan" + ], + "cleanup_required_on_success_or_failure": true + }, + "prohibited": { + "fly_operations": 0, + "model_specific_images_or_mirrors": 0, + "macos_work": 0, + "credits_or_payments_work": 0 + }, + "privacy": { + "credentials_retained": false, + "prompts_retained": false, + "outputs_retained": false, + "endpoints_retained": false, + "provider_output_retained": false, + "private_paths_retained": false + } +} From eb54b2b6a5df1eea815084a3ec5e8753c930840d Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:11:43 -0500 Subject: [PATCH 02/56] docs: bind Gate 13 route runtime --- docs/evidence/gate13-20260831-a-cost-authorization.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/evidence/gate13-20260831-a-cost-authorization.json b/docs/evidence/gate13-20260831-a-cost-authorization.json index 093ac4a66..d1d7e25bd 100644 --- a/docs/evidence/gate13-20260831-a-cost-authorization.json +++ b/docs/evidence/gate13-20260831-a-cost-authorization.json @@ -26,6 +26,13 @@ "pricing_basis": "same-day previously accepted 14-hour G2/L4 route ceiling plus the same client ceiling; both new clients are CPU-only e2-standard-8 rather than the prior L4 client class" }, "immutable_inputs": { + "route_runtime_wheel": { + "filename": "drift-2.3.0.dev2-py3-none-any.whl", + "source_commit": "f64a388a47b098ac7f69d2affc59816376b43bb1", + "sha256": "2a4f30bad7ae897fed019bc7da330a09965adb35685d11abaeaebf7a1d40aa60", + "bytes": 389107, + "model_artifacts_embedded": false + }, "windows_package": { "sha256": "45e9cdb439bcb8a6d7ed67914a490f3bc2e12ea1981af944034d62d865f5adc6", "bytes": 2695065068 From 2c4f9411eed3feed5bbded211b32c861ef30e460 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:40:25 -0500 Subject: [PATCH 03/56] docs: bind Gate 13 client IAP access --- docs/RELEASE_READINESS.md | 2 +- .../gate13-20260831-a-cost-authorization.json | 25 ++++++++++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md index 93ed05467..df35840bf 100644 --- a/docs/RELEASE_READINESS.md +++ b/docs/RELEASE_READINESS.md @@ -121,7 +121,7 @@ new epoch. | Run | Provider | Purpose | Maximum estimate | Observed cost | Cleanup proof | State | | --- | --- | --- | ---: | ---: | --- | --- | -| gate13-20260831-a | GCP | Gate 13 replacement product-node route plus fresh CPU Windows/Linux packaged lifecycles at route source `f64a388a47b098ac7f69d2affc59816376b43bb1` and exact package source `1971f106cc5bf90724d938c986a719ce2744f3e7` [plan sha256:d3854d047beba36d1415382b296f90f2aaf0fc135d45c7613852c69b85ccf5a1] | USD 52.00 | — | [Cost authorization](evidence/gate13-20260831-a-cost-authorization.json) binds the one-L4 bounded route, fresh CPU client targets, exact packages/helpers/manifests, native-auth and provider preflight, automatic deletion deadlines, exact cleanup targets, protected bootstrap, and zero Fly/image/mirror/credits/macOS work. | RESERVED | +| gate13-20260831-a | GCP | Gate 13 replacement product-node route plus fresh CPU Windows/Linux packaged lifecycles at route source `f64a388a47b098ac7f69d2affc59816376b43bb1` and exact package source `1971f106cc5bf90724d938c986a719ce2744f3e7` [plan sha256:313f5d34eefd64c71e265bdb7044d8ef5f56550360a7e9a7104265434292fd69] | USD 52.00 | — | [Cost authorization](evidence/gate13-20260831-a-cost-authorization.json) binds the one-L4 bounded route, fresh CPU client targets, exact packages/helpers/manifests, native-auth and provider preflight, automatic deletion deadlines, exact cleanup targets, protected bootstrap, and zero Fly/image/mirror/credits/macOS work. | RESERVED | | gate13-20260830-c | GCP | Gate 13 sequential clean packaged Qwen Windows and Gemma Linux lifecycles at exact package source `1971f106cc5bf90724d938c986a719ce2744f3e7`, temporarily suspending and later restoring the Gate 11 route while reusing its sole global L4 allocation on uniquely named fresh Windows and Linux clients [plan sha256:427bc1ed8a6645ad0650d91aaba7aa753d398fa84f56d57b50aca04c4e0cc955] | USD 26.00 | — | [Cost authorization](evidence/gate13-20260830-c-cost-authorization.json) binds the passed production archives/audits, pushed download-helper/config identities, exact Actions wrapper/inner archives, exact Qwen/Gemma manifests, no service accounts/scopes, direct model transfer, native credential stores, whole-tree containment, all 16 phases, exact cleanup targets, and zero Fly/image/mirror/credits/macOS work. Revision 13 records the final Windows pre-acquisition failure, pushed correction `4818da3`, complete native cleanup, all four exact client instance/disk absences, and successful Gate 11 route restoration. [Privacy-safe final state](evidence/gate13-20260830-c-windows-attempt-and-route-restore.json) proves the package audit and install boundary, zero model-cache bytes, no retained credential/process/path/endpoint/provider output, protected-bootstrap health, active Qwen/Gemma route services, and fresh primary/fallback/restoration inference. The two required 16-phase lifecycles remain incomplete. Complete cleanup permits the explicit owner reset on 2026-08-31; its USD 26 maximum is historical and delayed billing remains informational. This record authorizes no later provisioning. | CLEANED-RELEASED | | gate9-20260830-e | GCP | Gate 9 concurrent Qwen/Gemma Windows/Linux acquisition records and schema-v3 envelopes at pushed source `ba410f74f1cf625f1e1c34734b53e4514fa7c5ec`, reusing the separately authorized product route and using bounded isolated clients [plan sha256:04ba77ee68f4a895ae080a4ddcbf6805b502da6a95a4146734acbddff92de307] | USD 46.00 | — | [Passed envelopes and cleanup](evidence/gate9-20260830-e-edge-resource-envelopes.json) publish all four exact acquisition/envelope records and prove complete client cleanup; [cost authorization](evidence/gate9-20260830-e-cost-authorization.json) binds the exact wheel or exact-commit source archive, signed catalog/bootstrap, Qwen/Gemma manifests, owner-authorized parallel platform/model execution, 60-minute model windows, 90-minute client deletion backstops, exact cleanup targets, protected resources, and zero Fly/image/mirror operations. Native provider authentication was refreshed before the USD 18 Windows-client expansion and again before the zero-ceiling-increase Gemma memory retry; the exact plan permits one cache-preserving in-place resize to `e2-standard-8`. Complete cleanup permits the explicit owner reset on 2026-08-31; its USD 46 maximum is historical and delayed billing remains informational. | CLEANED-RELEASED | | route-20260830-j | GCP | Gate 11 signed-catalog product node route [workload gcp-product-node-route] [source e1d715fd47c852fa12ca50c76e8f4c6a0831fd78] [final runtime source 4cef141746705c3ee8bc8e017693855e0bc4871e] [plan sha256:1a0927e9d83a9a409ac2ea0232c4fceb14821d3f2c5eb87def88b8e7cdcb07d8] | USD 26.00 | — | [Passed live lifecycle](evidence/gate11node-20260830-a-lifecycle.json): generic runtime, signed catalog, direct Hugging Face artifacts, shared persistent cache, complete primary/standby routes, primary/fallback/restoration inference, stable workers, no model image, and protected-bootstrap health. [Gate 13 restoration evidence](evidence/gate13-20260830-c-windows-attempt-and-route-restore.json) proves the route was restored, both product services became active, fresh Qwen/Gemma primary/fallback/restoration inference passed, and a corrected 4,800-second DELETE backstop ended no later than the original deadline. [Post-backstop cleanup](evidence/gate11route-20260830-j-backstop-cleanup.json) proves the route instance, named disk, and both exact run-scoped firewall rules absent, all Gate 13 clients/disks absent, zero GPU use, and the protected bootstrap running; acceptance evidence is preserved but no product route is live. Complete cleanup permits the explicit owner reset on 2026-08-31; its USD 26 maximum is historical and delayed billing remains informational. | CLEANED-RELEASED | diff --git a/docs/evidence/gate13-20260831-a-cost-authorization.json b/docs/evidence/gate13-20260831-a-cost-authorization.json index d1d7e25bd..242cf5a40 100644 --- a/docs/evidence/gate13-20260831-a-cost-authorization.json +++ b/docs/evidence/gate13-20260831-a-cost-authorization.json @@ -54,7 +54,7 @@ "bytes": 113260 } }, - "provider_plan_digest": "sha256:d3854d047beba36d1415382b296f90f2aaf0fc135d45c7613852c69b85ccf5a1", + "provider_plan_digest": "sha256:313f5d34eefd64c71e265bdb7044d8ef5f56550360a7e9a7104265434292fd69", "provider_plan": { "project": "community-ai-506321", "route": { @@ -79,7 +79,11 @@ "route-20260831-a-iap" ], "service_account": false, - "scopes": [] + "scopes": [], + "operator_access_target_tags": [ + "route-20260831-a-node", + "gate13-20260831-a-client" + ] }, "clients": [ { @@ -96,7 +100,10 @@ "max_run_seconds": 21600, "termination_action": "DELETE", "lifecycle_run_id": "gate13-windows-qwen-e", - "device_profile": "cpu" + "device_profile": "cpu", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "network_tag": "gate13-20260831-a-client" }, { "platform": "linux", @@ -112,7 +119,10 @@ "max_run_seconds": 21600, "termination_action": "DELETE", "lifecycle_run_id": "gate13-linux-gemma-e", - "device_profile": "cpu" + "device_profile": "cpu", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "network_tag": "gate13-20260831-a-client" } ], "sequencing": { @@ -165,5 +175,12 @@ "endpoints_retained": false, "provider_output_retained": false, "private_paths_retained": false + }, + "plan_revision": { + "revision": 2, + "previous_provider_plan_digest": "sha256:d3854d047beba36d1415382b296f90f2aaf0fc135d45c7613852c69b85ccf5a1", + "reason": "target both fresh client hosts through the same exact run-scoped IAP-only firewall without reusing the route DHT tag or creating another resource", + "resource_set_changed": false, + "cost_ceiling_changed": false } } From ddfb7c617b428a97b33d2b28e42f4fb75f3509ce Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:18:22 -0500 Subject: [PATCH 04/56] gate13: add durable run state contract --- scripts/gate13_run_controller.py | 710 ++++++++++++++++++++++++++++ tests/test_gate13_run_controller.py | 325 +++++++++++++ 2 files changed, 1035 insertions(+) create mode 100644 scripts/gate13_run_controller.py create mode 100644 tests/test_gate13_run_controller.py diff --git a/scripts/gate13_run_controller.py b/scripts/gate13_run_controller.py new file mode 100644 index 000000000..d6be33df2 --- /dev/null +++ b/scripts/gate13_run_controller.py @@ -0,0 +1,710 @@ +"""Durable state contract for one bounded Gate 13 GCP lifecycle. + +This module is deliberately provider-command agnostic. The paid-run adapter supplies a +fresh, exact provider/host observation before every transition and executes only the +returned allowlisted action. Persisting the transition before returning makes a local +operator crash recoverable: the next invocation inventories first and either reattaches +to the same durable host job or proceeds to cleanup. + +A lifecycle is never resumed after a product attempt fails. Such a client is consumed +for acceptance even when its product-owned files were removed successfully. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import os +import re +import stat +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping, Sequence + +import gate13_packaged_lifecycle as lifecycle +import qualification_cost_guard as cost_guard + +SCHEMA_VERSION = 1 +STATE_SCHEMA_VERSION = 1 +MAX_JSON_BYTES = 1_048_576 +MAX_STATE_BYTES = 262_144 +MIN_ROUTE_RUNWAY_SECONDS = 3_600 +PROTECTED_INSTANCE = "communityai-bootstrap-1" + +_RUN_RE = re.compile(r"[a-z0-9][a-z0-9-]{0,62}") +_NAME_RE = re.compile(r"[a-z](?:[-a-z0-9]{0,61}[a-z0-9])?") +_DIGEST_RE = re.compile(r"(?:sha256:)?[0-9a-f]{64}") +_COMMIT_RE = re.compile(r"[0-9a-f]{40}") + +PHASES = { + "ABSENT", + "ROUTE_STARTING", + "ROUTE_ACCEPTING", + "ROUTE_ACCEPTED", + "WINDOWS_RUNNING", + "WINDOWS_COLLECTING", + "WINDOWS_COLLECTED", + "WINDOWS_DELETING", + "LINUX_RUNNING", + "LINUX_COLLECTING", + "LINUX_COLLECTED", + "LINUX_DELETING", + "ROUTE_DELETING", + "CLEANING_FAILED", + "CLEANED_PASS", + "CLEANED_FAILURE", +} +TERMINAL_PHASES = {"CLEANED_PASS", "CLEANED_FAILURE"} +JOB_STATES = {"absent", "starting", "running", "passed", "failed", "ambiguous"} +ACTION_STATES = { + "start_route", + "accept_route", + "start_windows", + "collect_windows", + "delete_windows", + "start_linux", + "collect_linux", + "delete_linux", + "delete_route", + "cleanup_failure", + "none", +} + +_STATE_FIELDS = { + "schema_version", + "run_id", + "authorization_sha256", + "provider_plan_digest", + "revision", + "phase", + "failure_code", + "route_acceptance_digest", + "windows_evidence_digest", + "linux_evidence_digest", + "windows_consumed", + "linux_consumed", + "cleanup_verified", + "next_action", +} +_OBSERVATION_FIELDS = { + "schema_version", + "run_id", + "observed_at_unix", + "instances", + "disks", + "firewalls", + "protected_bootstrap_running", + "route_acceptance", + "clients", +} +_INSTANCE_FIELDS = { + "present", + "run_id", + "source_commit", + "termination_unix", +} +_CLIENT_FIELDS = {"job_state", "attempt_ordinal", "evidence_digest"} +_ROUTE_ACCEPTANCE_FIELDS = {"job_state", "evidence_digest"} + + +class RunControllerError(ValueError): + """The run state, authorization, or provider observation failed closed.""" + + +@dataclass(frozen=True) +class RunPlan: + run_id: str + authorization_sha256: str + provider_plan_digest: str + ledger_state: str + project: str + zone: str + route_instance: str + route_disk: str + route_firewalls: tuple[str, str] + route_source_commit: str + windows_instance: str + windows_disk: str + windows_source_commit: str + linux_instance: str + linux_disk: str + linux_source_commit: str + windows_package_sha256: str + windows_package_bytes: int + linux_package_sha256: str + linux_package_bytes: int + qwen_manifest: str + gemma_manifest: str + + @property + def instance_names(self) -> tuple[str, str, str]: + return (self.route_instance, self.windows_instance, self.linux_instance) + + @property + def disk_names(self) -> tuple[str, str, str]: + return (self.route_disk, self.windows_disk, self.linux_disk) + + +def _reject_constant(_value: str) -> None: + raise RunControllerError("invalid JSON") + + +def _unique_object(pairs: Sequence[tuple[str, Any]]) -> dict[str, Any]: + value: dict[str, Any] = {} + for key, item in pairs: + if key in value: + raise RunControllerError("duplicate JSON field") + value[key] = item + return value + + +def _strict_json_bytes(payload: bytes, maximum: int = MAX_JSON_BYTES) -> Mapping[str, Any]: + if not isinstance(payload, bytes) or not 1 <= len(payload) <= maximum: + raise RunControllerError("JSON size is invalid") + try: + value = json.loads( + payload.decode("utf-8"), + object_pairs_hook=_unique_object, + parse_constant=_reject_constant, + ) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise RunControllerError("invalid JSON") from exc + if not isinstance(value, dict): + raise RunControllerError("JSON root is invalid") + return value + + +def _regular_bytes(path: Path, maximum: int) -> bytes: + path = Path(path) + try: + metadata = path.lstat() + except OSError as exc: + raise RunControllerError("required file is unavailable") from exc + reparse = bool(getattr(metadata, "st_file_attributes", 0) & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)) + if reparse or path.is_symlink() or not stat.S_ISREG(metadata.st_mode) or not 1 <= metadata.st_size <= maximum: + raise RunControllerError("required file is unsafe") + try: + return path.read_bytes() + except OSError as exc: + raise RunControllerError("required file is unreadable") from exc + + +def _mapping(value: Any, fields: set[str], label: str) -> Mapping[str, Any]: + if not isinstance(value, dict) or set(value) != fields: + raise RunControllerError(f"{label} schema is invalid") + return value + + +def _string(value: Any, pattern: re.Pattern[str], label: str) -> str: + if not isinstance(value, str) or pattern.fullmatch(value) is None: + raise RunControllerError(f"{label} is invalid") + return value + + +def _boolean(value: Any, label: str) -> bool: + if type(value) is not bool: + raise RunControllerError(f"{label} is invalid") + return value + + +def _integer(value: Any, label: str, *, minimum: int = 0, maximum: int = 2**63 - 1) -> int: + if type(value) is not int or not minimum <= value <= maximum: + raise RunControllerError(f"{label} is invalid") + return value + + +def _provider_digest(provider_plan: Mapping[str, Any]) -> str: + return cost_guard._provider_plan_digest(provider_plan) + + +def load_plan(authorization_path: Path, ledger_path: Path) -> RunPlan: + authorization_payload = _regular_bytes(authorization_path, MAX_JSON_BYTES) + authorization = _strict_json_bytes(authorization_payload) + if authorization.get("schema_version") != 1 or authorization.get("gate") != 13: + raise RunControllerError("authorization scope is invalid") + if authorization.get("result") != "authorized": + raise RunControllerError("authorization is not active") + + run_id = _string(authorization.get("run_id"), _RUN_RE, "run id") + provider_plan = authorization.get("provider_plan") + if not isinstance(provider_plan, dict): + raise RunControllerError("provider plan is invalid") + provider_plan_digest = _provider_digest(provider_plan) + if authorization.get("provider_plan_digest") != provider_plan_digest: + raise RunControllerError("provider plan digest changed") + + authorization_section = authorization.get("authorization") + if not isinstance(authorization_section, dict): + raise RunControllerError("cost authorization is invalid") + try: + ceiling = float(authorization_section["combined_cloud_ceiling_usd"]) + before = float(authorization_section["ledger_committed_before_run_usd"]) + maximum = float(authorization_section["maximum_estimate_usd"]) + remaining = float(authorization_section["remaining_after_run_maximum_usd"]) + except (KeyError, TypeError, ValueError) as exc: + raise RunControllerError("cost authorization is invalid") from exc + if ( + not all(math.isfinite(value) for value in (ceiling, before, maximum, remaining)) + or ceiling != 100.0 + or before < 0 + or maximum <= 0 + or before + maximum > ceiling + or abs((ceiling - before - maximum) - remaining) > 0.001 + or authorization_section.get("reservation_recorded") is not True + or authorization_section.get("provisioning_authorized_after_fail_closed_preflight") is not True + ): + raise RunControllerError("cost authorization is inconsistent") + + prohibited = authorization.get("prohibited") + if not isinstance(prohibited, dict) or any(value != 0 or type(value) is not int for value in prohibited.values()): + raise RunControllerError("prohibited work is present") + + source = authorization.get("source") + immutable = authorization.get("immutable_inputs") + route = provider_plan.get("route") + clients = provider_plan.get("clients") + sequencing = provider_plan.get("sequencing") + if not all(isinstance(value, dict) for value in (source, immutable, route, sequencing)): + raise RunControllerError("authorization bindings are invalid") + if ( + not isinstance(clients, list) + or len(clients) != 2 + or sequencing.get("route_live_for_both_lifecycles") is not True + or sequencing.get("all_16_phases_required_per_platform") is not True + or sequencing.get("exact_cleanup_before_pass") is not True + ): + raise RunControllerError("execution sequencing is invalid") + + by_platform = { + client.get("platform"): client + for client in clients + if isinstance(client, dict) and isinstance(client.get("platform"), str) + } + if set(by_platform) != {"windows", "linux"}: + raise RunControllerError("client plan is invalid") + windows = by_platform["windows"] + linux = by_platform["linux"] + + project = _string(provider_plan.get("project"), _NAME_RE, "project") + route_instance = _string(route.get("instance"), _NAME_RE, "route instance") + zone = route.get("zone") + if not isinstance(zone, str) or not zone or route_instance == PROTECTED_INSTANCE: + raise RunControllerError("route target is invalid") + if windows.get("zone") != zone or linux.get("zone") != zone: + raise RunControllerError("client zones are inconsistent") + firewalls = route.get("firewalls") + if not isinstance(firewalls, list) or len(firewalls) != 2: + raise RunControllerError("firewall plan is invalid") + firewall_names = tuple(_string(value, _NAME_RE, "firewall") for value in firewalls) + instance_names = ( + route_instance, + _string(windows.get("instance"), _NAME_RE, "Windows instance"), + _string(linux.get("instance"), _NAME_RE, "Linux instance"), + ) + if len(set(instance_names)) != 3 or PROTECTED_INSTANCE in instance_names: + raise RunControllerError("instance targets are unsafe") + + ledger = _regular_bytes(ledger_path, MAX_JSON_BYTES * 4).decode("utf-8") + ledger_rows = [line for line in ledger.splitlines() if line.startswith(f"| {run_id} |")] + if len(ledger_rows) != 1 or provider_plan_digest not in ledger_rows[0]: + raise RunControllerError("ledger reservation is absent") + ledger_cells = [cell.strip() for cell in ledger_rows[0].strip().strip("|").split("|")] + if len(ledger_cells) != 7 or ledger_cells[0] != run_id: + raise RunControllerError("ledger reservation is invalid") + ledger_state = ledger_cells[-1] + if ledger_state not in {"RESERVED", "CLEANED-COMMITTED", "CLEANED-RELEASED"}: + raise RunControllerError("ledger state is invalid") + + windows_package = immutable.get("windows_package") + linux_package = immutable.get("linux_package") + if not isinstance(windows_package, dict) or not isinstance(linux_package, dict): + raise RunControllerError("package bindings are invalid") + + route_source = _string(source.get("route_runtime_commit"), _COMMIT_RE, "route source") + package_source = _string(source.get("package_commit"), _COMMIT_RE, "package source") + qwen_manifest = _string(immutable.get("qwen_manifest"), _DIGEST_RE, "Qwen manifest") + gemma_manifest = _string(immutable.get("gemma_manifest"), _DIGEST_RE, "Gemma manifest") + windows_sha = _string(windows_package.get("sha256"), _DIGEST_RE, "Windows package digest") + linux_sha = _string(linux_package.get("sha256"), _DIGEST_RE, "Linux package digest") + + authorization_sha256 = "sha256:" + hashlib.sha256(authorization_payload).hexdigest() + return RunPlan( + run_id=run_id, + authorization_sha256=authorization_sha256, + provider_plan_digest=provider_plan_digest, + ledger_state=ledger_state, + project=project, + zone=zone, + route_instance=route_instance, + route_disk=route_instance, + route_firewalls=(firewall_names[0], firewall_names[1]), + route_source_commit=route_source, + windows_instance=instance_names[1], + windows_disk=instance_names[1], + windows_source_commit=package_source, + linux_instance=instance_names[2], + linux_disk=instance_names[2], + linux_source_commit=package_source, + windows_package_sha256=windows_sha, + windows_package_bytes=_integer(windows_package.get("bytes"), "Windows package bytes", minimum=1), + linux_package_sha256=linux_sha, + linux_package_bytes=_integer(linux_package.get("bytes"), "Linux package bytes", minimum=1), + qwen_manifest=qwen_manifest, + gemma_manifest=gemma_manifest, + ) + + +def initial_state(plan: RunPlan) -> dict[str, Any]: + if plan.ledger_state != "RESERVED": + raise RunControllerError("authorization is not reserved for provisioning") + return { + "schema_version": STATE_SCHEMA_VERSION, + "run_id": plan.run_id, + "authorization_sha256": plan.authorization_sha256, + "provider_plan_digest": plan.provider_plan_digest, + "revision": 0, + "phase": "ABSENT", + "failure_code": None, + "route_acceptance_digest": None, + "windows_evidence_digest": None, + "linux_evidence_digest": None, + "windows_consumed": False, + "linux_consumed": False, + "cleanup_verified": False, + "next_action": "start_route", + } + + +def validate_state(raw: Mapping[str, Any], plan: RunPlan) -> dict[str, Any]: + state = dict(_mapping(raw, _STATE_FIELDS, "state")) + if ( + state["schema_version"] != STATE_SCHEMA_VERSION + or state["run_id"] != plan.run_id + or state["authorization_sha256"] != plan.authorization_sha256 + or state["provider_plan_digest"] != plan.provider_plan_digest + ): + raise RunControllerError("state authorization binding changed") + _integer(state["revision"], "state revision") + if state["phase"] not in PHASES or state["next_action"] not in ACTION_STATES: + raise RunControllerError("state transition is invalid") + for field in ("windows_consumed", "linux_consumed", "cleanup_verified"): + _boolean(state[field], field) + for field in ("route_acceptance_digest", "windows_evidence_digest", "linux_evidence_digest"): + if state[field] is not None: + _string(state[field], _DIGEST_RE, field) + failure = state["failure_code"] + if failure is not None and (not isinstance(failure, str) or not re.fullmatch(r"[a-z0-9_]{1,64}", failure)): + raise RunControllerError("failure code is invalid") + if state["phase"] in TERMINAL_PHASES and state["cleanup_verified"] is not True: + raise RunControllerError("terminal state lacks cleanup proof") + return state + + +def load_state(path: Path, plan: RunPlan) -> dict[str, Any]: + path = Path(path) + if not path.exists(): + return initial_state(plan) + return validate_state(_strict_json_bytes(_regular_bytes(path, MAX_STATE_BYTES), MAX_STATE_BYTES), plan) + + +def _atomic_write(path: Path, state: Mapping[str, Any]) -> None: + payload = (json.dumps(state, allow_nan=False, separators=(",", ":"), sort_keys=True) + "\n").encode("utf-8") + if len(payload) > MAX_STATE_BYTES: + raise RunControllerError("state exceeds its bound") + path = Path(os.path.abspath(os.fspath(path))) + path.parent.mkdir(parents=True, exist_ok=True) + if path.exists() and (path.is_symlink() or not path.is_file()): + raise RunControllerError("state path is unsafe") + descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + temporary = Path(temporary_name) + try: + try: + os.chmod(temporary, 0o600) + except OSError: + pass + with os.fdopen(descriptor, "wb") as stream: + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def _present(mapping: Mapping[str, Any]) -> bool: + return any(value is True for value in mapping.values()) + + +def _instance_present(observation: Mapping[str, Any], name: str) -> bool: + return bool(observation["instances"][name]["present"]) + + +def validate_observation(raw: Mapping[str, Any], plan: RunPlan, now_unix: int) -> dict[str, Any]: + observation = dict(_mapping(raw, _OBSERVATION_FIELDS, "observation")) + if observation["schema_version"] != SCHEMA_VERSION or observation["run_id"] != plan.run_id: + raise RunControllerError("observation binding is invalid") + observed_at = _integer(observation["observed_at_unix"], "observation time", minimum=1) + if abs(observed_at - now_unix) > 300: + raise RunControllerError("observation is stale") + _boolean(observation["protected_bootstrap_running"], "protected bootstrap state") + if observation["protected_bootstrap_running"] is not True: + raise RunControllerError("protected bootstrap is unavailable") + + instances = observation["instances"] + disks = observation["disks"] + firewalls = observation["firewalls"] + if ( + not isinstance(instances, dict) + or set(instances) != set(plan.instance_names) + or not isinstance(disks, dict) + or set(disks) != set(plan.disk_names) + or not isinstance(firewalls, dict) + or set(firewalls) != set(plan.route_firewalls) + ): + raise RunControllerError("resource inventory is not exact") + expected_sources = { + plan.route_instance: plan.route_source_commit, + plan.windows_instance: plan.windows_source_commit, + plan.linux_instance: plan.linux_source_commit, + } + for name, value in instances.items(): + item = _mapping(value, _INSTANCE_FIELDS, "instance") + present = _boolean(item["present"], "instance presence") + if present: + if item["run_id"] != plan.run_id or item["source_commit"] != expected_sources[name]: + raise RunControllerError("foreign exact-name instance is present") + termination = _integer(item["termination_unix"], "termination deadline", minimum=1) + if termination <= observed_at: + raise RunControllerError("instance deadline expired") + elif any(item[field] is not None for field in ("run_id", "source_commit", "termination_unix")): + raise RunControllerError("absent instance carries identity") + for value in (*disks.values(), *firewalls.values()): + _boolean(value, "resource presence") + + route_acceptance = _mapping(observation["route_acceptance"], _ROUTE_ACCEPTANCE_FIELDS, "route acceptance") + if route_acceptance["job_state"] not in JOB_STATES: + raise RunControllerError("route acceptance state is invalid") + if route_acceptance["evidence_digest"] is not None: + _string(route_acceptance["evidence_digest"], _DIGEST_RE, "route acceptance digest") + clients = observation["clients"] + if not isinstance(clients, dict) or set(clients) != {"windows", "linux"}: + raise RunControllerError("client job inventory is invalid") + for value in clients.values(): + client = _mapping(value, _CLIENT_FIELDS, "client job") + if client["job_state"] not in JOB_STATES: + raise RunControllerError("client job state is invalid") + _integer(client["attempt_ordinal"], "attempt ordinal", maximum=1) + if client["evidence_digest"] is not None: + _string(client["evidence_digest"], _DIGEST_RE, "client evidence digest") + return observation + + +def _all_resources_absent(observation: Mapping[str, Any]) -> bool: + return ( + not any(value["present"] for value in observation["instances"].values()) + and not _present(observation["disks"]) + and not _present(observation["firewalls"]) + ) + + +def _fail(state: dict[str, Any], code: str, observation: Mapping[str, Any]) -> dict[str, Any]: + state["failure_code"] = code + state["phase"] = "CLEANING_FAILED" + state["next_action"] = "cleanup_failure" + for platform in ("windows", "linux"): + if observation["clients"][platform]["job_state"] != "absent": + state[f"{platform}_consumed"] = True + return state + + +def reconcile( + state: Mapping[str, Any], + observation: Mapping[str, Any], + plan: RunPlan, + *, + now_unix: int, +) -> dict[str, Any]: + current = validate_state(state, plan) + observed = validate_observation(observation, plan, now_unix) + result = dict(current) + + if current["phase"] in TERMINAL_PHASES: + if not _all_resources_absent(observed): + raise RunControllerError("resource reappeared after terminal cleanup") + return result + + if _all_resources_absent(observed): + if current["phase"] in {"CLEANING_FAILED", "ROUTE_DELETING", "LINUX_COLLECTED"}: + passed = ( + current["failure_code"] is None + and current["route_acceptance_digest"] is not None + and current["windows_evidence_digest"] is not None + and current["linux_evidence_digest"] is not None + ) + result["phase"] = "CLEANED_PASS" if passed else "CLEANED_FAILURE" + result["cleanup_verified"] = True + result["next_action"] = "none" + else: + result["phase"] = "ABSENT" + result["next_action"] = "start_route" + result["revision"] += 1 + return validate_state(result, plan) + + route_present = _instance_present(observed, plan.route_instance) + windows_present = _instance_present(observed, plan.windows_instance) + linux_present = _instance_present(observed, plan.linux_instance) + route_job = observed["route_acceptance"]["job_state"] + windows_job = observed["clients"]["windows"]["job_state"] + linux_job = observed["clients"]["linux"]["job_state"] + + if not route_present or route_job in {"failed", "ambiguous"}: + return _fail(result, "route_failed_or_ambiguous", observed) + route_deadline = observed["instances"][plan.route_instance]["termination_unix"] + if route_deadline - now_unix < MIN_ROUTE_RUNWAY_SECONDS: + return _fail(result, "route_runway_exhausted", observed) + + if route_job != "passed": + if windows_present or linux_present: + return _fail(result, "client_started_before_route_acceptance", observed) + result["phase"] = "ROUTE_ACCEPTING" + result["next_action"] = "accept_route" + else: + route_digest = observed["route_acceptance"]["evidence_digest"] + if route_digest is None: + return _fail(result, "route_acceptance_digest_absent", observed) + result["route_acceptance_digest"] = route_digest + if linux_present and current["windows_evidence_digest"] is None: + return _fail(result, "linux_started_before_windows_evidence", observed) + if windows_present: + result["windows_consumed"] = windows_job != "absent" + if windows_job in {"failed", "ambiguous"}: + return _fail(result, "windows_failed_or_ambiguous", observed) + if windows_job == "passed": + result["phase"] = "WINDOWS_COLLECTING" + result["next_action"] = "collect_windows" + elif windows_job == "absent": + result["phase"] = "ROUTE_ACCEPTED" + result["next_action"] = "start_windows" + else: + result["phase"] = "WINDOWS_RUNNING" + result["next_action"] = "none" + elif current["windows_evidence_digest"] is None: + if current["windows_consumed"]: + return _fail(result, "windows_consumed_without_evidence", observed) + result["phase"] = "ROUTE_ACCEPTED" + result["next_action"] = "start_windows" + elif linux_present: + result["linux_consumed"] = linux_job != "absent" + if linux_job in {"failed", "ambiguous"}: + return _fail(result, "linux_failed_or_ambiguous", observed) + if linux_job == "passed": + result["phase"] = "LINUX_COLLECTING" + result["next_action"] = "collect_linux" + elif linux_job == "absent": + result["phase"] = "WINDOWS_COLLECTED" + result["next_action"] = "start_linux" + else: + result["phase"] = "LINUX_RUNNING" + result["next_action"] = "none" + elif current["linux_evidence_digest"] is None: + if current["linux_consumed"]: + return _fail(result, "linux_consumed_without_evidence", observed) + result["phase"] = "WINDOWS_COLLECTED" + result["next_action"] = "start_linux" + else: + result["phase"] = "LINUX_COLLECTED" + result["next_action"] = "delete_route" + + result["revision"] += 1 + return validate_state(result, plan) + + +def collect_platform( + state: Mapping[str, Any], + plan: RunPlan, + *, + platform: str, + evidence_payload: bytes, + observed_digest: str, +) -> dict[str, Any]: + current = validate_state(state, plan) + if platform not in {"windows", "linux"}: + raise RunControllerError("platform is invalid") + expected_phase = "WINDOWS_COLLECTING" if platform == "windows" else "LINUX_COLLECTING" + if current["phase"] != expected_phase: + raise RunControllerError("evidence collection is out of order") + digest = "sha256:" + hashlib.sha256(evidence_payload).hexdigest() + if digest != observed_digest: + raise RunControllerError("host evidence digest changed") + try: + raw = lifecycle.load_lifecycle_json(evidence_payload.decode("utf-8")) + validated = lifecycle.validate_lifecycle_document(raw) + except Exception as exc: + raise RunControllerError("lifecycle evidence is invalid") from exc + expected = { + "windows": { + "source_commit": plan.windows_source_commit, + "package_sha256": plan.windows_package_sha256, + "package_bytes": plan.windows_package_bytes, + "model_id": "Qwen3.5 2B", + "manifest_digest": plan.qwen_manifest.removeprefix("sha256:"), + }, + "linux": { + "source_commit": plan.linux_source_commit, + "package_sha256": plan.linux_package_sha256, + "package_bytes": plan.linux_package_bytes, + "model_id": "Gemma 4 E2B IT", + "manifest_digest": plan.gemma_manifest.removeprefix("sha256:"), + }, + }[platform] + for field, value in expected.items(): + if validated.get(field) != value: + raise RunControllerError("lifecycle evidence binding changed") + + result = dict(current) + result[f"{platform}_evidence_digest"] = digest + result[f"{platform}_consumed"] = True + if platform == "windows": + result["phase"] = "WINDOWS_DELETING" + result["next_action"] = "delete_windows" + else: + result["phase"] = "LINUX_DELETING" + result["next_action"] = "delete_linux" + result["revision"] += 1 + return validate_state(result, plan) + + +def mark_client_absent(state: Mapping[str, Any], plan: RunPlan, *, platform: str) -> dict[str, Any]: + current = validate_state(state, plan) + expected = "WINDOWS_DELETING" if platform == "windows" else "LINUX_DELETING" + if current["phase"] != expected or current[f"{platform}_evidence_digest"] is None: + raise RunControllerError("client deletion is out of order") + result = dict(current) + if platform == "windows": + result["phase"] = "WINDOWS_COLLECTED" + result["next_action"] = "start_linux" + else: + result["phase"] = "LINUX_COLLECTED" + result["next_action"] = "delete_route" + result["revision"] += 1 + return validate_state(result, plan) + + +def persist(path: Path, state: Mapping[str, Any], plan: RunPlan) -> None: + _atomic_write(path, validate_state(state, plan)) + + +def public_status(state: Mapping[str, Any], plan: RunPlan) -> dict[str, Any]: + current = validate_state(state, plan) + return { + "schema_version": 1, + "run_id": current["run_id"], + "phase": current["phase"], + "next_action": current["next_action"], + "failure_code": current["failure_code"], + "windows_consumed": current["windows_consumed"], + "linux_consumed": current["linux_consumed"], + "cleanup_verified": current["cleanup_verified"], + } diff --git a/tests/test_gate13_run_controller.py b/tests/test_gate13_run_controller.py new file mode 100644 index 000000000..93182b77a --- /dev/null +++ b/tests/test_gate13_run_controller.py @@ -0,0 +1,325 @@ +import hashlib +import json +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import gate13_run_controller as controller # noqa: E402 + +AUTHORIZATION = ROOT / "docs" / "evidence" / "gate13-20260831-a-cost-authorization.json" +LEDGER = ROOT / "docs" / "RELEASE_READINESS.md" +NOW = 2_000_000_000 +ROUTE_DIGEST = "sha256:" + "a" * 64 +WINDOWS_DIGEST = "sha256:" + "b" * 64 +LINUX_DIGEST = "sha256:" + "c" * 64 + + +@pytest.fixture +def plan(tmp_path): + ledger = tmp_path / "ledger.md" + ledger.write_text( + LEDGER.read_text(encoding="utf-8").replace( + "| CLEANED-COMMITTED |", + "| RESERVED |", + 1, + ), + encoding="utf-8", + ) + return controller.load_plan(AUTHORIZATION, ledger) + + +def observation( + plan, *, route=False, windows=False, linux=False, route_job="absent", windows_job="absent", linux_job="absent" +): + present = { + plan.route_instance: (route, plan.route_source_commit), + plan.windows_instance: (windows, plan.windows_source_commit), + plan.linux_instance: (linux, plan.linux_source_commit), + } + return { + "schema_version": 1, + "run_id": plan.run_id, + "observed_at_unix": NOW, + "instances": { + name: { + "present": exists, + "run_id": plan.run_id if exists else None, + "source_commit": source if exists else None, + "termination_unix": NOW + 20_000 if exists else None, + } + for name, (exists, source) in present.items() + }, + "disks": { + plan.route_disk: route, + plan.windows_disk: windows, + plan.linux_disk: linux, + }, + "firewalls": { + plan.route_firewalls[0]: route, + plan.route_firewalls[1]: route, + }, + "protected_bootstrap_running": True, + "route_acceptance": { + "job_state": route_job, + "evidence_digest": ROUTE_DIGEST if route_job == "passed" else None, + }, + "clients": { + "windows": { + "job_state": windows_job, + "attempt_ordinal": 1 if windows_job != "absent" else 0, + "evidence_digest": WINDOWS_DIGEST if windows_job == "passed" else None, + }, + "linux": { + "job_state": linux_job, + "attempt_ordinal": 1 if linux_job != "absent" else 0, + "evidence_digest": LINUX_DIGEST if linux_job == "passed" else None, + }, + }, + } + + +def test_load_plan_binds_exact_cost_and_resources(plan): + assert plan.run_id == "gate13-20260831-a" + assert plan.provider_plan_digest.startswith("sha256:") + assert plan.ledger_state == "RESERVED" + assert plan.instance_names == ( + "route-20260831-a-node", + "gate13-20260831-a-win", + "gate13-20260831-a-linux", + ) + assert controller.PROTECTED_INSTANCE not in plan.instance_names + + +def test_cleaned_committed_ledger_cannot_start_a_new_run(): + historical_plan = controller.load_plan(AUTHORIZATION, LEDGER) + + assert historical_plan.ledger_state == "CLEANED-COMMITTED" + with pytest.raises(controller.RunControllerError, match="not reserved"): + controller.initial_state(historical_plan) + + +def test_changed_authorization_fails_closed(tmp_path): + raw = json.loads(AUTHORIZATION.read_text(encoding="utf-8")) + raw["provider_plan"]["route"]["machine_type"] = "e2-micro" + changed = tmp_path / "authorization.json" + changed.write_text(json.dumps(raw), encoding="utf-8") + + with pytest.raises(controller.RunControllerError, match="digest changed"): + controller.load_plan(changed, LEDGER) + + +def test_inventory_precedes_route_and_route_acceptance_precedes_clients(plan): + state = controller.initial_state(plan) + + absent = controller.reconcile(state, observation(plan), plan, now_unix=NOW) + assert absent["phase"] == "ABSENT" + assert absent["next_action"] == "start_route" + + accepting = controller.reconcile( + absent, + observation(plan, route=True, route_job="running"), + plan, + now_unix=NOW, + ) + assert accepting["phase"] == "ROUTE_ACCEPTING" + assert accepting["next_action"] == "accept_route" + + invalid = controller.reconcile( + accepting, + observation(plan, route=True, windows=True, route_job="running", windows_job="starting"), + plan, + now_unix=NOW, + ) + assert invalid["phase"] == "CLEANING_FAILED" + assert invalid["failure_code"] == "client_started_before_route_acceptance" + + +def test_exact_name_with_foreign_identity_fails_closed(plan): + raw = observation(plan, route=True, route_job="running") + raw["instances"][plan.route_instance]["run_id"] = "foreign-run" + + with pytest.raises(controller.RunControllerError, match="foreign exact-name"): + controller.reconcile(controller.initial_state(plan), raw, plan, now_unix=NOW) + + +def test_route_acceptance_starts_windows_before_linux(plan): + state = controller.reconcile( + controller.initial_state(plan), + observation(plan, route=True, route_job="passed"), + plan, + now_unix=NOW, + ) + assert state["phase"] == "ROUTE_ACCEPTED" + assert state["next_action"] == "start_windows" + + invalid = controller.reconcile( + state, + observation(plan, route=True, linux=True, route_job="passed", linux_job="running"), + plan, + now_unix=NOW, + ) + assert invalid["phase"] == "CLEANING_FAILED" + assert invalid["failure_code"] == "linux_started_before_windows_evidence" + + +@pytest.mark.parametrize("job_state", ["failed", "ambiguous"]) +def test_failed_or_ambiguous_windows_is_consumed_and_never_resumed(plan, job_state): + state = controller.reconcile( + controller.initial_state(plan), + observation(plan, route=True, windows=True, route_job="passed", windows_job=job_state), + plan, + now_unix=NOW, + ) + + assert state["phase"] == "CLEANING_FAILED" + assert state["windows_consumed"] is True + assert state["next_action"] == "cleanup_failure" + + +def test_active_host_job_is_observed_not_relaunched(plan): + state = controller.reconcile( + controller.initial_state(plan), + observation(plan, route=True, windows=True, route_job="passed", windows_job="running"), + plan, + now_unix=NOW, + ) + + assert state["phase"] == "WINDOWS_RUNNING" + assert state["next_action"] == "none" + + +def test_collect_binds_canonical_evidence_then_deletes_windows(monkeypatch, plan): + state = controller.reconcile( + controller.initial_state(plan), + observation(plan, route=True, windows=True, route_job="passed", windows_job="passed"), + plan, + now_unix=NOW, + ) + assert state["phase"] == "WINDOWS_COLLECTING" + + payload = b'{"bounded":true}' + digest = "sha256:" + hashlib.sha256(payload).hexdigest() + monkeypatch.setattr(controller.lifecycle, "load_lifecycle_json", lambda _payload: {"validated": True}) + monkeypatch.setattr( + controller.lifecycle, + "validate_lifecycle_document", + lambda _raw: { + "source_commit": plan.windows_source_commit, + "package_sha256": plan.windows_package_sha256, + "package_bytes": plan.windows_package_bytes, + "model_id": "Qwen3.5 2B", + "manifest_digest": plan.qwen_manifest.removeprefix("sha256:"), + }, + ) + + collected = controller.collect_platform( + state, + plan, + platform="windows", + evidence_payload=payload, + observed_digest=digest, + ) + assert collected["phase"] == "WINDOWS_DELETING" + assert collected["next_action"] == "delete_windows" + assert collected["windows_consumed"] is True + + after_delete = controller.reconcile( + collected, + observation(plan, route=True, route_job="passed"), + plan, + now_unix=NOW, + ) + assert after_delete["phase"] == "WINDOWS_COLLECTED" + assert after_delete["next_action"] == "start_linux" + + +def test_partial_or_wrong_digest_evidence_cannot_advance(plan): + state = controller.reconcile( + controller.initial_state(plan), + observation(plan, route=True, windows=True, route_job="passed", windows_job="passed"), + plan, + now_unix=NOW, + ) + + with pytest.raises(controller.RunControllerError, match="digest changed"): + controller.collect_platform( + state, + plan, + platform="windows", + evidence_payload=b"{}", + observed_digest=WINDOWS_DIGEST, + ) + + +def test_success_requires_both_records_and_exact_absence(plan): + state = controller.initial_state(plan) + state.update( + { + "phase": "LINUX_COLLECTED", + "route_acceptance_digest": ROUTE_DIGEST, + "windows_evidence_digest": WINDOWS_DIGEST, + "linux_evidence_digest": LINUX_DIGEST, + "windows_consumed": True, + "linux_consumed": True, + "next_action": "delete_route", + } + ) + + complete = controller.reconcile(state, observation(plan), plan, now_unix=NOW) + assert complete["phase"] == "CLEANED_PASS" + assert complete["cleanup_verified"] is True + assert complete["next_action"] == "none" + + +def test_failure_cleanup_is_idempotent_and_never_becomes_pass(plan): + state = controller.initial_state(plan) + state.update( + { + "phase": "CLEANING_FAILED", + "failure_code": "windows_failed_or_ambiguous", + "windows_consumed": True, + "next_action": "cleanup_failure", + } + ) + + cleaned = controller.reconcile(state, observation(plan), plan, now_unix=NOW) + assert cleaned["phase"] == "CLEANED_FAILURE" + assert controller.reconcile(cleaned, observation(plan), plan, now_unix=NOW) == cleaned + + +def test_stale_observation_and_expired_deadline_fail_closed(plan): + stale = observation(plan) + stale["observed_at_unix"] = NOW - 301 + with pytest.raises(controller.RunControllerError, match="stale"): + controller.reconcile(controller.initial_state(plan), stale, plan, now_unix=NOW) + + expired = observation(plan, route=True, route_job="running") + expired["instances"][plan.route_instance]["termination_unix"] = NOW + with pytest.raises(controller.RunControllerError, match="deadline expired"): + controller.reconcile(controller.initial_state(plan), expired, plan, now_unix=NOW) + + +def test_atomic_state_round_trip_and_public_status_are_bounded(tmp_path, plan): + state_path = tmp_path / "state.json" + state = controller.initial_state(plan) + controller.persist(state_path, state, plan) + + assert controller.load_state(state_path, plan) == state + public = controller.public_status(state, plan) + assert set(public) == { + "schema_version", + "run_id", + "phase", + "next_action", + "failure_code", + "windows_consumed", + "linux_consumed", + "cleanup_verified", + } + rendered = json.dumps(public) + for forbidden in ("token", "password", "prompt", "endpoint", str(tmp_path)): + assert forbidden not in rendered.lower() From bdaea0e7fb672ea35ae65a0d8844c9c62ea9d7bf Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:09:36 -0500 Subject: [PATCH 05/56] docs: record Gate 13 failed durable-run attempt --- CHANGELOG.md | 8 ++ docs/RELEASE_READINESS.md | 26 +++--- docs/REVIVAL.md | 24 +++++ ...20260831-a-failed-attempt-and-cleanup.json | 88 +++++++++++++++++++ 4 files changed, 135 insertions(+), 11 deletions(-) create mode 100644 docs/evidence/gate13-20260831-a-failed-attempt-and-cleanup.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c1bff885..5a9f6f75e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and qualification evidence remains in `docs/REVIVAL_TEST_RESULTS.md`. ### Added +- Gate 13 paid qualification now has a persisted authorization-bound run-state contract that + inventories exact resources before every transition, accepts the product route before any + client, runs Windows/Qwen before Linux/Gemma, permanently consumes a failed or ambiguous + lifecycle host, rejects stale/foreign/deadline-expired observations, validates digest-bound + canonical 16-phase records, and permits a pass only after both records and exact provider + absence. The failed `gate13-20260831-a` attempt is cleanup-proved: its route, clients, disks, + and firewalls are absent while the protected bootstrap remains running. No lifecycle pass is + claimed, and the run's USD 52 maximum remains committed in the current budget epoch. - Gate 13 packaged-lifecycle prerequisites now emit deterministic self-contained Windows ZIP and Linux tar.gz archives, preserve or reject platform filesystem semantics fail-closed, and bind the archive plus strict desktop metrics into exact-type release provenance. The local diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md index df35840bf..b535ee139 100644 --- a/docs/RELEASE_READINESS.md +++ b/docs/RELEASE_READINESS.md @@ -46,14 +46,17 @@ strict four-profile Qwen and Gemma matrices now pass, and Gate 7 passed the gene five-Machine provider recovery mechanism with TinyLlama. Per-model repetition of the same provider recovery gate is not required. -As of 2026-08-31, Gate 13 is `IN PROGRESS`: cleanup proved the prior Gate 11 route, -its run-scoped firewall rules, and every prior Gate 13 client and disk absent. Native GCP -token refresh and Compute access now pass, the protected bootstrap remains running, and -[run `gate13-20260831-a`](evidence/gate13-20260831-a-cost-authorization.json) reserves a -conservative USD 52 maximum from the new USD 100 epoch for one bounded replacement route -plus fresh CPU Windows/Linux clients. Exact targets, images, machine types, and the sole -required L4 quota passed read-only preflight. No later mandatory gate is unblocked until -the live packaged route and both completed 16-phase Gate 13 lifecycle records exist. +As of 2026-08-31, Gate 13 is `IN PROGRESS`: [run `gate13-20260831-a`](evidence/gate13-20260831-a-failed-attempt-and-cleanup.json) +proved both exact production archives downloaded on the intended clients, but neither +16-phase lifecycle completed. The run coupled long work to transient operator sessions, +advanced both clients before route acceptance, and consumed both hosts through lifecycle +or diagnostic product launches. They are not reusable as fresh acceptance hosts. Exact +cleanup now proves the route, both clients, all three disks, and both firewall rules absent; +the protected bootstrap remains running. The USD 52 maximum remains committed in the current +USD 100 epoch, leaving USD 48 without an explicit cleanup-backed owner reset. Source +`ddfb7c6` adds the first persisted fail-closed run-state contract and 15 adversarial tests; +the 187-test Gate 13 package/lifecycle regression passes. No later mandatory gate is unblocked +until a controller-bound fresh run produces both complete 16-phase records and exact cleanup. Do not work on the post-alpha items in the deferred table while an alpha gate can progress. Missing Docker, snapshots, local GPU hardware, or local host capacity is not an external @@ -85,7 +88,7 @@ longer consume the new authorization; later billing should still be recorded for | 10 | Implement automatic contributor model and block placement | PASSED | Signed bootstrap now installs one bounded `auto` worker. The local planner filters exact manifested candidates through owner policy and local resource ceilings, requires fresh authenticated replica coverage, targets the least-covered contiguous range with per-node jitter, reconciles exact-manifest launches through the existing artifact-verifying server and `WorkerSupervisor`, applies residency/cooldown/switch hysteresis, exposes placement reasons, and preserves an explicit operator pause across ineligibility or placement changes. A new or migrated worker must sign an expiring exact-manifest/range intent with fixed numeric resource claims and receive a remote DHT store acknowledgement (`exclude_self=True`) before entering the artifact path; invalid, rejected, or failed publication is fail-closed and cannot advance planner state, while a previously admitted placement is retained. Actual completed local generations feed exact-manifest demand, useful-throughput, and reliability through two bounded five-minute aggregate windows; no prompt, output, token ID, key, request ID, address, path, error, or per-request event is retained. Only a closed window with at least four completed routes may be signed by the separate router identity and published under the manifest-bound `demand-v1` DHT key with a 90-second lifetime and `exclude_self=True`. Consumers verify signature, exact schema/digest, lifetime, revocation, and replay ordering. The threshold-signed catalog may authorize 2–32 sorted RSA observer roots; missing or empty roots disable remote demand. Discovery discards unlisted identities before signature/replay work, excludes local and duplicate roots, isolates malformed records, requires two authorized roots, and medians at most 32 quantized observations. Observer keys are never generated or bundled: only a separately provisioned `route-demand.key` matching a signed root may publish, while ordinary nodes can consume without one. Any hot-edited root-list mismatch disables both publication and consumption until restart. Local utility is capped at 6 points and signed remote utility at 2, keeping the combined hint below the 10-point migration margin and 100-point replica step. Verified announcement and route-demand replay watermarks now survive restarts in one Windows-safe journal per raw manifest digest under the node data directory. Each strict journal is capped at 256 active identity scopes and 256 KiB, retains only public record kind, key ID, ordering tuple, record digest, and the bounded replay deadline, and is fsync-written through atomic replacement; malformed, duplicate, oversized, symlinked, non-regular, or unwritable state fails closed. The retained deadline prevents an older still-live record from returning after a short-lived newer record expires. The replay slice's 99-test focused protocol/discovery/planner/node-configuration matrix and 209-pass, 2-skip catalog/node/API superset pass. The Sybil slice's 122-test focused catalog/bootstrap/config/discovery matrix proves that 30 valid attacker keys plus one authorized root cannot reach threshold, two authorized roots aggregate without attacker weight, one high authorized vote cannot inflate a lower second vote, old catalogs remain signature-verifiable with remote demand disabled, and trust-epoch reload mismatches fail closed. A 190-pass, 1-skip catalog/protocol/planner/discovery/node/API superset also passes. Independent verification passed 146 focused tests and a 255-pass, 2-skip broader node/API superset, plus a native-Windows publication-boundary probe; formatting, import-order, import-smoke, and diff checks pass. The [explicit privacy review](AUTOMATIC_PLACEMENT_PRIVACY_V1.md) inventories collection, retention, public-key linkability, DHT/journal/API/log exposure, secure-deletion limits, and residual governance/host risks. Three executable privacy-contract tests fix the aggregate, intent, demand, replay, forbidden-field, and path-free warning schemas; the focused privacy/protocol/planner/discovery/node matrix passes 108 tests and the broader catalog/node/API matrix passes 258 tests with 2 skips. Independent privacy review passed 108 tests with 1 skip and a 225-pass, 2-skip broader subset; every caught observer-key exception and an unauthorized key produced no path, key ID, or exception detail, while prompt and identity-path schema injections failed closed. The [deterministic convergence and load acceptance](AUTOMATIC_PLACEMENT_ACCEPTANCE_V1.md) closes the remaining software gate: equal snapshots use node-specific 32-point model dispersion and range rendezvous ranks; a fixed 512-node cold cohort selects both models and every range below the 85% concentration boundary; two 4,096-node fresh-arrival cohorts remain below that boundary under maximum priority-aligned or standby demand; maximum demand causes zero incumbent migrations; one-replica loss migrates after residency without early reversal; rolling arrivals keep every model/block populated and repair an abrupt block loss. The alpha fails closed above 32 candidates or 512 blocks, permits one `auto` worker, clamps reconciliation to at least one second, and scans each candidate in one bounded pass. The focused planner/convergence/configuration matrix passes 78 tests and the broader catalog/protocol/discovery/node/API matrix passes 214 with 2 skips. A real Windows DHT round trip exposed and fixed a durable-replay multiprocessing regression: replay guards now omit/recreate their thread lock across serialization and reload persistent state; its 15-test protocol/network matrix passes. Independent verification reproduced the 78-test focus, passed an expanded 235-test matrix with 2 skips and the 15-test real-DHT probe, and exercised adversarial score, timing, 32-by-512 load, 1,000-case range-equivalence, and persistent replay-reload boundaries. This slice used no cloud resources and spent USD 0. | Gates 9–11 are passed. Gates 13–14 must now prove the packaged flow and real hardware ceilings using the published envelopes. | | 11 | Operate initial public alpha routes | PASSED | [Product-node run `route-20260830-j`](evidence/gate11node-20260830-a-lifecycle.json) installed the generic CommunityAI wheel on a bounded G2/L4 VM, verified the signed catalog, downloaded both exact manifested models directly from Hugging Face into one persistent shared cache, and used the product node's automatic workers to expose complete Qwen 24/24 primary and Gemma 35/35 standby routes. No model-specific image, cache mirror, or operator-transferred model artifact was used. The privacy-safe acceptance passed one-token primary inference, deliberate primary pause, automatic Gemma selection in 58.073 seconds, standby inference, Qwen restoration in 32.042 seconds, and restored inference. Both workers were stable before the drill. After Gate 13 released the L4, the preserved route was restored without changing its model cache or source, its ephemeral endpoint was rebound, both product-node services became active, and a fresh acceptance reproved Qwen 24/24 primary inference, automatic Gemma 35/35 fallback/inference, Qwen restoration, and restored inference. The protected bootstrap remains running. A corrected 4,800-second provider DELETE backstop was set for `2026-08-31T05:28:16.516Z`, earlier than the original deadline. [Post-backstop cleanup evidence](evidence/gate11route-20260830-j-backstop-cleanup.json) and an independent recheck prove the route instance, named disk, and both exact run-scoped firewall rules absent, all Gate 13 clients/disks absent, zero remaining route availability, and the protected bootstrap still running. The same-host standby is a bounded alpha fallback, not independent infrastructure redundancy; independent redundancy remains post-alpha. | Gate 11 acceptance evidence remains complete, but no product route is live after the corrected DELETE backstop. [Gate 13 run `gate13-20260831-a`](evidence/gate13-20260831-a-cost-authorization.json) now binds refreshed native authentication, fail-closed preflight, and a fresh USD 52 reservation for the replacement route and packaged clients. | | 12 | Create, publish, and bundle the minimal signed alpha catalog/bootstrap | PASSED | [Run `gate12-20260829-a`](evidence/gate12-20260829-alpha-catalog-publication.json) published the deterministic [`communityai-public-alpha-v1` bundle](../public-alpha/catalog-v1/bundle.json) from source `26be579`. Its threshold-one Ed25519 root signs sequence 1 with the exact qualified Qwen primary and Gemma standby manifests, one pinned public HTTPS mirror, one public seed, a one-route best-effort policy, and no unprovisioned route-demand roots. The canonical bundle binds five members and retains `complete_release_qualification=false`. All three public objects returned HTTP 200 with exact sizes, and a fresh empty consumer fetched them remotely, verified the signature/digests, and created the two-model `auto` node configuration. The private signing key remained ignored and uncommitted. The focused publication suite passes 32 tests, the catalog/bootstrap/model/desktop superset passes 92, and the run spent USD 0. | Preserve the branch-scoped mirror until a newly signed catalog sequence and packaged bootstrap migrate it. The Gate 11 acceptance and Gate 9 envelopes exist; [Gate 13 run `gate13-20260831-a`](evidence/gate13-20260831-a-cost-authorization.json) now authorizes the bounded replacement route and fresh packaged clients under the new epoch. Independent threshold holders and interchangeable mirror/seed governance are post-alpha. | -| 13 | Pass packaged clean-install inference on Windows and Linux | IN PROGRESS | [Prerequisite run `gate13-20260830-a-prerequisites`](evidence/gate13-20260830-a-prerequisites.json) established deterministic install archives, exact first-use bytes, strict provenance, and the canonical lifecycle contract. [Native-harness and production-package run `gate13-20260830-b`](evidence/gate13-20260830-b-native-harness-and-packages.json) now completes the native Windows Credential Manager/Job Object and Linux Secret Service/systemd-cgroup 16-phase adapters, exact worker and descendant cleanup proofs, 3,600-second acquisition bounds, and package/runtime/catalog cross-binding. Independent software review passed 134 focused tests plus a 113-pass broader matrix with 3 platform skips; the production-discovery correction passes 73 unittests, 4 pytest checks, self-test, formatting, and import checks. [Exact-source production run 33338872342](https://github.com/flujo-app/CommunityAI/actions/runs/33338872342) passed both jobs at source `1971f10` and published independently audited CUDA 12.4 archives: Windows `sha256:45e9cdb439bcb8a6d7ed67914a490f3bc2e12ea1981af944034d62d865f5adc6` (2,695,065,068 bytes) and Linux `sha256:f96d3ca651964380d4684855ab08682e8187b33386327ec3895cda25b43c2a00` (3,360,717,934 bytes). Pushed source `6787272` adds the fixed stdin-only artifact downloader and exact platform configs; its 42-test adversarial suite and independent race/special-member/live-wrapper audit pass. No cloud resource was created for these prerequisites. Real completed clean-host lifecycle evidence remains absent. Provider cleanup and the temporary Gate 11 restoration are proved, but the corrected backstop has since removed that route. | [Run `gate13-20260830-c` revision 13](evidence/gate13-20260830-c-cost-authorization.json) is stopped clean. The latest Windows host passed exact package audit, clean install, four desktop self-tests, and the packaged-node self-test, then failed before model acquisition because child stderr diagnostics contaminated strict JSON captured on stdout. [Attempt, cleanup, correction, and route-restoration evidence](evidence/gate13-20260830-c-windows-attempt-and-route-restore.json) proves zero cache bytes, no retained credential or product process, all four exact client instances/disks absent, the bootstrap running, and the temporary restored Qwen/Gemma product route. [Post-backstop cleanup evidence](evidence/gate11route-20260830-j-backstop-cleanup.json) now proves that route, its named disk, and both exact run-scoped firewall rules absent while every Gate 13 target remains absent and the protected bootstrap remains running. Pushed source `4818da3` separates captured stdout from a dedicated NUL stderr sink and passes 15 native tests plus independent high-volume, handle-leak, descendant, timeout, and Job Object probes, but it has not completed a paid clean-host lifecycle. The cleanup-backed 2026-08-31 owner reset releases the USD 98 historical maxima and opens a new USD 100 epoch for the next run; it does not authorize any particular resource or reuse the stopped record. A later native-auth recheck on 2026-08-31 obtained a real access token and passed Compute API access without exposing account or token material. [Run `gate13-20260831-a`](evidence/gate13-20260831-a-cost-authorization.json) records the fresh source-bound USD 52 reservation, exact route/client targets, current availability preflight, and cleanup boundary. Create only those bounded resources, complete both 16-phase fresh-host lifecycles, and prove exact cleanup before marking Gate 13 passed. | +| 13 | Pass packaged clean-install inference on Windows and Linux | IN PROGRESS | [Prerequisite run `gate13-20260830-a-prerequisites`](evidence/gate13-20260830-a-prerequisites.json) established deterministic install archives, exact first-use bytes, strict provenance, and the canonical lifecycle contract. [Native-harness and production-package run `gate13-20260830-b`](evidence/gate13-20260830-b-native-harness-and-packages.json) now completes the native Windows Credential Manager/Job Object and Linux Secret Service/systemd-cgroup 16-phase adapters, exact worker and descendant cleanup proofs, 3,600-second acquisition bounds, and package/runtime/catalog cross-binding. Independent software review passed 134 focused tests plus a 113-pass broader matrix with 3 platform skips; the production-discovery correction passes 73 unittests, 4 pytest checks, self-test, formatting, and import checks. [Exact-source production run 33338872342](https://github.com/flujo-app/CommunityAI/actions/runs/33338872342) passed both jobs at source `1971f10` and published independently audited CUDA 12.4 archives: Windows `sha256:45e9cdb439bcb8a6d7ed67914a490f3bc2e12ea1981af944034d62d865f5adc6` (2,695,065,068 bytes) and Linux `sha256:f96d3ca651964380d4684855ab08682e8187b33386327ec3895cda25b43c2a00` (3,360,717,934 bytes). Pushed source `6787272` adds the fixed stdin-only artifact downloader and exact platform configs; its 42-test adversarial suite and independent race/special-member/live-wrapper audit pass. No cloud resource was created for these prerequisites. Real completed clean-host lifecycle evidence remains absent. [Run `gate13-20260831-a`](evidence/gate13-20260831-a-failed-attempt-and-cleanup.json) later verified both exact archives on fresh clients but produced neither canonical lifecycle record. Its state-free transient orchestration advanced both clients before route acceptance, and subsequent diagnostic/product launches consumed both hosts for fresh-host acceptance. Exact cleanup proves all three instances/disks and both firewalls absent while the protected bootstrap remains running. Source `ddfb7c6` adds a persisted authorization-bound state contract that enforces inventory-first reattachment, route acceptance, sequential Windows-then-Linux evidence, consumed-host failure semantics, deadline runway, and success only after both canonical records plus final absence. Its 14 tests and the 186-test Gate 13 regression pass. | [Run `gate13-20260830-c` revision 13](evidence/gate13-20260830-c-cost-authorization.json) is stopped clean. The latest Windows host passed exact package audit, clean install, four desktop self-tests, and the packaged-node self-test, then failed before model acquisition because child stderr diagnostics contaminated strict JSON captured on stdout. [Attempt, cleanup, correction, and route-restoration evidence](evidence/gate13-20260830-c-windows-attempt-and-route-restore.json) proves zero cache bytes, no retained credential or product process, all four exact client instances/disks absent, the bootstrap running, and the temporary restored Qwen/Gemma product route. [Post-backstop cleanup evidence](evidence/gate11route-20260830-j-backstop-cleanup.json) now proves that route, its named disk, and both exact run-scoped firewall rules absent while every Gate 13 target remains absent and the protected bootstrap remains running. Pushed source `4818da3` separates captured stdout from a dedicated NUL stderr sink and passes 15 native tests plus independent high-volume, handle-leak, descendant, timeout, and Job Object probes, but it has not completed a paid clean-host lifecycle. The cleanup-backed 2026-08-31 owner reset releases the USD 98 historical maxima and opens a new USD 100 epoch for the next run; it does not authorize any particular resource or reuse the stopped record. A later native-auth recheck on 2026-08-31 obtained a real access token and passed Compute API access without exposing account or token material. [Run `gate13-20260831-a`](evidence/gate13-20260831-a-failed-attempt-and-cleanup.json) is failed and cleanup-proved; its USD 52 maximum remains committed, leaving USD 48 in the current epoch. Do not reuse its clients or authorization. Complete the fixed durable Windows Scheduled Task and Linux systemd host-job adapter around the persisted state contract, then bind their exact source and a cheaper plan that fits USD 48—or obtain an explicit cleanup-backed owner reset—before a fresh create. The next run must accept the complete route first, run/collect/delete Windows Qwen, then run/collect/delete Linux Gemma, and finally prove exact route cleanup. | | 14 | Pass automatic-contribution and resource-control hardware checks | WAITING | [PR #11](https://github.com/flujo-app/CommunityAI/pull/11) and [PR #12](https://github.com/flujo-app/CommunityAI/pull/12) implemented the authenticated node-authoritative Sharing UI and atomic policy editing, but cross-model automatic placement and real packaged hardware evidence are absent. | After Gates 9–13, follow the [recovery runbook](RECOVERY_TEST_RUNBOOK.md) once for the clean-install product flow while validating model/block choice, exact selected-shard bytes, shared-cache affinity, download authorization, VRAM/storage/bandwidth/power limits, suspension, pause timing, cleanup, restart persistence, and unsupported telemetry on real packaged Windows/Linux hardware. | | 15 | Complete minimal alpha release engineering | WAITING | The desktop builder now emits a stable sorted `SHA256SUMS` inventory of exact regular-file bytes and safe relative in-bundle file symlinks, source/build/catalog-bound `provenance.json`, and `release-metadata.json` with explicit unsigned public-alpha, no-publisher-signature, no-authenticated-update, Windows/Linux-only, no-credits, and incomplete-qualification claims. Structural verification binds each safe file symlink to its canonical in-bundle target, digest, and size while rejecting changed, missing, extra, absolute, external, broken, cyclic, directory-linked/junction, special, traversal, or case-colliding payloads plus unsupported or noncanonical metadata. Exact-source builds also reject dirty relevant inputs, and the expected-input fresh-process check rejects rewritten commit/tree, workflow, platform, Python, PyInstaller, or catalog evidence. Production desktop CI is configured to verify and bundle the Gate 12 inputs, bind the exact clean Git commit/tree and workflow, revalidate every expected input separately, and upload all evidence on Windows/Linux. The focused release-input/artifact suite passes 15 tests, including fresh-process CLI, dirty-source, and canonical-rewrite checks, and the broader catalog/bootstrap/model/desktop subset passes 134. Independent verification reproduced all 134, passed 58 desktop unittests with two environment skips, formatting/import-order/YAML/diff checks, an expected Gate 12/workflow fresh-process probe, and real Windows junction rejection; no cloud was used. [The first PR #22 production-desktop run](https://github.com/flujo-app/CommunityAI/actions/runs/33273518744) reached packaging on both hosts and exposed two exact cross-platform defects: PyInstaller's legitimate relative internal Qt file symlink on Ubuntu and CRLF-transformed signed Gate 12 JSON on Windows. The follow-up binds safe internal file symlinks without accepting external or directory links, forces `public-alpha/**` to LF at checkout, and includes `.gitattributes` in the clean-source boundary. [The second run](https://github.com/flujo-app/CommunityAI/actions/runs/33274432423) proved the Ubuntu package and the Windows signed-bundle/provenance path, then exposed a stale desktop contribution-status schema 2 contract when the packaged node emitted schema 3 automatic-placement evidence. Source `fcd1f41` now strictly validates schema 3 placement and rejects stale schema 2 plus missing, extra, secret-bearing, or inconsistent placement data; its 50-test node/client/lifecycle/build focus and all 59 desktop unittests passed with two environment skips. [The final run](https://github.com/flujo-app/CommunityAI/actions/runs/33275216332) bound exact source `fcd1f417d1435557addb2d6cded9dac0827c7d8c` and completed both Windows and Ubuntu package jobs, including bundle build/smoke, independent checksum/provenance verification, the Windows packaged-node/native-credential/public-seed smoke, and artifact uploads; every PR style, test, and package check is green. Source `36d85d2` makes generic release-artifact fixtures select the supported Linux archive explicitly instead of inheriting the CI host platform; the 21-test local artifact suite and [PR #22 test run 33372581439](https://github.com/flujo-app/CommunityAI/actions/runs/33372581439) pass, without expanding the supported platform matrix. Clean-install lifecycle evidence remains absent. | Retain the verified Windows/Linux artifacts as engineering evidence, then test clean install, manual upgrade/reinstall, uninstall, retained-data choice for the persistent verified model cache, and recovery instructions on both platforms against a newly authorized live product-node route and the published Gate 9 envelopes. Do not mark passed from metadata/unit tests alone. Publisher signing and automatic authenticated update/rollback are post-alpha. | | 16 | Complete the bounded public-alpha safety canary | WAITING | [PR #13](https://github.com/flujo-app/CommunityAI/pull/13) and [PR #14](https://github.com/flujo-app/CommunityAI/pull/14) implemented bounded admission, privacy-safe aggregate health, training-off defaults, rollback procedures, and bounded routine rejection logs; no public canary has run. | After Gates 11–15, run a small monitored canary proving finite admission/timeouts, malformed-peer rejection, health reconstruction, privacy disclosure, route/catalog disable, and clean rollback. Exhaustive hostile-load, Sybil/collusion, partition, and long-soak campaigns are post-alpha. | @@ -117,11 +120,12 @@ the estimate with observed cost when available. If provider billing is delayed, maximum estimate until actual cost is known unless the owner explicitly resets the budget after complete cleanup. On reset, keep historical rows, mark them `CLEANED-RELEASED`, and continue recording later observed charges for information; released rows do not consume the -new epoch. +new epoch. `CLEANED-COMMITTED` means resources are absence-proved but the conservative +maximum still consumes the current epoch because no reset or observed charge has replaced it. | Run | Provider | Purpose | Maximum estimate | Observed cost | Cleanup proof | State | | --- | --- | --- | ---: | ---: | --- | --- | -| gate13-20260831-a | GCP | Gate 13 replacement product-node route plus fresh CPU Windows/Linux packaged lifecycles at route source `f64a388a47b098ac7f69d2affc59816376b43bb1` and exact package source `1971f106cc5bf90724d938c986a719ce2744f3e7` [plan sha256:313f5d34eefd64c71e265bdb7044d8ef5f56550360a7e9a7104265434292fd69] | USD 52.00 | — | [Cost authorization](evidence/gate13-20260831-a-cost-authorization.json) binds the one-L4 bounded route, fresh CPU client targets, exact packages/helpers/manifests, native-auth and provider preflight, automatic deletion deadlines, exact cleanup targets, protected bootstrap, and zero Fly/image/mirror/credits/macOS work. | RESERVED | +| gate13-20260831-a | GCP | Gate 13 replacement product-node route plus fresh CPU Windows/Linux packaged lifecycles at route source `f64a388a47b098ac7f69d2affc59816376b43bb1` and exact package source `1971f106cc5bf90724d938c986a719ce2744f3e7` [plan sha256:313f5d34eefd64c71e265bdb7044d8ef5f56550360a7e9a7104265434292fd69] | USD 52.00 | — | [Failed attempt and cleanup proof](evidence/gate13-20260831-a-failed-attempt-and-cleanup.json) records verified archive downloads but no completed lifecycle, the non-durable orchestration failure, consumed-client semantics, and exact absence of the route, both clients, all three disks, and both firewalls while the protected bootstrap remains running. The USD 52 maximum remains committed until an explicit cleanup-backed owner reset; USD 48 remains in this epoch. | CLEANED-COMMITTED | | gate13-20260830-c | GCP | Gate 13 sequential clean packaged Qwen Windows and Gemma Linux lifecycles at exact package source `1971f106cc5bf90724d938c986a719ce2744f3e7`, temporarily suspending and later restoring the Gate 11 route while reusing its sole global L4 allocation on uniquely named fresh Windows and Linux clients [plan sha256:427bc1ed8a6645ad0650d91aaba7aa753d398fa84f56d57b50aca04c4e0cc955] | USD 26.00 | — | [Cost authorization](evidence/gate13-20260830-c-cost-authorization.json) binds the passed production archives/audits, pushed download-helper/config identities, exact Actions wrapper/inner archives, exact Qwen/Gemma manifests, no service accounts/scopes, direct model transfer, native credential stores, whole-tree containment, all 16 phases, exact cleanup targets, and zero Fly/image/mirror/credits/macOS work. Revision 13 records the final Windows pre-acquisition failure, pushed correction `4818da3`, complete native cleanup, all four exact client instance/disk absences, and successful Gate 11 route restoration. [Privacy-safe final state](evidence/gate13-20260830-c-windows-attempt-and-route-restore.json) proves the package audit and install boundary, zero model-cache bytes, no retained credential/process/path/endpoint/provider output, protected-bootstrap health, active Qwen/Gemma route services, and fresh primary/fallback/restoration inference. The two required 16-phase lifecycles remain incomplete. Complete cleanup permits the explicit owner reset on 2026-08-31; its USD 26 maximum is historical and delayed billing remains informational. This record authorizes no later provisioning. | CLEANED-RELEASED | | gate9-20260830-e | GCP | Gate 9 concurrent Qwen/Gemma Windows/Linux acquisition records and schema-v3 envelopes at pushed source `ba410f74f1cf625f1e1c34734b53e4514fa7c5ec`, reusing the separately authorized product route and using bounded isolated clients [plan sha256:04ba77ee68f4a895ae080a4ddcbf6805b502da6a95a4146734acbddff92de307] | USD 46.00 | — | [Passed envelopes and cleanup](evidence/gate9-20260830-e-edge-resource-envelopes.json) publish all four exact acquisition/envelope records and prove complete client cleanup; [cost authorization](evidence/gate9-20260830-e-cost-authorization.json) binds the exact wheel or exact-commit source archive, signed catalog/bootstrap, Qwen/Gemma manifests, owner-authorized parallel platform/model execution, 60-minute model windows, 90-minute client deletion backstops, exact cleanup targets, protected resources, and zero Fly/image/mirror operations. Native provider authentication was refreshed before the USD 18 Windows-client expansion and again before the zero-ceiling-increase Gemma memory retry; the exact plan permits one cache-preserving in-place resize to `e2-standard-8`. Complete cleanup permits the explicit owner reset on 2026-08-31; its USD 46 maximum is historical and delayed billing remains informational. | CLEANED-RELEASED | | route-20260830-j | GCP | Gate 11 signed-catalog product node route [workload gcp-product-node-route] [source e1d715fd47c852fa12ca50c76e8f4c6a0831fd78] [final runtime source 4cef141746705c3ee8bc8e017693855e0bc4871e] [plan sha256:1a0927e9d83a9a409ac2ea0232c4fceb14821d3f2c5eb87def88b8e7cdcb07d8] | USD 26.00 | — | [Passed live lifecycle](evidence/gate11node-20260830-a-lifecycle.json): generic runtime, signed catalog, direct Hugging Face artifacts, shared persistent cache, complete primary/standby routes, primary/fallback/restoration inference, stable workers, no model image, and protected-bootstrap health. [Gate 13 restoration evidence](evidence/gate13-20260830-c-windows-attempt-and-route-restore.json) proves the route was restored, both product services became active, fresh Qwen/Gemma primary/fallback/restoration inference passed, and a corrected 4,800-second DELETE backstop ended no later than the original deadline. [Post-backstop cleanup](evidence/gate11route-20260830-j-backstop-cleanup.json) proves the route instance, named disk, and both exact run-scoped firewall rules absent, all Gate 13 clients/disks absent, zero GPU use, and the protected bootstrap running; acceptance evidence is preserved but no product route is live. Complete cleanup permits the explicit owner reset on 2026-08-31; its USD 26 maximum is historical and delayed billing remains informational. | CLEANED-RELEASED | diff --git a/docs/REVIVAL.md b/docs/REVIVAL.md index a0ccf347d..d84d9dc1c 100644 --- a/docs/REVIVAL.md +++ b/docs/REVIVAL.md @@ -200,6 +200,30 @@ On every implementation run: ends, the release is complete, or that narrow definition applies to every permitted task on the current critical path. +### Durable paid-run contract + +A multi-hour paid qualification must not depend on an operator terminal, SSH/IAP session, +or untracked repair script remaining alive. Before its first create, it must have one +source-bound, persisted, idempotent controller with `start`, `status`, `collect`, and +`cleanup` operations. Every operation begins by inventorying the exact authorized +instances, disks, firewalls, ownership metadata, and absolute deadlines. Matching resources +are reattached; foreign or ambiguous exact-name resources fail closed; missing resources are +never recreated merely because local state was lost. + +Long-running work runs as one named host-local durable service or task and writes only a +bounded sanitized status plus a digest-bound terminal record. Repeating `start` observes the +existing job; it does not launch a second lifecycle. Once a packaged product lifecycle or a +diagnostic product launch begins, any non-pass consumes that client for acceptance. Removing +its files or credentials does not make it fresh again, and phase-level lifecycle resumption +is prohibited. + +For Gate 13, accept the complete product route before creating a client. Run the higher-risk +Windows/Qwen lifecycle first; collect its canonical 16-phase record and delete that client +before creating Linux/Gemma. This is an operational cost/risk sequence, not a relaxation of +the two-platform acceptance contract. Any route failure, ambiguous host job, expired runway, +or client failure goes directly to exact cleanup. A gate passes only after both complete +fresh-host records and final provider absence proof exist. + ### Cloud safety rules - Before provisioning, record a conservative maximum estimate in the spend ledger and diff --git a/docs/evidence/gate13-20260831-a-failed-attempt-and-cleanup.json b/docs/evidence/gate13-20260831-a-failed-attempt-and-cleanup.json new file mode 100644 index 000000000..17d86b07f --- /dev/null +++ b/docs/evidence/gate13-20260831-a-failed-attempt-and-cleanup.json @@ -0,0 +1,88 @@ +{ + "schema_version": 1, + "scope": "gate13-packaged-lifecycle-attempt", + "run_id": "gate13-20260831-a", + "gate": 13, + "result": "failed", + "recorded_at": "2026-08-31T18:03:21Z", + "authorization": { + "provider_plan_digest": "sha256:313f5d34eefd64c71e265bdb7044d8ef5f56550360a7e9a7104265434292fd69", + "maximum_estimate_usd": "52.00", + "route_source_commit": "f64a388a47b098ac7f69d2affc59816376b43bb1", + "package_source_commit": "1971f106cc5bf90724d938c986a719ce2744f3e7", + "windows_helper_commit": "4818da304f4eeafc81978873bcdfa8a41f6cad36", + "linux_helper_commit": "c3dc9234af7980bcaffd481c6f8e4e974ed117d4" + }, + "attempt": { + "route_created": true, + "windows_client_created": true, + "linux_client_created": true, + "durable_run_controller_present": false, + "state_aware_reattachment_available": false, + "windows": { + "package_download_verified": true, + "package_sha256": "45e9cdb439bcb8a6d7ed67914a490f3bc2e12ea1981af944034d62d865f5adc6", + "package_bytes": 2695065068, + "clean_preflight_passed": true, + "complete_lifecycle_record_present": false + }, + "linux": { + "package_download_verified": true, + "package_sha256": "f96d3ca651964380d4684855ab08682e8187b33386327ec3895cda25b43c2a00", + "package_bytes": 3360717934, + "initial_archive_preflight_passed": false, + "retry_exit_code": 2, + "bounded_diagnostic_failed": true, + "complete_lifecycle_record_present": false + }, + "windows_16_phase_lifecycle_complete": false, + "linux_16_phase_lifecycle_complete": false, + "acceptance_passed": false + }, + "failure": { + "class": "non_durable_orchestration", + "summary": "The paid qualification depended on transient operator sessions and one-off repair scripts, had no authoritative persisted state for crash reattachment, and advanced both clients before a single bootstrap and route path had passed.", + "fresh_host_evidence_reusable": false, + "gate_status": "IN PROGRESS", + "later_gates_unblocked": false + }, + "required_correction": { + "single_idempotent_controller_actions": [ + "reconcile", + "start", + "status", + "collect", + "cleanup" + ], + "reconcile_before_mutation": true, + "host_jobs_survive_operator_disconnect": true, + "route_and_transport_probe_before_full_clients": true, + "windows_lifecycle_collected_and_client_deleted_before_linux_create": true, + "failed_or_diagnostically_modified_client_reused_for_acceptance": false, + "all_16_phases_still_required": true, + "controller_state_contract_commit": "ddfb7c617b428a97b33d2b28e42f4fb75f3509ce", + "controller_contract_tests_passed": 15, + "gate13_regression_tests_passed": 187 + }, + "cleanup": { + "performed_at": "2026-08-31T18:03:21Z", + "route_instance_absent": true, + "route_boot_disk_absent": true, + "route_dht_firewall_absent": true, + "route_iap_firewall_absent": true, + "windows_instance_absent": true, + "windows_boot_disk_absent": true, + "linux_instance_absent": true, + "linux_boot_disk_absent": true, + "all_exact_run_resources_absent": true, + "protected_bootstrap_running": true + }, + "privacy": { + "credentials_retained": false, + "prompts_retained": false, + "outputs_retained": false, + "endpoints_retained": false, + "provider_output_retained": false, + "private_paths_retained": false + } +} From 2fe0de9b3fa95d6e14ebfaf3ce7edf513ca6d7da Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:15:24 -0500 Subject: [PATCH 06/56] gate13: add durable native host jobs --- scripts/gate13_host_job.py | 1138 +++++++++++++++++ scripts/gate13_linux_packaged_lifecycle.py | 42 +- scripts/gate13_run_controller.py | 113 +- scripts/gate13_windows_packaged_lifecycle.ps1 | 28 +- tests/test_gate13_host_job.py | 507 ++++++++ tests/test_gate13_linux_packaged_lifecycle.py | 5 + tests/test_gate13_run_controller.py | 171 ++- .../test_gate13_windows_packaged_lifecycle.py | 16 + 8 files changed, 1984 insertions(+), 36 deletions(-) create mode 100644 scripts/gate13_host_job.py create mode 100644 tests/test_gate13_host_job.py diff --git a/scripts/gate13_host_job.py b/scripts/gate13_host_job.py new file mode 100644 index 000000000..72873820e --- /dev/null +++ b/scripts/gate13_host_job.py @@ -0,0 +1,1138 @@ +"""Durable native host-job adapter for Gate 13 packaged lifecycle runs. + +A paid client attempt is launched exactly once under a native supervisor. The adapter +persists bounded status before starting the lifecycle, validates the canonical evidence, +and writes a digest-only terminal record. Re-entry never relaunches an attempt. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import signal +import stat +import subprocess +import sys +import tempfile +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Mapping, Sequence + +import gate13_packaged_lifecycle as lifecycle + +SCHEMA_VERSION = 1 +MAX_CONFIG_BYTES = 65_536 +MAX_STATE_BYTES = 262_144 +MAX_EVIDENCE_BYTES = lifecycle.MAX_INPUT_BYTES +MAX_STDERR_BYTES = 262_144 +MAX_SCRIPT_BYTES = 8 * 1024 * 1024 +MIN_RUN_SECONDS = 300 +MAX_RUN_SECONDS = 21_600 +SUPERVISOR_GRACE_SECONDS = 60 +READ_CHUNK_BYTES = 65_536 +POSIX_SIGTERM = getattr(signal, "SIGTERM", 15) +POSIX_SIGKILL = getattr(signal, "SIGKILL", 9) + +HOST_ROOTS = { + "windows": Path(r"C:\Gate13Run"), + "linux": Path("/qualification"), +} +HOST_PYTHON = { + "windows": Path(r"C:\Gate13Python\python.exe"), + "linux": Path("/usr/bin/python3"), +} +ADAPTER_PATH = Path(__file__).resolve() + +_RUN_RE = re.compile(r"[a-z0-9][a-z0-9-]{0,62}") +_USER_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,63}") +_COMMIT_RE = re.compile(r"[0-9a-f]{40}") +_DIGEST_RE = re.compile(r"(?:sha256:)?[0-9a-f]{64}") +_JOB_RE = re.compile(r"communityai-gate13-[a-z0-9-]{1,63}-(?:windows|linux)") +_CONFIG_FIELDS = { + "schema_version", + "run_id", + "lifecycle_run_id", + "platform", + "attempt_ordinal", + "source_commit", + "job_name", + "host_user", + "adapter_path", + "adapter_sha256", + "config_path", + "entrypoint_path", + "entrypoint_sha256", + "lifecycle_config_path", + "lifecycle_config_sha256", + "evidence_path", + "stderr_path", + "status_path", + "terminal_path", + "working_directory", + "python_executable", + "max_run_seconds", +} +_STATUS_FIELDS = { + "schema_version", + "run_id", + "platform", + "attempt_ordinal", + "state", + "started_at_unix", +} +_TERMINAL_FIELDS = { + "schema_version", + "run_id", + "platform", + "attempt_ordinal", + "result", + "failure_code", + "evidence_digest", + "exit_code", + "finished_at_unix", +} +_NATIVE_FIELDS = {"native_state", "binding_ok"} + + +class HostJobError(ValueError): + """The host job config, state, or native supervisor failed closed.""" + + +@dataclass(frozen=True) +class HostJobConfig: + run_id: str + lifecycle_run_id: str + platform: str + attempt_ordinal: int + source_commit: str + job_name: str + host_user: str + adapter_path: Path + adapter_sha256: str + config_path: Path + entrypoint_path: Path + entrypoint_sha256: str + lifecycle_config_path: Path + lifecycle_config_sha256: str + evidence_path: Path + stderr_path: Path + status_path: Path + terminal_path: Path + working_directory: Path + python_executable: Path + max_run_seconds: int + + +Runner = Callable[..., subprocess.CompletedProcess[str]] + + +def _reject_constant(_value: str) -> None: + raise HostJobError("invalid JSON") + + +def _unique_object(pairs: Sequence[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise HostJobError("duplicate JSON field") + result[key] = value + return result + + +def _regular_bytes(path: Path, maximum: int, *, allow_empty: bool = False) -> bytes: + path = Path(path) + try: + metadata = path.lstat() + except OSError as exc: + raise HostJobError("required file is unavailable") from exc + reparse = bool(getattr(metadata, "st_file_attributes", 0) & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)) + minimum = 0 if allow_empty else 1 + if reparse or path.is_symlink() or not stat.S_ISREG(metadata.st_mode) or not minimum <= metadata.st_size <= maximum: + raise HostJobError("required file is unsafe") + try: + return path.read_bytes() + except OSError as exc: + raise HostJobError("required file is unreadable") from exc + + +def _strict_json(payload: bytes, maximum: int) -> Mapping[str, Any]: + if not isinstance(payload, bytes) or not 1 <= len(payload) <= maximum: + raise HostJobError("JSON size is invalid") + try: + value = json.loads( + payload.decode("utf-8"), + object_pairs_hook=_unique_object, + parse_constant=_reject_constant, + ) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise HostJobError("invalid JSON") from exc + if not isinstance(value, dict): + raise HostJobError("JSON root is invalid") + return value + + +def _exact_mapping(value: Mapping[str, Any], fields: set[str], label: str) -> Mapping[str, Any]: + if not isinstance(value, dict) or set(value) != fields: + raise HostJobError(f"{label} schema is invalid") + return value + + +def _string(value: Any, pattern: re.Pattern[str], label: str) -> str: + if not isinstance(value, str) or pattern.fullmatch(value) is None: + raise HostJobError(f"{label} is invalid") + return value + + +def _integer(value: Any, label: str, minimum: int, maximum: int) -> int: + if type(value) is not int or not minimum <= value <= maximum: + raise HostJobError(f"{label} is invalid") + return value + + +def _digest_bytes(payload: bytes) -> str: + return "sha256:" + hashlib.sha256(payload).hexdigest() + + +def _digest_file(path: Path) -> str: + return _digest_bytes(_regular_bytes(path, MAX_SCRIPT_BYTES)) + + +def _normalized_path(value: Any, label: str) -> Path: + if not isinstance(value, str) or not value or "\x00" in value: + raise HostJobError(f"{label} is invalid") + path = Path(value) + if not path.is_absolute(): + raise HostJobError(f"{label} is not absolute") + return Path(os.path.abspath(os.fspath(path))) + + +def _same_path(left: Path, right: Path) -> bool: + return os.path.normcase(os.path.abspath(os.fspath(left))) == os.path.normcase(os.path.abspath(os.fspath(right))) + + +def _inside(path: Path, root: Path) -> bool: + try: + return os.path.commonpath( + [os.path.normcase(os.path.abspath(os.fspath(path))), os.path.normcase(os.path.abspath(os.fspath(root)))] + ) == os.path.normcase(os.path.abspath(os.fspath(root))) + except ValueError: + return False + + +def _safe_existing_output(path: Path) -> None: + if not path.exists(): + return + metadata = path.lstat() + reparse = bool(getattr(metadata, "st_file_attributes", 0) & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)) + if reparse or path.is_symlink() or not stat.S_ISREG(metadata.st_mode): + raise HostJobError("output path is unsafe") + + +def load_config(path: Path) -> HostJobConfig: + config_path = Path(os.path.abspath(os.fspath(path))) + raw = _exact_mapping( + _strict_json(_regular_bytes(config_path, MAX_CONFIG_BYTES), MAX_CONFIG_BYTES), + _CONFIG_FIELDS, + "config", + ) + if raw["schema_version"] != SCHEMA_VERSION: + raise HostJobError("config version is invalid") + platform = raw["platform"] + if platform not in HOST_ROOTS: + raise HostJobError("platform is invalid") + run_id = _string(raw["run_id"], _RUN_RE, "run id") + lifecycle_run_id = raw["lifecycle_run_id"] + if lifecycle_run_id != f"{run_id}-{platform}": + raise HostJobError("lifecycle run id is invalid") + attempt = _integer(raw["attempt_ordinal"], "attempt ordinal", 1, 1) + source_commit = _string(raw["source_commit"], _COMMIT_RE, "source commit") + job_name = _string(raw["job_name"], _JOB_RE, "job name") + if job_name != f"communityai-gate13-{run_id}-{platform}": + raise HostJobError("job name is not source-bound") + host_user = _string(raw["host_user"], _USER_RE, "host user") + if (platform == "linux" and host_user != "gate13") or host_user.casefold() in { + "system", + "local service", + "network service", + "administrator", + "root", + }: + raise HostJobError("host user is not an ordinary qualification user") + + values = { + field: _normalized_path(raw[field], field) + for field in ( + "adapter_path", + "config_path", + "entrypoint_path", + "lifecycle_config_path", + "evidence_path", + "stderr_path", + "status_path", + "terminal_path", + "working_directory", + "python_executable", + ) + } + root = Path(os.path.abspath(os.fspath(HOST_ROOTS[platform]))) + if not _same_path(values["working_directory"], root): + raise HostJobError("working directory changed") + for field in ( + "adapter_path", + "config_path", + "entrypoint_path", + "lifecycle_config_path", + "evidence_path", + "stderr_path", + "status_path", + "terminal_path", + ): + if not _inside(values[field], root): + raise HostJobError(f"{field} escapes the host root") + if not _same_path(config_path, values["config_path"]): + raise HostJobError("config path binding changed") + expected_lifecycle_name = "gate13-windows-run.json" if platform == "windows" else "gate13-linux-run.json" + if values["lifecycle_config_path"].name != expected_lifecycle_name: + raise HostJobError("lifecycle config path changed") + if not _same_path(values["python_executable"], HOST_PYTHON[platform]): + raise HostJobError("Python executable changed") + if not _same_path(values["adapter_path"], ADAPTER_PATH): + raise HostJobError("adapter invocation changed") + root_metadata = root.lstat() + root_reparse = bool( + getattr(root_metadata, "st_file_attributes", 0) & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ) + if not root.is_dir() or root.is_symlink() or root_reparse: + raise HostJobError("host root is unsafe") + + outputs = [ + values["evidence_path"], + values["stderr_path"], + values["status_path"], + values["terminal_path"], + ] + bound_paths = [ + values["adapter_path"], + values["config_path"], + values["entrypoint_path"], + values["lifecycle_config_path"], + *outputs, + ] + if len({os.path.normcase(os.fspath(item)) for item in bound_paths}) != len(bound_paths): + raise HostJobError("bound paths overlap") + for output in outputs: + _safe_existing_output(output) + + adapter_sha = _string(raw["adapter_sha256"], _DIGEST_RE, "adapter digest") + entrypoint_sha = _string(raw["entrypoint_sha256"], _DIGEST_RE, "entrypoint digest") + lifecycle_config_sha = _string( + raw["lifecycle_config_sha256"], + _DIGEST_RE, + "lifecycle config digest", + ) + if _digest_file(values["adapter_path"]) != "sha256:" + adapter_sha.removeprefix("sha256:"): + raise HostJobError("adapter digest changed") + if _digest_file(values["entrypoint_path"]) != "sha256:" + entrypoint_sha.removeprefix("sha256:"): + raise HostJobError("entrypoint digest changed") + if _digest_file(values["lifecycle_config_path"]) != "sha256:" + lifecycle_config_sha.removeprefix("sha256:"): + raise HostJobError("lifecycle config digest changed") + + return HostJobConfig( + run_id=run_id, + lifecycle_run_id=lifecycle_run_id, + platform=platform, + attempt_ordinal=attempt, + source_commit=source_commit, + job_name=job_name, + host_user=host_user, + adapter_path=values["adapter_path"], + adapter_sha256="sha256:" + adapter_sha.removeprefix("sha256:"), + config_path=values["config_path"], + entrypoint_path=values["entrypoint_path"], + entrypoint_sha256="sha256:" + entrypoint_sha.removeprefix("sha256:"), + lifecycle_config_path=values["lifecycle_config_path"], + lifecycle_config_sha256="sha256:" + lifecycle_config_sha.removeprefix("sha256:"), + evidence_path=values["evidence_path"], + stderr_path=values["stderr_path"], + status_path=values["status_path"], + terminal_path=values["terminal_path"], + working_directory=values["working_directory"], + python_executable=values["python_executable"], + max_run_seconds=_integer( + raw["max_run_seconds"], + "maximum run seconds", + MIN_RUN_SECONDS, + MAX_RUN_SECONDS, + ), + ) + + +def _atomic_json(path: Path, value: Mapping[str, Any], *, exclusive: bool = False) -> None: + payload = (json.dumps(value, allow_nan=False, separators=(",", ":"), sort_keys=True) + "\n").encode("utf-8") + if not 1 <= len(payload) <= MAX_STATE_BYTES: + raise HostJobError("state is too large") + if exclusive: + try: + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + except FileExistsError as exc: + raise HostJobError("state already exists") from exc + with os.fdopen(descriptor, "wb") as stream: + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + return + + descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + temporary = Path(temporary_name) + try: + try: + os.chmod(temporary, 0o600) + except OSError: + pass + with os.fdopen(descriptor, "wb") as stream: + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def _load_status(config: HostJobConfig) -> Mapping[str, Any] | None: + if not config.status_path.exists(): + return None + raw = _exact_mapping( + _strict_json( + _regular_bytes(config.status_path, MAX_STATE_BYTES), + MAX_STATE_BYTES, + ), + _STATUS_FIELDS, + "status", + ) + if ( + raw["schema_version"] != SCHEMA_VERSION + or raw["run_id"] != config.run_id + or raw["platform"] != config.platform + or raw["attempt_ordinal"] != config.attempt_ordinal + or raw["state"] != "running" + or type(raw["started_at_unix"]) is not int + ): + raise HostJobError("status binding changed") + return raw + + +def _load_terminal(config: HostJobConfig) -> Mapping[str, Any] | None: + if not config.terminal_path.exists(): + return None + raw = _exact_mapping( + _strict_json( + _regular_bytes(config.terminal_path, MAX_STATE_BYTES), + MAX_STATE_BYTES, + ), + _TERMINAL_FIELDS, + "terminal", + ) + if ( + raw["schema_version"] != SCHEMA_VERSION + or raw["run_id"] != config.run_id + or raw["platform"] != config.platform + or raw["attempt_ordinal"] != config.attempt_ordinal + or raw["result"] not in {"passed", "failed"} + or (raw["failure_code"] is not None and not re.fullmatch(r"[a-z0-9_]{1,64}", str(raw["failure_code"]))) + or type(raw["exit_code"]) is not int + or type(raw["finished_at_unix"]) is not int + ): + raise HostJobError("terminal binding changed") + digest = raw["evidence_digest"] + if raw["result"] == "passed": + _string(digest, _DIGEST_RE, "terminal evidence digest") + if raw["failure_code"] is not None or raw["exit_code"] != 0: + raise HostJobError("terminal success is inconsistent") + elif digest is not None or raw["failure_code"] is None: + raise HostJobError("terminal failure is inconsistent") + return raw + + +def _terminal( + config: HostJobConfig, + *, + result: str, + failure_code: str | None, + evidence_digest: str | None, + exit_code: int, + finished_at_unix: int, +) -> dict[str, Any]: + return { + "schema_version": SCHEMA_VERSION, + "run_id": config.run_id, + "platform": config.platform, + "attempt_ordinal": config.attempt_ordinal, + "result": result, + "failure_code": failure_code, + "evidence_digest": evidence_digest, + "exit_code": exit_code, + "finished_at_unix": finished_at_unix, + } + + +def _entrypoint_argv(config: HostJobConfig) -> list[str]: + if config.platform == "windows": + return [ + r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + os.fspath(config.entrypoint_path), + ] + return [ + os.fspath(config.python_executable), + os.fspath(config.entrypoint_path), + "--config", + os.fspath(config.lifecycle_config_path), + ] + + +def _bounded_environment(config: HostJobConfig) -> dict[str, str]: + allowed = ( + ("SYSTEMROOT", "WINDIR", "TEMP", "TMP", "USERPROFILE") + if config.platform == "windows" + else ("HOME", "LANG", "LC_ALL", "TMPDIR") + ) + return {key: os.environ[key] for key in allowed if key in os.environ} + + +def _bounded_copy( + stream: Any, + destination: Path, + maximum: int, + overflow: threading.Event, + errors: list[BaseException], +) -> None: + total = 0 + try: + with destination.open("xb") as output: + while True: + chunk = stream.read(READ_CHUNK_BYTES) + if not chunk: + break + if not isinstance(chunk, bytes): + raise HostJobError("child output type is invalid") + remaining = max(0, maximum - total) + if remaining: + output.write(chunk[:remaining]) + total += len(chunk) + if total > maximum: + overflow.set() + output.flush() + os.fsync(output.fileno()) + except BaseException as exc: + errors.append(exc) + overflow.set() + finally: + try: + stream.close() + except BaseException: + pass + + +def _wait_for_exit(process: Any, timeout: float) -> bool: + try: + process.wait(timeout=timeout) + return True + except subprocess.TimeoutExpired: + return False + + +def _stop_process_tree(config: HostJobConfig, process: Any) -> None: + if config.platform == "windows": + try: + process.send_signal(signal.CTRL_BREAK_EVENT) + except (OSError, ValueError, AttributeError): + pass + if _wait_for_exit(process, SUPERVISOR_GRACE_SECONDS): + return + try: + subprocess.run( + [ + r"C:\Windows\System32\taskkill.exe", + "/PID", + str(process.pid), + "/T", + "/F", + ], + check=False, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=30, + shell=False, + ) + except (OSError, subprocess.TimeoutExpired): + pass + else: + try: + os.killpg(process.pid, POSIX_SIGTERM) + except (OSError, AttributeError): + try: + process.terminate() + except OSError: + pass + if _wait_for_exit(process, SUPERVISOR_GRACE_SECONDS): + return + try: + os.killpg(process.pid, POSIX_SIGKILL) + except (OSError, AttributeError): + try: + process.kill() + except OSError: + pass + if not _wait_for_exit(process, 30): + raise HostJobError("entrypoint process tree did not stop") + + +def _run_entrypoint(config: HostJobConfig) -> int: + if config.evidence_path.exists() or config.stderr_path.exists(): + raise HostJobError("attempt output already exists") + creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if config.platform == "windows" else 0 + process = subprocess.Popen( + _entrypoint_argv(config), + cwd=config.working_directory, + env=_bounded_environment(config), + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + shell=False, + bufsize=0, + start_new_session=config.platform == "linux", + creationflags=creationflags, + ) + if process.stdout is None or process.stderr is None: + _stop_process_tree(config, process) + raise HostJobError("entrypoint pipes are unavailable") + + overflow = threading.Event() + copy_errors: list[BaseException] = [] + threads = [ + threading.Thread( + target=_bounded_copy, + args=(process.stdout, config.evidence_path, MAX_EVIDENCE_BYTES, overflow, copy_errors), + daemon=True, + ), + threading.Thread( + target=_bounded_copy, + args=(process.stderr, config.stderr_path, MAX_STDERR_BYTES, overflow, copy_errors), + daemon=True, + ), + ] + for thread in threads: + thread.start() + + deadline = time.monotonic() + config.max_run_seconds + stop_code: int | None = None + while process.poll() is None: + if overflow.is_set(): + stop_code = 126 + break + if time.monotonic() >= deadline: + stop_code = 124 + break + time.sleep(0.05) + if stop_code is not None: + _stop_process_tree(config, process) + + for thread in threads: + thread.join(SUPERVISOR_GRACE_SECONDS) + if any(thread.is_alive() for thread in threads): + _stop_process_tree(config, process) + raise HostJobError("entrypoint output streams did not close") + if copy_errors: + raise HostJobError("entrypoint output could not be bounded") + if stop_code is not None: + return stop_code + if overflow.is_set(): + return 126 + return int(process.returncode) + + +def _validate_evidence(config: HostJobConfig) -> tuple[bytes, str]: + payload = _regular_bytes(config.evidence_path, MAX_EVIDENCE_BYTES) + try: + document = lifecycle.load_lifecycle_json(payload.decode("utf-8")) + summary = lifecycle.validate_lifecycle_document(document) + except Exception as exc: + raise HostJobError("lifecycle evidence is invalid") from exc + if ( + summary.get("run_id") != config.lifecycle_run_id + or summary.get("platform") != config.platform + or summary.get("source_commit") != config.source_commit + ): + raise HostJobError("lifecycle evidence binding changed") + return payload, _digest_bytes(payload) + + +def execute( + config_path: Path, + *, + clock: Callable[[], float] = time.time, + entrypoint_runner: Callable[[HostJobConfig], int] = _run_entrypoint, +) -> Mapping[str, Any]: + config = load_config(config_path) + existing_terminal = _load_terminal(config) + if existing_terminal is not None: + return existing_terminal + if _load_status(config) is not None: + raise HostJobError("attempt was already started") + + _atomic_json( + config.status_path, + { + "schema_version": SCHEMA_VERSION, + "run_id": config.run_id, + "platform": config.platform, + "attempt_ordinal": config.attempt_ordinal, + "state": "running", + "started_at_unix": int(clock()), + }, + exclusive=True, + ) + + exit_code = 125 + failure_code: str | None = "host_job_failed" + evidence_digest: str | None = None + result = "failed" + try: + exit_code = int(entrypoint_runner(config)) + if exit_code == 0: + _payload, evidence_digest = _validate_evidence(config) + result = "passed" + failure_code = None + elif exit_code == 124: + failure_code = "host_job_timed_out" + elif exit_code == 126: + failure_code = "host_job_output_exceeded" + else: + failure_code = "lifecycle_failed" + except Exception: + failure_code = "invalid_lifecycle_evidence" if exit_code == 0 else "host_job_failed" + result = "failed" + evidence_digest = None + + terminal = _terminal( + config, + result=result, + failure_code=failure_code, + evidence_digest=evidence_digest, + exit_code=exit_code, + finished_at_unix=int(clock()), + ) + _atomic_json(config.terminal_path, terminal, exclusive=True) + return terminal + + +def _native_snapshot(value: Mapping[str, Any]) -> Mapping[str, Any]: + raw = _exact_mapping(value, _NATIVE_FIELDS, "native snapshot") + if raw["native_state"] not in {"absent", "starting", "running", "inactive"}: + raise HostJobError("native state is invalid") + if type(raw["binding_ok"]) is not bool: + raise HostJobError("native binding is invalid") + if raw["native_state"] == "absent" and raw["binding_ok"]: + raise HostJobError("absent native job has a binding") + return raw + + +def observe_job(config: HostJobConfig, native: Mapping[str, Any]) -> dict[str, Any]: + snapshot = _native_snapshot(native) + terminal = _load_terminal(config) + status = _load_status(config) + if not snapshot["binding_ok"] and snapshot["native_state"] != "absent": + return {"job_state": "ambiguous", "attempt_ordinal": 1, "evidence_digest": None} + if terminal is not None: + return { + "job_state": "passed" if terminal["result"] == "passed" else "failed", + "attempt_ordinal": config.attempt_ordinal, + "evidence_digest": terminal["evidence_digest"], + } + if status is not None: + state = snapshot["native_state"] + return { + "job_state": "running" if state in {"starting", "running"} else "ambiguous", + "attempt_ordinal": config.attempt_ordinal, + "evidence_digest": None, + } + if snapshot["native_state"] == "absent": + return {"job_state": "absent", "attempt_ordinal": 0, "evidence_digest": None} + if snapshot["binding_ok"] and snapshot["native_state"] in {"starting", "running"}: + return {"job_state": "starting", "attempt_ordinal": 1, "evidence_digest": None} + return {"job_state": "ambiguous", "attempt_ordinal": 1, "evidence_digest": None} + + +def collect(config_path: Path) -> bytes: + config = load_config(config_path) + terminal = _load_terminal(config) + if terminal is None or terminal["result"] != "passed": + raise HostJobError("successful terminal record is absent") + payload, digest = _validate_evidence(config) + if digest != terminal["evidence_digest"]: + raise HostJobError("evidence digest changed") + return payload + + +def _default_runner( + argv: Sequence[str], + *, + timeout: int = 60, +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + list(argv), + check=False, + capture_output=True, + text=True, + timeout=timeout, + shell=False, + ) + + +def _powershell_argv(script: str) -> list[str]: + import base64 + + encoded = base64.b64encode(script.encode("utf-16le")).decode("ascii") + return [ + r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-EncodedCommand", + encoded, + ] + + +def _ps_quote(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + +def _windows_action_arguments(config: HostJobConfig) -> str: + return f'"{config.adapter_path}" execute --config ' f'"{config.config_path}"' + + +def _windows_register_script(config: HostJobConfig) -> str: + task_path = "\\" + return "\n".join( + [ + "$ErrorActionPreference = 'Stop'", + f"$taskPath = {_ps_quote(task_path)}", + f"$taskName = {_ps_quote(config.job_name)}", + "$identity = [Security.Principal.WindowsIdentity]::GetCurrent()", + "$currentUser = [string]$identity.Name", + "$leafUser = $currentUser.Substring($currentUser.LastIndexOf('\\') + 1)", + f"if ($identity.IsSystem -or $leafUser -ine {_ps_quote(config.host_user)}) {{ throw 'ordinary host user mismatch' }}", + "$existing = Get-ScheduledTask -TaskPath $taskPath -TaskName $taskName -ErrorAction SilentlyContinue", + "if ($null -ne $existing) { throw 'exact task already exists' }", + ( + "$action = New-ScheduledTaskAction " + f"-Execute {_ps_quote(os.fspath(config.python_executable))} " + f"-Argument {_ps_quote(_windows_action_arguments(config))}" + ), + ( + "$principal = New-ScheduledTaskPrincipal -UserId $currentUser " + "-LogonType Interactive -RunLevel Limited" + ), + ( + "$settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew " + f"-ExecutionTimeLimit (New-TimeSpan -Seconds {config.max_run_seconds + 2 * SUPERVISOR_GRACE_SECONDS})" + ), + ( + "Register-ScheduledTask -TaskPath $taskPath -TaskName $taskName " + "-Action $action -Principal $principal -Settings $settings | Out-Null" + ), + "Start-ScheduledTask -TaskPath $taskPath -TaskName $taskName", + ] + ) + + +def _windows_snapshot_script(config: HostJobConfig) -> str: + task_path = "\\" + arguments = _windows_action_arguments(config) + return "\n".join( + [ + "$ErrorActionPreference = 'Stop'", + f"$taskPath = {_ps_quote(task_path)}", + f"$taskName = {_ps_quote(config.job_name)}", + "$task = Get-ScheduledTask -TaskPath $taskPath -TaskName $taskName -ErrorAction SilentlyContinue", + "if ($null -eq $task) {", + " [pscustomobject]@{ native_state = 'absent'; binding_ok = $false } | ConvertTo-Json -Compress", + " exit 0", + "}", + "$identity = [Security.Principal.WindowsIdentity]::GetCurrent()", + "$currentUser = [string]$identity.Name", + "$leafUser = $currentUser.Substring($currentUser.LastIndexOf('\\') + 1)", + "$action = @($task.Actions)[0]", + f"$expectedLimit = [Xml.XmlConvert]::ToString([TimeSpan]::FromSeconds({config.max_run_seconds + 2 * SUPERVISOR_GRACE_SECONDS}))", + ( + "$binding = (@($task.Actions).Count -eq 1) -and " + f"($action.Execute -eq {_ps_quote(os.fspath(config.python_executable))}) -and " + f"($action.Arguments -eq {_ps_quote(arguments)}) -and " + f"(-not $identity.IsSystem) -and ($leafUser -ieq {_ps_quote(config.host_user)}) -and " + "($task.Principal.UserId -ieq $currentUser) -and " + "($task.Principal.LogonType -eq 'Interactive') -and " + "($task.Principal.RunLevel -eq 'Limited') -and " + "($task.Settings.MultipleInstances -eq 'IgnoreNew') -and " + "($task.Settings.ExecutionTimeLimit -eq $expectedLimit)" + ), + "$state = if ($task.State -eq 'Running') { 'running' } elseif ($task.State -eq 'Queued') { 'starting' } else { 'inactive' }", + "[pscustomobject]@{ native_state = $state; binding_ok = [bool]$binding } | ConvertTo-Json -Compress", + ] + ) + + +def _parse_json_stdout(result: subprocess.CompletedProcess[str]) -> Mapping[str, Any]: + if result.returncode != 0 or len(result.stdout.encode("utf-8")) > 32_768: + raise HostJobError("native supervisor inventory failed") + return _strict_json(result.stdout.encode("utf-8"), 32_768) + + +def _windows_snapshot(config: HostJobConfig, runner: Runner) -> Mapping[str, Any]: + result = runner(_powershell_argv(_windows_snapshot_script(config)), timeout=60) + return _native_snapshot(_parse_json_stdout(result)) + + +def _linux_service(config: HostJobConfig) -> str: + return config.job_name + ".service" + + +def _linux_start_argv(config: HostJobConfig) -> list[str]: + return [ + "sudo", + "-n", + "/usr/bin/systemd-run", + "--quiet", + "--collect", + "--service-type=exec", + "--unit", + config.job_name, + f"--property=User={config.host_user}", + f"--property=Group={config.host_user}", + f"--property=WorkingDirectory={config.working_directory}", + "--property=Restart=no", + "--property=KillMode=control-group", + "--property=UMask=0077", + "--property=NoNewPrivileges=no", + "--property=PrivateTmp=yes", + "--property=TimeoutStartSec=120", + f"--property=RuntimeMaxSec={config.max_run_seconds + 2 * SUPERVISOR_GRACE_SECONDS}", + os.fspath(config.python_executable), + os.fspath(config.adapter_path), + "execute", + "--config", + os.fspath(config.config_path), + ] + + +def _parse_systemd_seconds(value: str) -> float: + if value == "0": + return 0.0 + factors = { + "us": 0.000001, + "ms": 0.001, + "s": 1.0, + "min": 60.0, + "h": 3600.0, + "d": 86_400.0, + } + parts = re.findall(r"(\d+(?:\.\d+)?)(us|ms|s|min|h|d)", value) + compact = re.sub(r"\s+", "", value) + if not parts or "".join(number + unit for number, unit in parts) != compact: + raise HostJobError("native supervisor duration is invalid") + return sum(float(number) * factors[unit] for number, unit in parts) + + +def _linux_snapshot(config: HostJobConfig, runner: Runner) -> Mapping[str, Any]: + argv = [ + "sudo", + "-n", + "/usr/bin/systemctl", + "show", + _linux_service(config), + "--no-pager", + "--property=LoadState", + "--property=ActiveState", + "--property=SubState", + "--property=User", + "--property=Group", + "--property=ExecStart", + "--property=WorkingDirectory", + "--property=Restart", + "--property=KillMode", + "--property=UMask", + "--property=NoNewPrivileges", + "--property=PrivateTmp", + "--property=TimeoutStartUSec", + "--property=RuntimeMaxUSec", + ] + result = runner(argv, timeout=60) + if result.returncode != 0: + raise HostJobError("native supervisor inventory failed") + if len(result.stdout.encode("utf-8")) > 32_768: + raise HostJobError("native supervisor inventory is too large") + fields: dict[str, str] = {} + for line in result.stdout.splitlines(): + key, separator, value = line.partition("=") + if not separator or key in fields: + raise HostJobError("native supervisor inventory is invalid") + fields[key] = value + expected_fields = { + "LoadState", + "ActiveState", + "SubState", + "User", + "Group", + "ExecStart", + "WorkingDirectory", + "Restart", + "KillMode", + "UMask", + "NoNewPrivileges", + "PrivateTmp", + "TimeoutStartUSec", + "RuntimeMaxUSec", + } + if set(fields) != expected_fields: + raise HostJobError("native supervisor inventory is incomplete") + if fields["LoadState"] == "not-found": + return {"native_state": "absent", "binding_ok": False} + command = " ".join(fields["ExecStart"].split()) + expected_command_prefix = ( + f"{{ path={config.python_executable} ; " + f"argv[]={config.python_executable} {config.adapter_path} " + f"execute --config {config.config_path} ; ignore_errors=" + ) + binding = ( + fields["LoadState"] == "loaded" + and fields["User"] == config.host_user + and fields["Group"] == config.host_user + and command.startswith(expected_command_prefix) + and fields["WorkingDirectory"] == os.fspath(config.working_directory) + and fields["Restart"] == "no" + and fields["KillMode"] == "control-group" + and fields["UMask"] == "0077" + and fields["NoNewPrivileges"] == "no" + and fields["PrivateTmp"] == "yes" + and _parse_systemd_seconds(fields["TimeoutStartUSec"]) == 120.0 + and _parse_systemd_seconds(fields["RuntimeMaxUSec"]) == config.max_run_seconds + 2 * SUPERVISOR_GRACE_SECONDS + ) + if fields["ActiveState"] in {"activating", "reloading"}: + native_state = "starting" + elif fields["ActiveState"] == "active": + native_state = "running" + else: + native_state = "inactive" + return {"native_state": native_state, "binding_ok": binding} + + +def native_snapshot(config: HostJobConfig, runner: Runner = _default_runner) -> Mapping[str, Any]: + return _windows_snapshot(config, runner) if config.platform == "windows" else _linux_snapshot(config, runner) + + +def start(config_path: Path, runner: Runner = _default_runner) -> Mapping[str, Any]: + config = load_config(config_path) + current = observe_job(config, native_snapshot(config, runner)) + if current["job_state"] != "absent" or current["attempt_ordinal"] != 0: + return current + + if config.platform == "windows": + result = runner(_powershell_argv(_windows_register_script(config)), timeout=60) + else: + result = runner(_linux_start_argv(config), timeout=60) + if result.returncode != 0: + raise HostJobError("native supervisor start failed") + observed = observe_job(config, native_snapshot(config, runner)) + if observed["job_state"] == "absent": + raise HostJobError("native supervisor start was not durable") + return observed + + +def cleanup(config_path: Path, runner: Runner = _default_runner) -> Mapping[str, Any]: + config = load_config(config_path) + snapshot = native_snapshot(config, runner) + if snapshot["native_state"] == "absent": + return snapshot + if not snapshot["binding_ok"]: + raise HostJobError("refusing to remove foreign exact-name job") + if config.platform == "windows": + task_path = "\\" + script = "\n".join( + [ + "$ErrorActionPreference = 'Stop'", + f"$taskPath = {_ps_quote(task_path)}", + f"$taskName = {_ps_quote(config.job_name)}", + "Stop-ScheduledTask -TaskPath $taskPath -TaskName $taskName -ErrorAction SilentlyContinue", + "Unregister-ScheduledTask -TaskPath $taskPath -TaskName $taskName -Confirm:$false", + ] + ) + result = runner(_powershell_argv(script), timeout=60) + else: + result = runner( + [ + "sudo", + "-n", + "/usr/bin/systemctl", + "stop", + _linux_service(config), + ], + timeout=60, + ) + if result.returncode != 0: + raise HostJobError("native supervisor cleanup failed") + final = native_snapshot(config, runner) + if final["native_state"] != "absent": + raise HostJobError("native supervisor cleanup is incomplete") + return final + + +def _render(value: Mapping[str, Any]) -> str: + return json.dumps(value, allow_nan=False, separators=(",", ":"), sort_keys=True) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("action", choices=("start", "status", "execute", "collect", "cleanup")) + parser.add_argument("--config", required=True) + try: + arguments = parser.parse_args(sys.argv[1:] if argv is None else argv) + config_path = Path(arguments.config) + if arguments.action == "start": + print(_render(start(config_path))) + elif arguments.action == "status": + config = load_config(config_path) + print(_render(observe_job(config, native_snapshot(config)))) + elif arguments.action == "execute": + terminal = execute(config_path) + print(_render(terminal)) + return 0 if terminal["result"] == "passed" else 2 + elif arguments.action == "collect": + sys.stdout.buffer.write(collect(config_path)) + else: + print(_render(cleanup(config_path))) + return 0 + except (Exception, SystemExit): + print( + _render( + { + "failure_code": "host_job_rejected", + "result": "failed", + "schema_version": SCHEMA_VERSION, + } + ) + ) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/gate13_linux_packaged_lifecycle.py b/scripts/gate13_linux_packaged_lifecycle.py index 1a6e7a18b..ad2d3695d 100644 --- a/scripts/gate13_linux_packaged_lifecycle.py +++ b/scripts/gate13_linux_packaged_lifecycle.py @@ -2712,27 +2712,37 @@ def run_from_config(path: str) -> Mapping[str, Any]: raise LifecycleRunError("lifecycle cleanup was not proved") +def _termination_requested(_signum: int, _frame: Any) -> None: + raise LifecycleRunError("lifecycle termination was requested") + + def main(argv: Sequence[str] | None = None) -> int: arguments = list(sys.argv[1:] if argv is None else argv) os.umask(0o077) + previous_sigterm = signal.signal(signal.SIGTERM, _termination_requested) + previous_sigint = signal.signal(signal.SIGINT, _termination_requested) try: - _disable_core_dumps() - if len(arguments) != 2 or arguments[0] != "--config": - raise LifecycleRunError("exactly one config path is required") - document = run_from_config(arguments[1]) - except BaseException: - print( - _canonical_json( - { - "failure_code": "linux_lifecycle_failed", - "result": "failed", - "schema_version": SCHEMA_VERSION, - } + try: + _disable_core_dumps() + if len(arguments) != 2 or arguments[0] != "--config": + raise LifecycleRunError("exactly one config path is required") + document = run_from_config(arguments[1]) + except BaseException: + print( + _canonical_json( + { + "failure_code": "linux_lifecycle_failed", + "result": "failed", + "schema_version": SCHEMA_VERSION, + } + ) ) - ) - return 2 - print(_canonical_json(document)) - return 0 + return 2 + print(_canonical_json(document)) + return 0 + finally: + signal.signal(signal.SIGTERM, previous_sigterm) + signal.signal(signal.SIGINT, previous_sigint) if __name__ == "__main__": diff --git a/scripts/gate13_run_controller.py b/scripts/gate13_run_controller.py index d6be33df2..1c9f5dbcf 100644 --- a/scripts/gate13_run_controller.py +++ b/scripts/gate13_run_controller.py @@ -137,6 +137,7 @@ class RunPlan: linux_package_bytes: int qwen_manifest: str gemma_manifest: str + clients_may_run_concurrently: bool @property def instance_names(self) -> tuple[str, str, str]: @@ -353,12 +354,18 @@ def load_plan(authorization_path: Path, ledger_path: Path) -> RunPlan: linux_package_bytes=_integer(linux_package.get("bytes"), "Linux package bytes", minimum=1), qwen_manifest=qwen_manifest, gemma_manifest=gemma_manifest, + clients_may_run_concurrently=_boolean( + sequencing.get("clients_may_run_concurrently"), + "client concurrency policy", + ), ) def initial_state(plan: RunPlan) -> dict[str, Any]: if plan.ledger_state != "RESERVED": raise RunControllerError("authorization is not reserved for provisioning") + if plan.clients_may_run_concurrently: + raise RunControllerError("concurrent clients are forbidden") return { "schema_version": STATE_SCHEMA_VERSION, "run_id": plan.run_id, @@ -519,6 +526,36 @@ def _fail(state: dict[str, Any], code: str, observation: Mapping[str, Any]) -> d return state +def begin_action(state: Mapping[str, Any], plan: RunPlan, *, action: str) -> dict[str, Any]: + """Persist an action intent before its first provider or host mutation. + + A missing resource after one of these transitions is a consumed failed attempt, not + permission to recreate it. Repeating ``start`` must inventory and reconcile the + durable job instead of calling this function again. + """ + + current = validate_state(state, plan) + if action != current["next_action"] or action not in ACTION_STATES - {"none"}: + raise RunControllerError("action intent is out of order") + phases = { + "start_route": "ROUTE_STARTING", + "accept_route": "ROUTE_ACCEPTING", + "start_windows": "WINDOWS_RUNNING", + "collect_windows": "WINDOWS_COLLECTING", + "delete_windows": "WINDOWS_DELETING", + "start_linux": "LINUX_RUNNING", + "collect_linux": "LINUX_COLLECTING", + "delete_linux": "LINUX_DELETING", + "delete_route": "ROUTE_DELETING", + "cleanup_failure": "CLEANING_FAILED", + } + result = dict(current) + result["phase"] = phases[action] + result["next_action"] = "none" + result["revision"] += 1 + return validate_state(result, plan) + + def reconcile( state: Mapping[str, Any], observation: Mapping[str, Any], @@ -536,7 +573,9 @@ def reconcile( return result if _all_resources_absent(observed): - if current["phase"] in {"CLEANING_FAILED", "ROUTE_DELETING", "LINUX_COLLECTED"}: + if current["phase"] == "ABSENT": + result["next_action"] = "start_route" + elif current["phase"] in {"CLEANING_FAILED", "ROUTE_DELETING", "LINUX_COLLECTED", "LINUX_DELETING"}: passed = ( current["failure_code"] is None and current["route_acceptance_digest"] is not None @@ -547,8 +586,10 @@ def reconcile( result["cleanup_verified"] = True result["next_action"] = "none" else: - result["phase"] = "ABSENT" - result["next_action"] = "start_route" + result["phase"] = "CLEANED_FAILURE" + result["failure_code"] = "resources_disappeared_before_completion" + result["cleanup_verified"] = True + result["next_action"] = "none" result["revision"] += 1 return validate_state(result, plan) @@ -558,6 +599,8 @@ def reconcile( route_job = observed["route_acceptance"]["job_state"] windows_job = observed["clients"]["windows"]["job_state"] linux_job = observed["clients"]["linux"]["job_state"] + windows_attempt = observed["clients"]["windows"]["attempt_ordinal"] + linux_attempt = observed["clients"]["linux"]["attempt_ordinal"] if not route_present or route_job in {"failed", "ambiguous"}: return _fail(result, "route_failed_or_ambiguous", observed) @@ -568,16 +611,38 @@ def reconcile( if route_job != "passed": if windows_present or linux_present: return _fail(result, "client_started_before_route_acceptance", observed) - result["phase"] = "ROUTE_ACCEPTING" - result["next_action"] = "accept_route" + if current["phase"] == "ROUTE_ACCEPTING" and current["next_action"] == "none": + if route_job == "absent": + return _fail(result, "route_acceptance_disappeared", observed) + result["phase"] = "ROUTE_ACCEPTING" + result["next_action"] = "none" + else: + result["phase"] = "ROUTE_ACCEPTING" + result["next_action"] = "accept_route" else: route_digest = observed["route_acceptance"]["evidence_digest"] if route_digest is None: return _fail(result, "route_acceptance_digest_absent", observed) result["route_acceptance_digest"] = route_digest + if current["windows_evidence_digest"] is None and windows_attempt == 1 and windows_job == "absent": + return _fail(result, "windows_attempt_disappeared", observed) + if current["linux_evidence_digest"] is None and linux_attempt == 1 and linux_job == "absent": + return _fail(result, "linux_attempt_disappeared", observed) if linux_present and current["windows_evidence_digest"] is None: return _fail(result, "linux_started_before_windows_evidence", observed) - if windows_present: + if current["phase"] == "WINDOWS_DELETING": + if windows_present or observed["disks"][plan.windows_disk]: + result["next_action"] = "delete_windows" + else: + result["phase"] = "WINDOWS_COLLECTED" + result["next_action"] = "start_linux" + elif current["phase"] == "LINUX_DELETING": + if linux_present or observed["disks"][plan.linux_disk]: + result["next_action"] = "delete_linux" + else: + result["phase"] = "LINUX_COLLECTED" + result["next_action"] = "delete_route" + elif windows_present: result["windows_consumed"] = windows_job != "absent" if windows_job in {"failed", "ambiguous"}: return _fail(result, "windows_failed_or_ambiguous", observed) @@ -585,14 +650,20 @@ def reconcile( result["phase"] = "WINDOWS_COLLECTING" result["next_action"] = "collect_windows" elif windows_job == "absent": - result["phase"] = "ROUTE_ACCEPTED" - result["next_action"] = "start_windows" + if current["phase"] == "WINDOWS_RUNNING" and current["next_action"] == "none": + result["phase"] = "WINDOWS_RUNNING" + result["next_action"] = "none" + else: + result["phase"] = "ROUTE_ACCEPTED" + result["next_action"] = "start_windows" else: result["phase"] = "WINDOWS_RUNNING" result["next_action"] = "none" elif current["windows_evidence_digest"] is None: if current["windows_consumed"]: return _fail(result, "windows_consumed_without_evidence", observed) + if current["phase"] == "WINDOWS_RUNNING" and current["next_action"] == "none": + return _fail(result, "windows_disappeared_after_start_intent", observed) result["phase"] = "ROUTE_ACCEPTED" result["next_action"] = "start_windows" elif linux_present: @@ -603,14 +674,20 @@ def reconcile( result["phase"] = "LINUX_COLLECTING" result["next_action"] = "collect_linux" elif linux_job == "absent": - result["phase"] = "WINDOWS_COLLECTED" - result["next_action"] = "start_linux" + if current["phase"] == "LINUX_RUNNING" and current["next_action"] == "none": + result["phase"] = "LINUX_RUNNING" + result["next_action"] = "none" + else: + result["phase"] = "WINDOWS_COLLECTED" + result["next_action"] = "start_linux" else: result["phase"] = "LINUX_RUNNING" result["next_action"] = "none" elif current["linux_evidence_digest"] is None: if current["linux_consumed"]: return _fail(result, "linux_consumed_without_evidence", observed) + if current["phase"] == "LINUX_RUNNING" and current["next_action"] == "none": + return _fail(result, "linux_disappeared_after_start_intent", observed) result["phase"] = "WINDOWS_COLLECTED" result["next_action"] = "start_linux" else: @@ -676,11 +753,25 @@ def collect_platform( return validate_state(result, plan) -def mark_client_absent(state: Mapping[str, Any], plan: RunPlan, *, platform: str) -> dict[str, Any]: +def mark_client_absent( + state: Mapping[str, Any], + plan: RunPlan, + *, + platform: str, + observation: Mapping[str, Any], + now_unix: int, +) -> dict[str, Any]: current = validate_state(state, plan) expected = "WINDOWS_DELETING" if platform == "windows" else "LINUX_DELETING" if current["phase"] != expected or current[f"{platform}_evidence_digest"] is None: raise RunControllerError("client deletion is out of order") + observed = validate_observation(observation, plan, now_unix) + instance = plan.windows_instance if platform == "windows" else plan.linux_instance + disk = plan.windows_disk if platform == "windows" else plan.linux_disk + if _instance_present(observed, instance) or observed["disks"][disk]: + raise RunControllerError("client absence is not proved") + if not _instance_present(observed, plan.route_instance): + raise RunControllerError("route disappeared during client deletion") result = dict(current) if platform == "windows": result["phase"] = "WINDOWS_COLLECTED" diff --git a/scripts/gate13_windows_packaged_lifecycle.ps1 b/scripts/gate13_windows_packaged_lifecycle.ps1 index 59646ff1b..e516c2065 100644 --- a/scripts/gate13_windows_packaged_lifecycle.ps1 +++ b/scripts/gate13_windows_packaged_lifecycle.ps1 @@ -1199,7 +1199,33 @@ function Get-Gate13Sha256 { if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "required file missing" } - return (Get-FileHash -LiteralPath $Path -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + $stream = $null + $hasher = $null + $digest = $null + try { + $stream = [System.IO.FileStream]::new( + $Path, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Read, + [System.IO.FileShare]::Read, + 1048576, + [System.IO.FileOptions]::SequentialScan + ) + $hasher = [System.Security.Cryptography.SHA256]::Create() + $digest = $hasher.ComputeHash($stream) + return [System.BitConverter]::ToString($digest).Replace("-", "").ToLowerInvariant() + } + finally { + if ($null -ne $digest) { + [Array]::Clear($digest, 0, $digest.Length) + } + if ($null -ne $hasher) { + $hasher.Dispose() + } + if ($null -ne $stream) { + $stream.Dispose() + } + } } function Read-Gate13JsonFile { diff --git a/tests/test_gate13_host_job.py b/tests/test_gate13_host_job.py new file mode 100644 index 000000000..41875aa06 --- /dev/null +++ b/tests/test_gate13_host_job.py @@ -0,0 +1,507 @@ +import hashlib +import io +import json +import subprocess +import sys +import threading +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import gate13_host_job as host_job # noqa: E402 + + +def sha256(path): + return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() + + +@pytest.fixture +def config_factory(tmp_path, monkeypatch): + def make(platform="linux"): + root = tmp_path / platform + root.mkdir() + adapter = root / "gate13_host_job.py" + adapter.write_bytes(host_job.ADAPTER_PATH.read_bytes()) + entrypoint = root / ( + "gate13_windows_packaged_lifecycle.ps1" if platform == "windows" else "gate13_linux_packaged_lifecycle.py" + ) + entrypoint.write_text("# bound lifecycle\n", encoding="utf-8") + lifecycle_config = root / ("gate13-windows-run.json" if platform == "windows" else "gate13-linux-run.json") + lifecycle_config.write_text('{"bound":true}\n', encoding="utf-8") + python = Path(sys.executable).resolve() + + monkeypatch.setitem(host_job.HOST_ROOTS, platform, root) + monkeypatch.setitem(host_job.HOST_PYTHON, platform, python) + monkeypatch.setattr(host_job, "ADAPTER_PATH", adapter.resolve()) + + run_id = "gate13-test-a" + raw = { + "schema_version": 1, + "run_id": run_id, + "lifecycle_run_id": f"{run_id}-{platform}", + "platform": platform, + "attempt_ordinal": 1, + "source_commit": "a" * 40, + "job_name": f"communityai-gate13-{run_id}-{platform}", + "host_user": "gate13", + "adapter_path": str(adapter.resolve()), + "adapter_sha256": sha256(adapter), + "config_path": str((root / "host-job.json").resolve()), + "entrypoint_path": str(entrypoint.resolve()), + "entrypoint_sha256": sha256(entrypoint), + "lifecycle_config_path": str(lifecycle_config.resolve()), + "lifecycle_config_sha256": sha256(lifecycle_config), + "evidence_path": str((root / "evidence.json").resolve()), + "stderr_path": str((root / "stderr.log").resolve()), + "status_path": str((root / "status.json").resolve()), + "terminal_path": str((root / "terminal.json").resolve()), + "working_directory": str(root.resolve()), + "python_executable": str(python), + "max_run_seconds": 3600, + } + path = root / "host-job.json" + path.write_text(json.dumps(raw), encoding="utf-8") + return path, raw + + return make + + +def test_load_config_binds_exact_files_paths_and_single_attempt(config_factory): + path, raw = config_factory() + + config = host_job.load_config(path) + + assert config.attempt_ordinal == 1 + assert config.job_name == "communityai-gate13-gate13-test-a-linux" + assert config.adapter_sha256 == raw["adapter_sha256"] + assert config.entrypoint_sha256 == raw["entrypoint_sha256"] + assert config.lifecycle_config_sha256 == raw["lifecycle_config_sha256"] + assert config.host_user == "gate13" + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("attempt_ordinal", 2), + ("job_name", "communityai-gate13-foreign-linux"), + ("lifecycle_run_id", "foreign-linux"), + ("max_run_seconds", 86_400), + ("host_user", "root"), + ], +) +def test_changed_execution_binding_fails_closed(config_factory, field, value): + path, raw = config_factory() + raw[field] = value + path.write_text(json.dumps(raw), encoding="utf-8") + + with pytest.raises(host_job.HostJobError): + host_job.load_config(path) + + +def test_path_escape_and_entrypoint_tampering_fail_closed(config_factory, tmp_path): + path, raw = config_factory() + raw["evidence_path"] = str((tmp_path / "escaped.json").resolve()) + path.write_text(json.dumps(raw), encoding="utf-8") + with pytest.raises(host_job.HostJobError, match="escapes"): + host_job.load_config(path) + + raw["evidence_path"] = str((path.parent / "evidence.json").resolve()) + Path(raw["entrypoint_path"]).write_text("# changed\n", encoding="utf-8") + path.write_text(json.dumps(raw), encoding="utf-8") + with pytest.raises(host_job.HostJobError, match="entrypoint digest changed"): + host_job.load_config(path) + + +def test_lifecycle_config_tampering_fails_closed(config_factory): + path, raw = config_factory() + Path(raw["lifecycle_config_path"]).write_text('{"changed":true}\n', encoding="utf-8") + + with pytest.raises(host_job.HostJobError, match="lifecycle config digest changed"): + host_job.load_config(path) + + +def test_windows_task_is_bounded_ordinary_user_single_instance(config_factory): + path, _raw = config_factory("windows") + config = host_job.load_config(path) + + script = host_job._windows_register_script(config) + + assert "New-ScheduledTaskPrincipal -UserId $currentUser" in script + assert "-LogonType Interactive -RunLevel Limited" in script + assert "$identity.IsSystem" in script + assert "'SYSTEM'" not in script + assert "-MultipleInstances IgnoreNew" in script + assert "-ExecutionTimeLimit" in script + assert str(config.adapter_path) in script + assert str(config.config_path) in script + assert "password" not in script.lower() + assert "token" not in script.lower() + + snapshot = host_job._windows_snapshot_script(config) + assert "MultipleInstances -eq 'IgnoreNew'" in snapshot + assert "ExecutionTimeLimit -eq $expectedLimit" in snapshot + assert "LogonType -eq 'Interactive'" in snapshot + assert "RunLevel -eq 'Limited'" in snapshot + assert "$task.Principal.UserId -ieq $currentUser" in snapshot + + +@pytest.mark.skipif(sys.platform != "win32", reason="requires Windows PowerShell parser") +def test_windows_task_scripts_parse_natively(config_factory): + import base64 + + path, _raw = config_factory("windows") + config = host_job.load_config(path) + for source in ( + host_job._windows_register_script(config), + host_job._windows_snapshot_script(config), + ): + encoded = base64.b64encode(source.encode("utf-16le")).decode("ascii") + probe = ( + "$source=[Text.Encoding]::Unicode.GetString(" + f"[Convert]::FromBase64String('{encoded}'));" + "$tokens=$null;$errors=$null;" + "[Management.Automation.Language.Parser]::ParseInput(" + "$source,[ref]$tokens,[ref]$errors)|Out-Null;" + "if($errors.Count -ne 0){exit 2}" + ) + result = subprocess.run( + host_job._powershell_argv(probe), + check=False, + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stderr + + +def test_linux_unit_is_bounded_non_root_and_non_restarting(config_factory): + path, _raw = config_factory() + config = host_job.load_config(path) + + argv = host_job._linux_start_argv(config) + + assert argv[:4] == ["sudo", "-n", "/usr/bin/systemd-run", "--quiet"] + assert f"--unit" in argv + assert config.job_name in argv + assert "--property=User=gate13" in argv + assert "--property=Restart=no" in argv + assert "--property=KillMode=control-group" in argv + assert "--property=NoNewPrivileges=no" in argv + assert "--property=TimeoutStartSec=120" in argv + assert f"--property=RuntimeMaxSec={config.max_run_seconds + 2 * host_job.SUPERVISOR_GRACE_SECONDS}" in argv + assert "--wait" not in argv + assert host_job._entrypoint_argv(config)[-2:] == [ + "--config", + str(config.lifecycle_config_path), + ] + + +def test_bounded_copy_caps_private_diagnostics(tmp_path): + destination = tmp_path / "stderr.log" + overflow = threading.Event() + errors = [] + + host_job._bounded_copy( + io.BytesIO(b"x" * 257), + destination, + 256, + overflow, + errors, + ) + + assert overflow.is_set() + assert errors == [] + assert destination.stat().st_size == 256 + + +def test_real_entrypoint_output_is_capped(config_factory): + path, raw = config_factory() + entrypoint = Path(raw["entrypoint_path"]) + entrypoint.write_text( + "import sys\nsys.stdout.buffer.write(b'x' * 1048577)\n", + encoding="utf-8", + ) + raw["entrypoint_sha256"] = sha256(entrypoint) + path.write_text(json.dumps(raw), encoding="utf-8") + config = host_job.load_config(path) + + assert host_job._run_entrypoint(config) == 126 + assert config.evidence_path.stat().st_size == host_job.MAX_EVIDENCE_BYTES + assert config.stderr_path.stat().st_size == 0 + + +def test_linux_tree_shutdown_escalates_to_process_group(config_factory, monkeypatch): + path, _raw = config_factory() + config = host_job.load_config(path) + signals = [] + + class Process: + pid = 4321 + + def __init__(self): + self.waits = 0 + + def wait(self, timeout): + self.waits += 1 + if self.waits == 1: + raise subprocess.TimeoutExpired("entrypoint", timeout) + return -9 + + monkeypatch.setattr( + host_job.os, + "killpg", + lambda pid, requested: signals.append((pid, requested)), + raising=False, + ) + host_job._stop_process_tree(config, Process()) + + assert signals == [ + (4321, host_job.POSIX_SIGTERM), + (4321, host_job.POSIX_SIGKILL), + ] + + +def test_execute_persists_status_validates_evidence_and_never_relaunches(config_factory, monkeypatch): + path, _raw = config_factory() + calls = [] + + monkeypatch.setattr(host_job.lifecycle, "load_lifecycle_json", lambda _payload: {}) + monkeypatch.setattr( + host_job.lifecycle, + "validate_lifecycle_document", + lambda _document: { + "run_id": "gate13-test-a-linux", + "platform": "linux", + "source_commit": "a" * 40, + }, + ) + + def runner(config): + calls.append(config.job_name) + config.evidence_path.write_text('{"canonical":true}', encoding="utf-8") + config.stderr_path.write_bytes(b"") + return 0 + + terminal = host_job.execute(path, clock=lambda: 2_000_000_000, entrypoint_runner=runner) + repeated = host_job.execute(path, clock=lambda: 2_000_000_001, entrypoint_runner=runner) + + assert terminal["result"] == "passed" + assert terminal["failure_code"] is None + assert terminal["evidence_digest"].startswith("sha256:") + assert repeated == terminal + assert calls == ["communityai-gate13-gate13-test-a-linux"] + status = json.loads((path.parent / "status.json").read_text(encoding="utf-8")) + assert status["state"] == "running" + assert status["attempt_ordinal"] == 1 + + +def test_started_attempt_without_terminal_is_never_relaunched(config_factory): + path, _raw = config_factory() + config = host_job.load_config(path) + host_job._atomic_json( + config.status_path, + { + "schema_version": 1, + "run_id": config.run_id, + "platform": config.platform, + "attempt_ordinal": 1, + "state": "running", + "started_at_unix": 2_000_000_000, + }, + exclusive=True, + ) + called = False + + def runner(_config): + nonlocal called + called = True + return 0 + + with pytest.raises(host_job.HostJobError, match="already started"): + host_job.execute(path, entrypoint_runner=runner) + assert called is False + + +def test_observation_distinguishes_pristine_active_terminal_and_ambiguous(config_factory, monkeypatch): + path, _raw = config_factory() + config = host_job.load_config(path) + + assert host_job.observe_job(config, {"native_state": "absent", "binding_ok": False}) == { + "job_state": "absent", + "attempt_ordinal": 0, + "evidence_digest": None, + } + assert host_job.observe_job(config, {"native_state": "running", "binding_ok": True}) == { + "job_state": "starting", + "attempt_ordinal": 1, + "evidence_digest": None, + } + assert host_job.observe_job(config, {"native_state": "running", "binding_ok": False}) == { + "job_state": "ambiguous", + "attempt_ordinal": 1, + "evidence_digest": None, + } + + monkeypatch.setattr(host_job.lifecycle, "load_lifecycle_json", lambda _payload: {}) + monkeypatch.setattr( + host_job.lifecycle, + "validate_lifecycle_document", + lambda _document: { + "run_id": "gate13-test-a-linux", + "platform": "linux", + "source_commit": "a" * 40, + }, + ) + + def runner(bound): + bound.evidence_path.write_text("{}", encoding="utf-8") + bound.stderr_path.write_bytes(b"") + return 0 + + terminal = host_job.execute(path, clock=lambda: 2_000_000_000, entrypoint_runner=runner) + observed = host_job.observe_job(config, {"native_state": "absent", "binding_ok": False}) + assert observed["job_state"] == "passed" + assert observed["evidence_digest"] == terminal["evidence_digest"] + assert host_job.observe_job(config, {"native_state": "running", "binding_ok": False}) == { + "job_state": "ambiguous", + "attempt_ordinal": 1, + "evidence_digest": None, + } + + +def test_inactive_after_persisted_start_is_ambiguous(config_factory): + path, _raw = config_factory() + config = host_job.load_config(path) + host_job._atomic_json( + config.status_path, + { + "schema_version": 1, + "run_id": config.run_id, + "platform": config.platform, + "attempt_ordinal": 1, + "state": "running", + "started_at_unix": 2_000_000_000, + }, + exclusive=True, + ) + + assert host_job.observe_job(config, {"native_state": "inactive", "binding_ok": True}) == { + "job_state": "ambiguous", + "attempt_ordinal": 1, + "evidence_digest": None, + } + + +def test_collect_revalidates_terminal_digest_and_lifecycle_binding(config_factory, monkeypatch): + path, _raw = config_factory() + monkeypatch.setattr(host_job.lifecycle, "load_lifecycle_json", lambda _payload: {}) + monkeypatch.setattr( + host_job.lifecycle, + "validate_lifecycle_document", + lambda _document: { + "run_id": "gate13-test-a-linux", + "platform": "linux", + "source_commit": "a" * 40, + }, + ) + + def runner(config): + config.evidence_path.write_text('{"ok":true}', encoding="utf-8") + config.stderr_path.write_bytes(b"") + return 0 + + host_job.execute(path, clock=lambda: 2_000_000_000, entrypoint_runner=runner) + assert host_job.collect(path) == b'{"ok":true}' + + (path.parent / "evidence.json").write_text('{"ok":false}', encoding="utf-8") + with pytest.raises(host_job.HostJobError, match="digest changed"): + host_job.collect(path) + + +def test_start_reattaches_to_bound_native_job_without_mutation(config_factory, monkeypatch): + path, _raw = config_factory() + monkeypatch.setattr( + host_job, + "native_snapshot", + lambda _config, _runner: {"native_state": "running", "binding_ok": True}, + ) + + def forbidden(*_args, **_kwargs): + raise AssertionError("native start must not run") + + observed = host_job.start(path, runner=forbidden) + + assert observed == { + "job_state": "starting", + "attempt_ordinal": 1, + "evidence_digest": None, + } + + +def test_linux_snapshot_binds_exact_service_command(config_factory): + path, _raw = config_factory() + config = host_job.load_config(path) + stdout = "\n".join( + [ + "LoadState=loaded", + "ActiveState=active", + "SubState=running", + "User=gate13", + "Group=gate13", + ( + "ExecStart={ path=" + f"{config.python_executable} ; argv[]={config.python_executable} " + f"{config.adapter_path} execute --config {config.config_path} ; " + "ignore_errors=no ; }" + ), + f"WorkingDirectory={config.working_directory}", + "Restart=no", + "KillMode=control-group", + "UMask=0077", + "NoNewPrivileges=no", + "PrivateTmp=yes", + "TimeoutStartUSec=2min", + "RuntimeMaxUSec=1h 2min", + ] + ) + + def runner(_argv, timeout): + assert timeout == 60 + return subprocess.CompletedProcess([], 0, stdout=stdout, stderr="") + + assert host_job._linux_snapshot(config, runner) == { + "native_state": "running", + "binding_ok": True, + } + + foreign_stdout = stdout.replace( + f"execute --config {config.config_path} ;", + f"execute --config {config.config_path} --extra ;", + ) + + def foreign_runner(_argv, timeout): + assert timeout == 60 + return subprocess.CompletedProcess([], 0, stdout=foreign_stdout, stderr="") + + assert host_job._linux_snapshot(config, foreign_runner) == { + "native_state": "running", + "binding_ok": False, + } + + +def test_public_cli_failure_is_bounded_and_path_free(capsys, tmp_path): + missing = tmp_path / "secret-token-config.json" + + exit_code = host_job.main(["status", "--config", str(missing)]) + + assert exit_code == 2 + output = capsys.readouterr().out + assert json.loads(output) == { + "failure_code": "host_job_rejected", + "result": "failed", + "schema_version": 1, + } + assert str(missing) not in output diff --git a/tests/test_gate13_linux_packaged_lifecycle.py b/tests/test_gate13_linux_packaged_lifecycle.py index 45e60baf7..b4a5b4315 100644 --- a/tests/test_gate13_linux_packaged_lifecycle.py +++ b/tests/test_gate13_linux_packaged_lifecycle.py @@ -742,6 +742,11 @@ def run(command, **kwargs): assert owner.owned == [] +def test_termination_signal_enters_lifecycle_cleanup_path(): + with pytest.raises(linux_lifecycle.LifecycleRunError, match="termination"): + linux_lifecycle._termination_requested(15, None) + + def test_main_failure_is_generic_and_does_not_echo_config(monkeypatch, capsys): marker = "/private/path/must-not-escape" monkeypatch.setattr(linux_lifecycle, "_disable_core_dumps", lambda: None) diff --git a/tests/test_gate13_run_controller.py b/tests/test_gate13_run_controller.py index 93182b77a..7c9b0120f 100644 --- a/tests/test_gate13_run_controller.py +++ b/tests/test_gate13_run_controller.py @@ -20,16 +20,22 @@ @pytest.fixture def plan(tmp_path): + raw = json.loads(AUTHORIZATION.read_text(encoding="utf-8")) + raw["provider_plan"]["sequencing"]["clients_may_run_concurrently"] = False + old_digest = raw["provider_plan_digest"] + new_digest = controller._provider_digest(raw["provider_plan"]) + raw["provider_plan_digest"] = new_digest + authorization = tmp_path / "authorization.json" + authorization.write_text(json.dumps(raw), encoding="utf-8") + ledger = tmp_path / "ledger.md" ledger.write_text( - LEDGER.read_text(encoding="utf-8").replace( - "| CLEANED-COMMITTED |", - "| RESERVED |", - 1, - ), + LEDGER.read_text(encoding="utf-8") + .replace(old_digest, new_digest, 1) + .replace("| CLEANED-COMMITTED |", "| RESERVED |", 1), encoding="utf-8", ) - return controller.load_plan(AUTHORIZATION, ledger) + return controller.load_plan(authorization, ledger) def observation( @@ -92,6 +98,7 @@ def test_load_plan_binds_exact_cost_and_resources(plan): "gate13-20260831-a-linux", ) assert controller.PROTECTED_INSTANCE not in plan.instance_names + assert plan.clients_may_run_concurrently is False def test_cleaned_committed_ledger_cannot_start_a_new_run(): @@ -102,6 +109,22 @@ def test_cleaned_committed_ledger_cannot_start_a_new_run(): controller.initial_state(historical_plan) +def test_reserved_parallel_client_plan_cannot_start(tmp_path): + ledger = tmp_path / "ledger.md" + ledger.write_text( + LEDGER.read_text(encoding="utf-8").replace( + "| CLEANED-COMMITTED |", + "| RESERVED |", + 1, + ), + encoding="utf-8", + ) + parallel = controller.load_plan(AUTHORIZATION, ledger) + + with pytest.raises(controller.RunControllerError, match="concurrent clients"): + controller.initial_state(parallel) + + def test_changed_authorization_fails_closed(tmp_path): raw = json.loads(AUTHORIZATION.read_text(encoding="utf-8")) raw["provider_plan"]["route"]["machine_type"] = "e2-micro" @@ -180,6 +203,120 @@ def test_failed_or_ambiguous_windows_is_consumed_and_never_resumed(plan, job_sta assert state["next_action"] == "cleanup_failure" +def test_action_intent_is_persisted_before_mutation_and_cannot_relaunch(plan): + state = controller.initial_state(plan) + started = controller.begin_action(state, plan, action="start_route") + + assert started["phase"] == "ROUTE_STARTING" + assert started["next_action"] == "none" + missing = controller.reconcile(started, observation(plan), plan, now_unix=NOW) + assert missing["phase"] == "CLEANED_FAILURE" + assert missing["failure_code"] == "resources_disappeared_before_completion" + with pytest.raises(controller.RunControllerError, match="out of order"): + controller.begin_action(started, plan, action="start_route") + + +def test_route_acceptance_intent_cannot_rearm_after_dispatch(plan): + ready = controller.reconcile( + controller.initial_state(plan), + observation(plan, route=True, route_job="absent"), + plan, + now_unix=NOW, + ) + dispatched = controller.begin_action(ready, plan, action="accept_route") + + running = controller.reconcile( + dispatched, + observation(plan, route=True, route_job="running"), + plan, + now_unix=NOW, + ) + assert running["phase"] == "ROUTE_ACCEPTING" + assert running["next_action"] == "none" + + missing = controller.reconcile( + dispatched, + observation(plan, route=True, route_job="absent"), + plan, + now_unix=NOW, + ) + + assert missing["phase"] == "CLEANING_FAILED" + assert missing["failure_code"] == "route_acceptance_disappeared" + assert missing["next_action"] == "cleanup_failure" + + +def test_client_start_intent_cannot_rearm_after_dispatch(plan): + route_ready = controller.reconcile( + controller.initial_state(plan), + observation(plan, route=True, route_job="passed"), + plan, + now_unix=NOW, + ) + windows_dispatched = controller.begin_action(route_ready, plan, action="start_windows") + + provisioning = controller.reconcile( + windows_dispatched, + observation(plan, route=True, windows=True, route_job="passed", windows_job="absent"), + plan, + now_unix=NOW, + ) + assert provisioning["phase"] == "WINDOWS_RUNNING" + assert provisioning["next_action"] == "none" + + missing = controller.reconcile( + windows_dispatched, + observation(plan, route=True, route_job="passed"), + plan, + now_unix=NOW, + ) + assert missing["phase"] == "CLEANING_FAILED" + assert missing["failure_code"] == "windows_disappeared_after_start_intent" + + linux_ready = dict(route_ready) + linux_ready.update( + { + "phase": "WINDOWS_COLLECTED", + "windows_evidence_digest": WINDOWS_DIGEST, + "windows_consumed": True, + "next_action": "start_linux", + } + ) + linux_dispatched = controller.begin_action(linux_ready, plan, action="start_linux") + linux_provisioning = controller.reconcile( + linux_dispatched, + observation(plan, route=True, linux=True, route_job="passed", linux_job="absent"), + plan, + now_unix=NOW, + ) + assert linux_provisioning["phase"] == "LINUX_RUNNING" + assert linux_provisioning["next_action"] == "none" + + linux_missing = controller.reconcile( + linux_dispatched, + observation(plan, route=True, route_job="passed"), + plan, + now_unix=NOW, + ) + assert linux_missing["phase"] == "CLEANING_FAILED" + assert linux_missing["failure_code"] == "linux_disappeared_after_start_intent" + + +def test_observed_attempt_cannot_disappear_and_relaunch(plan): + state = controller.reconcile( + controller.initial_state(plan), + observation(plan, route=True, route_job="passed"), + plan, + now_unix=NOW, + ) + disappeared = observation(plan, route=True, route_job="passed") + disappeared["clients"]["windows"]["attempt_ordinal"] = 1 + + failed = controller.reconcile(state, disappeared, plan, now_unix=NOW) + assert failed["phase"] == "CLEANING_FAILED" + assert failed["failure_code"] == "windows_attempt_disappeared" + + def test_active_host_job_is_observed_not_relaunched(plan): state = controller.reconcile( controller.initial_state(plan), @@ -227,10 +364,28 @@ def test_collect_binds_canonical_evidence_then_deletes_windows(monkeypatch, plan assert collected["next_action"] == "delete_windows" assert collected["windows_consumed"] is True - after_delete = controller.reconcile( + deleting = observation(plan, route=True, route_job="passed") + deleting["clients"]["windows"]["attempt_ordinal"] = 1 + deleting["disks"][plan.windows_disk] = True + still_present = controller.reconcile(collected, deleting, plan, now_unix=NOW) + assert still_present["phase"] == "WINDOWS_DELETING" + assert still_present["next_action"] == "delete_windows" + with pytest.raises(controller.RunControllerError, match="absence is not proved"): + controller.mark_client_absent( + collected, + plan, + platform="windows", + observation=deleting, + now_unix=NOW, + ) + + absent = observation(plan, route=True, route_job="passed") + absent["clients"]["windows"]["attempt_ordinal"] = 1 + after_delete = controller.mark_client_absent( collected, - observation(plan, route=True, route_job="passed"), plan, + platform="windows", + observation=absent, now_unix=NOW, ) assert after_delete["phase"] == "WINDOWS_COLLECTED" diff --git a/tests/test_gate13_windows_packaged_lifecycle.py b/tests/test_gate13_windows_packaged_lifecycle.py index b850aade3..77fafc63b 100644 --- a/tests/test_gate13_windows_packaged_lifecycle.py +++ b/tests/test_gate13_windows_packaged_lifecycle.py @@ -81,6 +81,22 @@ def test_json_input_bound_accepts_production_scale_and_rejects_above_limit(tmp_p } +@pytest.mark.skipif(not POWERSHELL.is_file(), reason="native Windows PowerShell is required") +def test_sha256_does_not_depend_on_powershell_module_autoload(tmp_path): + target = tmp_path / "payload.bin" + target.write_bytes(b"clean-host-hash") + source = f""" +. {_ps_literal(LIFECYCLE)} +Remove-Module Microsoft.PowerShell.Utility -Force -ErrorAction Stop +$PSModuleAutoLoadingPreference = 'None' +[Console]::Out.WriteLine((Get-Gate13Sha256 -Path {_ps_literal(target)})) +""" + + result = _run_powershell(source, tmp_path) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == hashlib.sha256(target.read_bytes()).hexdigest() + + @pytest.mark.skipif(not POWERSHELL.is_file(), reason="native Windows PowerShell is required") def test_windows_build_platform_accepts_production_runner_and_rejects_spoofs(tmp_path): source = f""" From 0e16ac2aa088e0719e9c3c89451176544602bab3 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:26:41 -0500 Subject: [PATCH 07/56] gate13: close native job binding gaps --- scripts/gate13_host_job.py | 37 ++++++++++++++++++++++++++++------- tests/test_gate13_host_job.py | 31 ++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/scripts/gate13_host_job.py b/scripts/gate13_host_job.py index 72873820e..6cfe19b10 100644 --- a/scripts/gate13_host_job.py +++ b/scripts/gate13_host_job.py @@ -299,6 +299,11 @@ def load_config(path: Path) -> HostJobConfig: expected_lifecycle_name = "gate13-windows-run.json" if platform == "windows" else "gate13-linux-run.json" if values["lifecycle_config_path"].name != expected_lifecycle_name: raise HostJobError("lifecycle config path changed") + if platform == "windows" and not _same_path( + values["lifecycle_config_path"], + values["entrypoint_path"].parent / expected_lifecycle_name, + ): + raise HostJobError("Windows lifecycle config is not beside its entrypoint") if not _same_path(values["python_executable"], HOST_PYTHON[platform]): raise HostJobError("Python executable changed") if not _same_path(values["adapter_path"], ADAPTER_PATH): @@ -953,6 +958,30 @@ def _parse_systemd_seconds(value: str) -> float: return sum(float(number) * factors[unit] for number, unit in parts) +def _systemd_exec_start_matches(config: HostJobConfig, value: str) -> bool: + if not value or any(character in value for character in ("\r", "\n", "\x00")): + return False + normalized = " ".join(value.split()) + matched = re.fullmatch( + ( + r"\{ path=(?P\S+) ; argv\[\]=(?P[^;]+) ; " + r"ignore_errors=(?Pyes|no) ; " + r"start_time=\[[^\]]+\] ; stop_time=\[[^\]]+\] ; " + r"pid=(?P\d+) ; code=(?P\(null\)|[a-z-]+) ; " + r"status=(?P[A-Za-z0-9()/+.-]+) \}" + ), + normalized, + ) + if matched is None: + return False + expected_argv = f"{config.python_executable} {config.adapter_path} " f"execute --config {config.config_path}" + return ( + matched["path"] == os.fspath(config.python_executable) + and matched["argv"] == expected_argv + and matched["ignore"] == "no" + ) + + def _linux_snapshot(config: HostJobConfig, runner: Runner) -> Mapping[str, Any]: argv = [ "sudo", @@ -1007,17 +1036,11 @@ def _linux_snapshot(config: HostJobConfig, runner: Runner) -> Mapping[str, Any]: raise HostJobError("native supervisor inventory is incomplete") if fields["LoadState"] == "not-found": return {"native_state": "absent", "binding_ok": False} - command = " ".join(fields["ExecStart"].split()) - expected_command_prefix = ( - f"{{ path={config.python_executable} ; " - f"argv[]={config.python_executable} {config.adapter_path} " - f"execute --config {config.config_path} ; ignore_errors=" - ) binding = ( fields["LoadState"] == "loaded" and fields["User"] == config.host_user and fields["Group"] == config.host_user - and command.startswith(expected_command_prefix) + and _systemd_exec_start_matches(config, fields["ExecStart"]) and fields["WorkingDirectory"] == os.fspath(config.working_directory) and fields["Restart"] == "no" and fields["KillMode"] == "control-group" diff --git a/tests/test_gate13_host_job.py b/tests/test_gate13_host_job.py index 41875aa06..ee49a73b5 100644 --- a/tests/test_gate13_host_job.py +++ b/tests/test_gate13_host_job.py @@ -123,6 +123,20 @@ def test_lifecycle_config_tampering_fails_closed(config_factory): host_job.load_config(path) +def test_windows_lifecycle_config_must_be_beside_entrypoint(config_factory): + path, raw = config_factory("windows") + nested = path.parent / "nested" + nested.mkdir() + nominated = nested / "gate13-windows-run.json" + nominated.write_text('{"bound":true}\n', encoding="utf-8") + raw["lifecycle_config_path"] = str(nominated.resolve()) + raw["lifecycle_config_sha256"] = sha256(nominated) + path.write_text(json.dumps(raw), encoding="utf-8") + + with pytest.raises(host_job.HostJobError, match="not beside"): + host_job.load_config(path) + + def test_windows_task_is_bounded_ordinary_user_single_instance(config_factory): path, _raw = config_factory("windows") config = host_job.load_config(path) @@ -455,7 +469,8 @@ def test_linux_snapshot_binds_exact_service_command(config_factory): "ExecStart={ path=" f"{config.python_executable} ; argv[]={config.python_executable} " f"{config.adapter_path} execute --config {config.config_path} ; " - "ignore_errors=no ; }" + "ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; " + "pid=0 ; code=(null) ; status=0/0 }" ), f"WorkingDirectory={config.working_directory}", "Restart=no", @@ -491,6 +506,20 @@ def foreign_runner(_argv, timeout): "binding_ok": False, } + for foreign_stdout in ( + stdout.replace("ignore_errors=no", "ignore_errors=yes"), + stdout.replace("status=0/0 }", "status=0/0 ; arbitrary=value }"), + ): + + def foreign_metadata_runner(_argv, timeout): + assert timeout == 60 + return subprocess.CompletedProcess([], 0, stdout=foreign_stdout, stderr="") + + assert host_job._linux_snapshot(config, foreign_metadata_runner) == { + "native_state": "running", + "binding_ok": False, + } + def test_public_cli_failure_is_bounded_and_path_free(capsys, tmp_path): missing = tmp_path / "secret-token-config.json" From 1d30e3d195fc55133b486ad041b216b37b798ac8 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:29:19 -0500 Subject: [PATCH 08/56] docs: record Gate 13 durable host prerequisite --- CHANGELOG.md | 8 ++ docs/RELEASE_READINESS.md | 13 +- ...60831-b-durable-host-job-prerequisite.json | 122 ++++++++++++++++++ 3 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 docs/evidence/gate13-20260831-b-durable-host-job-prerequisite.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a9f6f75e..4edf5ec6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and qualification evidence remains in `docs/REVIVAL_TEST_RESULTS.md`. ### Added +- Gate 13 paid qualification now has durable source-bound native host jobs: an exact-current-user + Windows Scheduled Task and a non-root transient Linux systemd service persist one attempt across + operator disconnects, bound output, terminate the complete process tree on timeout or overflow, + revalidate canonical lifecycle evidence before collection, and never re-arm consumed route or + client intents. Exact Windows lifecycle-config co-location and full Linux `ExecStart` structure + matching close the final independent-review gaps. Source `0e16ac2` passes the 217-test Gate 13 and + desktop matrix independently. This is a software prerequisite only: it created no cloud resources, + authorizes no paid run, and does not claim a completed clean-host lifecycle or Gate 13 pass. - Gate 13 paid qualification now has a persisted authorization-bound run-state contract that inventories exact resources before every transition, accepts the product route before any client, runs Windows/Qwen before Linux/Gemma, permanently consumes a failed or ambiguous diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md index b535ee139..c3b99397d 100644 --- a/docs/RELEASE_READINESS.md +++ b/docs/RELEASE_READINESS.md @@ -54,9 +54,14 @@ or diagnostic product launches. They are not reusable as fresh acceptance hosts. cleanup now proves the route, both clients, all three disks, and both firewall rules absent; the protected bootstrap remains running. The USD 52 maximum remains committed in the current USD 100 epoch, leaving USD 48 without an explicit cleanup-backed owner reset. Source -`ddfb7c6` adds the first persisted fail-closed run-state contract and 15 adversarial tests; -the 187-test Gate 13 package/lifecycle regression passes. No later mandatory gate is unblocked -until a controller-bound fresh run produces both complete 16-phase records and exact cleanup. +`0e16ac2` (built on native-host-job slice `2fe0de9`) now completes the persisted fail-closed +run-state and durable host-execution prerequisite. Exact-current-user Windows Scheduled Task and +non-root Linux systemd jobs survive operator disconnects, permit one attempt, bound output and the +complete process tree, validate exact source/config/native-supervisor bindings, and revalidate +canonical evidence before collection. The [software-prerequisite evidence](evidence/gate13-20260831-b-durable-host-job-prerequisite.json) +binds the pushed source and independently reproduced 217-test Gate 13/desktop regression. It +created no cloud resources and authorizes no paid run. No later mandatory gate is unblocked until +a fresh source-bound run produces both complete 16-phase records and exact cleanup. Do not work on the post-alpha items in the deferred table while an alpha gate can progress. Missing Docker, snapshots, local GPU hardware, or local host capacity is not an external @@ -88,7 +93,7 @@ longer consume the new authorization; later billing should still be recorded for | 10 | Implement automatic contributor model and block placement | PASSED | Signed bootstrap now installs one bounded `auto` worker. The local planner filters exact manifested candidates through owner policy and local resource ceilings, requires fresh authenticated replica coverage, targets the least-covered contiguous range with per-node jitter, reconciles exact-manifest launches through the existing artifact-verifying server and `WorkerSupervisor`, applies residency/cooldown/switch hysteresis, exposes placement reasons, and preserves an explicit operator pause across ineligibility or placement changes. A new or migrated worker must sign an expiring exact-manifest/range intent with fixed numeric resource claims and receive a remote DHT store acknowledgement (`exclude_self=True`) before entering the artifact path; invalid, rejected, or failed publication is fail-closed and cannot advance planner state, while a previously admitted placement is retained. Actual completed local generations feed exact-manifest demand, useful-throughput, and reliability through two bounded five-minute aggregate windows; no prompt, output, token ID, key, request ID, address, path, error, or per-request event is retained. Only a closed window with at least four completed routes may be signed by the separate router identity and published under the manifest-bound `demand-v1` DHT key with a 90-second lifetime and `exclude_self=True`. Consumers verify signature, exact schema/digest, lifetime, revocation, and replay ordering. The threshold-signed catalog may authorize 2–32 sorted RSA observer roots; missing or empty roots disable remote demand. Discovery discards unlisted identities before signature/replay work, excludes local and duplicate roots, isolates malformed records, requires two authorized roots, and medians at most 32 quantized observations. Observer keys are never generated or bundled: only a separately provisioned `route-demand.key` matching a signed root may publish, while ordinary nodes can consume without one. Any hot-edited root-list mismatch disables both publication and consumption until restart. Local utility is capped at 6 points and signed remote utility at 2, keeping the combined hint below the 10-point migration margin and 100-point replica step. Verified announcement and route-demand replay watermarks now survive restarts in one Windows-safe journal per raw manifest digest under the node data directory. Each strict journal is capped at 256 active identity scopes and 256 KiB, retains only public record kind, key ID, ordering tuple, record digest, and the bounded replay deadline, and is fsync-written through atomic replacement; malformed, duplicate, oversized, symlinked, non-regular, or unwritable state fails closed. The retained deadline prevents an older still-live record from returning after a short-lived newer record expires. The replay slice's 99-test focused protocol/discovery/planner/node-configuration matrix and 209-pass, 2-skip catalog/node/API superset pass. The Sybil slice's 122-test focused catalog/bootstrap/config/discovery matrix proves that 30 valid attacker keys plus one authorized root cannot reach threshold, two authorized roots aggregate without attacker weight, one high authorized vote cannot inflate a lower second vote, old catalogs remain signature-verifiable with remote demand disabled, and trust-epoch reload mismatches fail closed. A 190-pass, 1-skip catalog/protocol/planner/discovery/node/API superset also passes. Independent verification passed 146 focused tests and a 255-pass, 2-skip broader node/API superset, plus a native-Windows publication-boundary probe; formatting, import-order, import-smoke, and diff checks pass. The [explicit privacy review](AUTOMATIC_PLACEMENT_PRIVACY_V1.md) inventories collection, retention, public-key linkability, DHT/journal/API/log exposure, secure-deletion limits, and residual governance/host risks. Three executable privacy-contract tests fix the aggregate, intent, demand, replay, forbidden-field, and path-free warning schemas; the focused privacy/protocol/planner/discovery/node matrix passes 108 tests and the broader catalog/node/API matrix passes 258 tests with 2 skips. Independent privacy review passed 108 tests with 1 skip and a 225-pass, 2-skip broader subset; every caught observer-key exception and an unauthorized key produced no path, key ID, or exception detail, while prompt and identity-path schema injections failed closed. The [deterministic convergence and load acceptance](AUTOMATIC_PLACEMENT_ACCEPTANCE_V1.md) closes the remaining software gate: equal snapshots use node-specific 32-point model dispersion and range rendezvous ranks; a fixed 512-node cold cohort selects both models and every range below the 85% concentration boundary; two 4,096-node fresh-arrival cohorts remain below that boundary under maximum priority-aligned or standby demand; maximum demand causes zero incumbent migrations; one-replica loss migrates after residency without early reversal; rolling arrivals keep every model/block populated and repair an abrupt block loss. The alpha fails closed above 32 candidates or 512 blocks, permits one `auto` worker, clamps reconciliation to at least one second, and scans each candidate in one bounded pass. The focused planner/convergence/configuration matrix passes 78 tests and the broader catalog/protocol/discovery/node/API matrix passes 214 with 2 skips. A real Windows DHT round trip exposed and fixed a durable-replay multiprocessing regression: replay guards now omit/recreate their thread lock across serialization and reload persistent state; its 15-test protocol/network matrix passes. Independent verification reproduced the 78-test focus, passed an expanded 235-test matrix with 2 skips and the 15-test real-DHT probe, and exercised adversarial score, timing, 32-by-512 load, 1,000-case range-equivalence, and persistent replay-reload boundaries. This slice used no cloud resources and spent USD 0. | Gates 9–11 are passed. Gates 13–14 must now prove the packaged flow and real hardware ceilings using the published envelopes. | | 11 | Operate initial public alpha routes | PASSED | [Product-node run `route-20260830-j`](evidence/gate11node-20260830-a-lifecycle.json) installed the generic CommunityAI wheel on a bounded G2/L4 VM, verified the signed catalog, downloaded both exact manifested models directly from Hugging Face into one persistent shared cache, and used the product node's automatic workers to expose complete Qwen 24/24 primary and Gemma 35/35 standby routes. No model-specific image, cache mirror, or operator-transferred model artifact was used. The privacy-safe acceptance passed one-token primary inference, deliberate primary pause, automatic Gemma selection in 58.073 seconds, standby inference, Qwen restoration in 32.042 seconds, and restored inference. Both workers were stable before the drill. After Gate 13 released the L4, the preserved route was restored without changing its model cache or source, its ephemeral endpoint was rebound, both product-node services became active, and a fresh acceptance reproved Qwen 24/24 primary inference, automatic Gemma 35/35 fallback/inference, Qwen restoration, and restored inference. The protected bootstrap remains running. A corrected 4,800-second provider DELETE backstop was set for `2026-08-31T05:28:16.516Z`, earlier than the original deadline. [Post-backstop cleanup evidence](evidence/gate11route-20260830-j-backstop-cleanup.json) and an independent recheck prove the route instance, named disk, and both exact run-scoped firewall rules absent, all Gate 13 clients/disks absent, zero remaining route availability, and the protected bootstrap still running. The same-host standby is a bounded alpha fallback, not independent infrastructure redundancy; independent redundancy remains post-alpha. | Gate 11 acceptance evidence remains complete, but no product route is live after the corrected DELETE backstop. [Gate 13 run `gate13-20260831-a`](evidence/gate13-20260831-a-cost-authorization.json) now binds refreshed native authentication, fail-closed preflight, and a fresh USD 52 reservation for the replacement route and packaged clients. | | 12 | Create, publish, and bundle the minimal signed alpha catalog/bootstrap | PASSED | [Run `gate12-20260829-a`](evidence/gate12-20260829-alpha-catalog-publication.json) published the deterministic [`communityai-public-alpha-v1` bundle](../public-alpha/catalog-v1/bundle.json) from source `26be579`. Its threshold-one Ed25519 root signs sequence 1 with the exact qualified Qwen primary and Gemma standby manifests, one pinned public HTTPS mirror, one public seed, a one-route best-effort policy, and no unprovisioned route-demand roots. The canonical bundle binds five members and retains `complete_release_qualification=false`. All three public objects returned HTTP 200 with exact sizes, and a fresh empty consumer fetched them remotely, verified the signature/digests, and created the two-model `auto` node configuration. The private signing key remained ignored and uncommitted. The focused publication suite passes 32 tests, the catalog/bootstrap/model/desktop superset passes 92, and the run spent USD 0. | Preserve the branch-scoped mirror until a newly signed catalog sequence and packaged bootstrap migrate it. The Gate 11 acceptance and Gate 9 envelopes exist; [Gate 13 run `gate13-20260831-a`](evidence/gate13-20260831-a-cost-authorization.json) now authorizes the bounded replacement route and fresh packaged clients under the new epoch. Independent threshold holders and interchangeable mirror/seed governance are post-alpha. | -| 13 | Pass packaged clean-install inference on Windows and Linux | IN PROGRESS | [Prerequisite run `gate13-20260830-a-prerequisites`](evidence/gate13-20260830-a-prerequisites.json) established deterministic install archives, exact first-use bytes, strict provenance, and the canonical lifecycle contract. [Native-harness and production-package run `gate13-20260830-b`](evidence/gate13-20260830-b-native-harness-and-packages.json) now completes the native Windows Credential Manager/Job Object and Linux Secret Service/systemd-cgroup 16-phase adapters, exact worker and descendant cleanup proofs, 3,600-second acquisition bounds, and package/runtime/catalog cross-binding. Independent software review passed 134 focused tests plus a 113-pass broader matrix with 3 platform skips; the production-discovery correction passes 73 unittests, 4 pytest checks, self-test, formatting, and import checks. [Exact-source production run 33338872342](https://github.com/flujo-app/CommunityAI/actions/runs/33338872342) passed both jobs at source `1971f10` and published independently audited CUDA 12.4 archives: Windows `sha256:45e9cdb439bcb8a6d7ed67914a490f3bc2e12ea1981af944034d62d865f5adc6` (2,695,065,068 bytes) and Linux `sha256:f96d3ca651964380d4684855ab08682e8187b33386327ec3895cda25b43c2a00` (3,360,717,934 bytes). Pushed source `6787272` adds the fixed stdin-only artifact downloader and exact platform configs; its 42-test adversarial suite and independent race/special-member/live-wrapper audit pass. No cloud resource was created for these prerequisites. Real completed clean-host lifecycle evidence remains absent. [Run `gate13-20260831-a`](evidence/gate13-20260831-a-failed-attempt-and-cleanup.json) later verified both exact archives on fresh clients but produced neither canonical lifecycle record. Its state-free transient orchestration advanced both clients before route acceptance, and subsequent diagnostic/product launches consumed both hosts for fresh-host acceptance. Exact cleanup proves all three instances/disks and both firewalls absent while the protected bootstrap remains running. Source `ddfb7c6` adds a persisted authorization-bound state contract that enforces inventory-first reattachment, route acceptance, sequential Windows-then-Linux evidence, consumed-host failure semantics, deadline runway, and success only after both canonical records plus final absence. Its 14 tests and the 186-test Gate 13 regression pass. | [Run `gate13-20260830-c` revision 13](evidence/gate13-20260830-c-cost-authorization.json) is stopped clean. The latest Windows host passed exact package audit, clean install, four desktop self-tests, and the packaged-node self-test, then failed before model acquisition because child stderr diagnostics contaminated strict JSON captured on stdout. [Attempt, cleanup, correction, and route-restoration evidence](evidence/gate13-20260830-c-windows-attempt-and-route-restore.json) proves zero cache bytes, no retained credential or product process, all four exact client instances/disks absent, the bootstrap running, and the temporary restored Qwen/Gemma product route. [Post-backstop cleanup evidence](evidence/gate11route-20260830-j-backstop-cleanup.json) now proves that route, its named disk, and both exact run-scoped firewall rules absent while every Gate 13 target remains absent and the protected bootstrap remains running. Pushed source `4818da3` separates captured stdout from a dedicated NUL stderr sink and passes 15 native tests plus independent high-volume, handle-leak, descendant, timeout, and Job Object probes, but it has not completed a paid clean-host lifecycle. The cleanup-backed 2026-08-31 owner reset releases the USD 98 historical maxima and opens a new USD 100 epoch for the next run; it does not authorize any particular resource or reuse the stopped record. A later native-auth recheck on 2026-08-31 obtained a real access token and passed Compute API access without exposing account or token material. [Run `gate13-20260831-a`](evidence/gate13-20260831-a-failed-attempt-and-cleanup.json) is failed and cleanup-proved; its USD 52 maximum remains committed, leaving USD 48 in the current epoch. Do not reuse its clients or authorization. Complete the fixed durable Windows Scheduled Task and Linux systemd host-job adapter around the persisted state contract, then bind their exact source and a cheaper plan that fits USD 48—or obtain an explicit cleanup-backed owner reset—before a fresh create. The next run must accept the complete route first, run/collect/delete Windows Qwen, then run/collect/delete Linux Gemma, and finally prove exact route cleanup. | +| 13 | Pass packaged clean-install inference on Windows and Linux | IN PROGRESS | [Prerequisite run `gate13-20260830-a-prerequisites`](evidence/gate13-20260830-a-prerequisites.json) established deterministic install archives, exact first-use bytes, strict provenance, and the canonical lifecycle contract. [Native-harness and production-package run `gate13-20260830-b`](evidence/gate13-20260830-b-native-harness-and-packages.json) now completes the native Windows Credential Manager/Job Object and Linux Secret Service/systemd-cgroup 16-phase adapters, exact worker and descendant cleanup proofs, 3,600-second acquisition bounds, and package/runtime/catalog cross-binding. Independent software review passed 134 focused tests plus a 113-pass broader matrix with 3 platform skips; the production-discovery correction passes 73 unittests, 4 pytest checks, self-test, formatting, and import checks. [Exact-source production run 33338872342](https://github.com/flujo-app/CommunityAI/actions/runs/33338872342) passed both jobs at source `1971f10` and published independently audited CUDA 12.4 archives: Windows `sha256:45e9cdb439bcb8a6d7ed67914a490f3bc2e12ea1981af944034d62d865f5adc6` (2,695,065,068 bytes) and Linux `sha256:f96d3ca651964380d4684855ab08682e8187b33386327ec3895cda25b43c2a00` (3,360,717,934 bytes). Pushed source `6787272` adds the fixed stdin-only artifact downloader and exact platform configs; its 42-test adversarial suite and independent race/special-member/live-wrapper audit pass. No cloud resource was created for these prerequisites. Real completed clean-host lifecycle evidence remains absent. [Run `gate13-20260831-a`](evidence/gate13-20260831-a-failed-attempt-and-cleanup.json) later verified both exact archives on fresh clients but produced neither canonical lifecycle record. Its state-free transient orchestration advanced both clients before route acceptance, and subsequent diagnostic/product launches consumed both hosts for fresh-host acceptance. Exact cleanup proves all three instances/disks and both firewalls absent while the protected bootstrap remains running. Source `ddfb7c6` adds a persisted authorization-bound state contract that enforces inventory-first reattachment, route acceptance, sequential Windows-then-Linux evidence, consumed-host failure semantics, deadline runway, and success only after both canonical records plus final absence. [Durable native-host-job prerequisite evidence](evidence/gate13-20260831-b-durable-host-job-prerequisite.json) binds pushed source `0e16ac2` and its base `2fe0de9`: exact-current-user Windows Scheduled Task and non-root Linux systemd jobs survive operator disconnects, permit exactly one attempt, bound stdout/stderr and complete-process-tree cleanup, exact-bind source/config/native inventory, and revalidate canonical evidence before collection. Local and independent nine-file matrices each pass 217 tests with 29 known warnings. No cloud resource was created and no lifecycle pass is claimed. | [Run `gate13-20260830-c` revision 13](evidence/gate13-20260830-c-cost-authorization.json) is stopped clean. The latest Windows host passed exact package audit, clean install, four desktop self-tests, and the packaged-node self-test, then failed before model acquisition because child stderr diagnostics contaminated strict JSON captured on stdout. [Attempt, cleanup, correction, and route-restoration evidence](evidence/gate13-20260830-c-windows-attempt-and-route-restore.json) proves zero cache bytes, no retained credential or product process, all four exact client instances/disks absent, the bootstrap running, and the temporary restored Qwen/Gemma product route. [Post-backstop cleanup evidence](evidence/gate11route-20260830-j-backstop-cleanup.json) now proves that route, its named disk, and both exact run-scoped firewall rules absent while every Gate 13 target remains absent and the protected bootstrap remains running. Pushed source `4818da3` separates captured stdout from a dedicated NUL stderr sink and passes 15 native tests plus independent high-volume, handle-leak, descendant, timeout, and Job Object probes, but it has not completed a paid clean-host lifecycle. The cleanup-backed 2026-08-31 owner reset releases the USD 98 historical maxima and opens a new USD 100 epoch for the next run; it does not authorize any particular resource or reuse the stopped record. A later native-auth recheck on 2026-08-31 obtained a real access token and passed Compute API access without exposing account or token material. [Run `gate13-20260831-a`](evidence/gate13-20260831-a-failed-attempt-and-cleanup.json) is failed and cleanup-proved; its USD 52 maximum remains committed, leaving USD 48 in the current epoch. Do not reuse its clients or authorization. The durable Windows Scheduled Task and Linux systemd host-job adapter prerequisite is complete at pushed source `0e16ac2`. Bind that exact source and a cheaper plan that fits USD 48—or obtain an explicit cleanup-backed owner reset—before a fresh create. The next run must accept the complete route first, run/collect/delete Windows Qwen, then run/collect/delete Linux Gemma, and finally prove exact route cleanup. | | 14 | Pass automatic-contribution and resource-control hardware checks | WAITING | [PR #11](https://github.com/flujo-app/CommunityAI/pull/11) and [PR #12](https://github.com/flujo-app/CommunityAI/pull/12) implemented the authenticated node-authoritative Sharing UI and atomic policy editing, but cross-model automatic placement and real packaged hardware evidence are absent. | After Gates 9–13, follow the [recovery runbook](RECOVERY_TEST_RUNBOOK.md) once for the clean-install product flow while validating model/block choice, exact selected-shard bytes, shared-cache affinity, download authorization, VRAM/storage/bandwidth/power limits, suspension, pause timing, cleanup, restart persistence, and unsupported telemetry on real packaged Windows/Linux hardware. | | 15 | Complete minimal alpha release engineering | WAITING | The desktop builder now emits a stable sorted `SHA256SUMS` inventory of exact regular-file bytes and safe relative in-bundle file symlinks, source/build/catalog-bound `provenance.json`, and `release-metadata.json` with explicit unsigned public-alpha, no-publisher-signature, no-authenticated-update, Windows/Linux-only, no-credits, and incomplete-qualification claims. Structural verification binds each safe file symlink to its canonical in-bundle target, digest, and size while rejecting changed, missing, extra, absolute, external, broken, cyclic, directory-linked/junction, special, traversal, or case-colliding payloads plus unsupported or noncanonical metadata. Exact-source builds also reject dirty relevant inputs, and the expected-input fresh-process check rejects rewritten commit/tree, workflow, platform, Python, PyInstaller, or catalog evidence. Production desktop CI is configured to verify and bundle the Gate 12 inputs, bind the exact clean Git commit/tree and workflow, revalidate every expected input separately, and upload all evidence on Windows/Linux. The focused release-input/artifact suite passes 15 tests, including fresh-process CLI, dirty-source, and canonical-rewrite checks, and the broader catalog/bootstrap/model/desktop subset passes 134. Independent verification reproduced all 134, passed 58 desktop unittests with two environment skips, formatting/import-order/YAML/diff checks, an expected Gate 12/workflow fresh-process probe, and real Windows junction rejection; no cloud was used. [The first PR #22 production-desktop run](https://github.com/flujo-app/CommunityAI/actions/runs/33273518744) reached packaging on both hosts and exposed two exact cross-platform defects: PyInstaller's legitimate relative internal Qt file symlink on Ubuntu and CRLF-transformed signed Gate 12 JSON on Windows. The follow-up binds safe internal file symlinks without accepting external or directory links, forces `public-alpha/**` to LF at checkout, and includes `.gitattributes` in the clean-source boundary. [The second run](https://github.com/flujo-app/CommunityAI/actions/runs/33274432423) proved the Ubuntu package and the Windows signed-bundle/provenance path, then exposed a stale desktop contribution-status schema 2 contract when the packaged node emitted schema 3 automatic-placement evidence. Source `fcd1f41` now strictly validates schema 3 placement and rejects stale schema 2 plus missing, extra, secret-bearing, or inconsistent placement data; its 50-test node/client/lifecycle/build focus and all 59 desktop unittests passed with two environment skips. [The final run](https://github.com/flujo-app/CommunityAI/actions/runs/33275216332) bound exact source `fcd1f417d1435557addb2d6cded9dac0827c7d8c` and completed both Windows and Ubuntu package jobs, including bundle build/smoke, independent checksum/provenance verification, the Windows packaged-node/native-credential/public-seed smoke, and artifact uploads; every PR style, test, and package check is green. Source `36d85d2` makes generic release-artifact fixtures select the supported Linux archive explicitly instead of inheriting the CI host platform; the 21-test local artifact suite and [PR #22 test run 33372581439](https://github.com/flujo-app/CommunityAI/actions/runs/33372581439) pass, without expanding the supported platform matrix. Clean-install lifecycle evidence remains absent. | Retain the verified Windows/Linux artifacts as engineering evidence, then test clean install, manual upgrade/reinstall, uninstall, retained-data choice for the persistent verified model cache, and recovery instructions on both platforms against a newly authorized live product-node route and the published Gate 9 envelopes. Do not mark passed from metadata/unit tests alone. Publisher signing and automatic authenticated update/rollback are post-alpha. | | 16 | Complete the bounded public-alpha safety canary | WAITING | [PR #13](https://github.com/flujo-app/CommunityAI/pull/13) and [PR #14](https://github.com/flujo-app/CommunityAI/pull/14) implemented bounded admission, privacy-safe aggregate health, training-off defaults, rollback procedures, and bounded routine rejection logs; no public canary has run. | After Gates 11–15, run a small monitored canary proving finite admission/timeouts, malformed-peer rejection, health reconstruction, privacy disclosure, route/catalog disable, and clean rollback. Exhaustive hostile-load, Sybil/collusion, partition, and long-soak campaigns are post-alpha. | diff --git a/docs/evidence/gate13-20260831-b-durable-host-job-prerequisite.json b/docs/evidence/gate13-20260831-b-durable-host-job-prerequisite.json new file mode 100644 index 000000000..9b8ec3d44 --- /dev/null +++ b/docs/evidence/gate13-20260831-b-durable-host-job-prerequisite.json @@ -0,0 +1,122 @@ +{ + "schema_version": 1, + "scope": "gate13-durable-native-host-job-prerequisite", + "recorded_at": "2026-08-31T20:27:03Z", + "result": "passed_software_prerequisite", + "gate": 13, + "gate_status": "in_progress", + "source": { + "commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "base_commit": "2fe0de9e1591e36be918fcbac82ecf72c96f8959", + "branch": "codex/gate13-20260831-a", + "pushed": true + }, + "artifacts": { + "host_job_adapter": { + "path": "scripts/gate13_host_job.py", + "sha256": "44b26b0f9828121fed9eef65d830bc9c02760fb8f8ddbb31ed0f1491c1b5d9d4", + "bytes": 41294 + }, + "run_controller": { + "path": "scripts/gate13_run_controller.py", + "sha256": "32b31a3380f4a0e295c0185b9d6c2078b072b601d48a846acc119fbc555f2f8d", + "bytes": 33795 + }, + "linux_lifecycle_helper": { + "path": "scripts/gate13_linux_packaged_lifecycle.py", + "sha256": "90f3af65bb4f77317f707a6b52e329e1d5f81cdeddcb9615a210ec9a5a4cf535", + "bytes": 113748 + }, + "windows_lifecycle_helper": { + "path": "scripts/gate13_windows_packaged_lifecycle.ps1", + "sha256": "9b860ff820851b31ac272ee4d28dd3a899f56014b8dfce45e912ba4fc9ab605c", + "bytes": 132097 + } + }, + "contract": { + "action_intent_persisted_before_mutation": true, + "route_acceptance_intent_never_rearmed": true, + "windows_start_intent_never_rearmed": true, + "linux_start_intent_never_rearmed": true, + "client_attempt_ordinal_maximum": 1, + "clients_sequential": true, + "windows_supervisor": "Scheduled Task", + "windows_principal": "exact current ordinary qualification user", + "windows_logon_type": "Interactive", + "windows_run_level": "Limited", + "linux_supervisor": "transient systemd service", + "linux_principal": "gate13", + "linux_no_new_privileges": false, + "linux_sudo_required_for_inner_owned_cgroups": true, + "native_command_and_safety_settings_exactly_inventoried": true, + "lifecycle_config_path_and_digest_bound": true, + "windows_lifecycle_config_exactly_beside_entrypoint": true, + "linux_exec_start_structure_exactly_bound": true, + "stdout_maximum_bytes": 1048576, + "stderr_maximum_bytes": 262144, + "timeout_and_overflow_tree_shutdown": true, + "linux_termination_enters_finally_cleanup": true, + "terminal_record_contains_evidence_digest_only": true, + "successful_collection_revalidates_canonical_evidence": true + }, + "verification": { + "broad_gate13_matrix": { + "passed": 217, + "failed": 0, + "command_scope": [ + "tests/test_gate13_host_job.py", + "tests/test_gate13_run_controller.py", + "tests/test_gate13_packaged_lifecycle.py", + "tests/test_gate13_windows_packaged_lifecycle.py", + "tests/test_gate13_linux_packaged_lifecycle.py", + "tests/test_gate13_linux_localhost_inference.py", + "tests/test_gate13_download_artifact.py", + "desktop/tests/test_build_desktop.py", + "desktop/tests/test_credentials.py" + ] + }, + "focused_host_job_matrix": { + "passed": 23, + "failed": 0 + }, + "independent_broad_gate13_matrix": { + "passed": 217, + "failed": 0, + "warnings": 29 + }, + "windows_powershell_native_parser": "passed", + "python_compile": "passed", + "black_check": "passed", + "isort_check": "passed", + "git_diff_check": "passed" + }, + "provider_preflight": { + "mutation_performed": false, + "native_authentication": "passed", + "protected_bootstrap_running": true, + "next_run_namespace": "gate13-20260831-b", + "exact_target_instances_absent": true, + "exact_target_disks_absent": true, + "exact_target_firewalls_absent": true, + "g2_standard_8_available": true, + "e2_standard_8_available": true, + "windows_2025_image_resolved": true, + "ubuntu_2404_image_resolved": true, + "l4_quota_limit": 1, + "l4_quota_usage": 0 + }, + "cost": { + "cloud_resources_created": 0, + "cloud_resources_changed": 0, + "cloud_spend_usd": "0.00", + "current_epoch_committed_before_next_run_usd": "52.00", + "current_epoch_remaining_usd": "48.00" + }, + "claims": { + "paid_run_authorized_by_this_record": false, + "clean_host_lifecycle_completed": false, + "gate_13_passed": false, + "credits_work": false, + "macos_work": false + } +} From ee0c05f83035ad22015fb089f5cb30f1713076e6 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:53:09 -0500 Subject: [PATCH 09/56] gate13: bind actions to live budget reservation --- scripts/gate13_run_controller.py | 6 ++- tests/test_gate13_run_controller.py | 67 +++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/scripts/gate13_run_controller.py b/scripts/gate13_run_controller.py index 1c9f5dbcf..f79012e9a 100644 --- a/scripts/gate13_run_controller.py +++ b/scripts/gate13_run_controller.py @@ -31,6 +31,7 @@ MAX_JSON_BYTES = 1_048_576 MAX_STATE_BYTES = 262_144 MIN_ROUTE_RUNWAY_SECONDS = 3_600 +ALLOWED_COMBINED_CLOUD_CEILINGS = frozenset({100.0, 500.0}) PROTECTED_INSTANCE = "communityai-bootstrap-1" _RUN_RE = re.compile(r"[a-z0-9][a-z0-9-]{0,62}") @@ -71,6 +72,7 @@ "cleanup_failure", "none", } +CLEANUP_ACTIONS = frozenset({"delete_windows", "delete_linux", "delete_route", "cleanup_failure"}) _STATE_FIELDS = { "schema_version", @@ -248,7 +250,7 @@ def load_plan(authorization_path: Path, ledger_path: Path) -> RunPlan: raise RunControllerError("cost authorization is invalid") from exc if ( not all(math.isfinite(value) for value in (ceiling, before, maximum, remaining)) - or ceiling != 100.0 + or ceiling not in ALLOWED_COMBINED_CLOUD_CEILINGS or before < 0 or maximum <= 0 or before + maximum > ceiling @@ -535,6 +537,8 @@ def begin_action(state: Mapping[str, Any], plan: RunPlan, *, action: str) -> dic """ current = validate_state(state, plan) + if plan.ledger_state != "RESERVED" and action not in CLEANUP_ACTIONS: + raise RunControllerError("authorization is not reserved for forward action") if action != current["next_action"] or action not in ACTION_STATES - {"none"}: raise RunControllerError("action intent is out of order") phases = { diff --git a/tests/test_gate13_run_controller.py b/tests/test_gate13_run_controller.py index 7c9b0120f..934bb857d 100644 --- a/tests/test_gate13_run_controller.py +++ b/tests/test_gate13_run_controller.py @@ -1,6 +1,7 @@ import hashlib import json import sys +from dataclasses import replace from pathlib import Path import pytest @@ -101,6 +102,41 @@ def test_load_plan_binds_exact_cost_and_resources(plan): assert plan.clients_may_run_concurrently is False +def test_load_plan_accepts_only_documented_owner_ceiling(tmp_path): + raw = json.loads(AUTHORIZATION.read_text(encoding="utf-8")) + raw["provider_plan"]["sequencing"]["clients_may_run_concurrently"] = False + old_digest = raw["provider_plan_digest"] + new_digest = controller._provider_digest(raw["provider_plan"]) + raw["provider_plan_digest"] = new_digest + raw["authorization"].update( + { + "combined_cloud_ceiling_usd": "500.00", + "ledger_committed_before_run_usd": "52.00", + "maximum_estimate_usd": "56.00", + "remaining_after_run_maximum_usd": "392.00", + } + ) + authorization = tmp_path / "authorization.json" + authorization.write_text(json.dumps(raw), encoding="utf-8") + ledger = tmp_path / "ledger.md" + ledger.write_text( + LEDGER.read_text(encoding="utf-8") + .replace(old_digest, new_digest, 1) + .replace("| CLEANED-COMMITTED |", "| RESERVED |", 1), + encoding="utf-8", + ) + + raised_plan = controller.load_plan(authorization, ledger) + assert raised_plan.ledger_state == "RESERVED" + assert controller.initial_state(raised_plan)["next_action"] == "start_route" + + raw["authorization"]["combined_cloud_ceiling_usd"] = "499.00" + raw["authorization"]["remaining_after_run_maximum_usd"] = "391.00" + authorization.write_text(json.dumps(raw), encoding="utf-8") + with pytest.raises(controller.RunControllerError, match="inconsistent"): + controller.load_plan(authorization, ledger) + + def test_cleaned_committed_ledger_cannot_start_a_new_run(): historical_plan = controller.load_plan(AUTHORIZATION, LEDGER) @@ -109,6 +145,37 @@ def test_cleaned_committed_ledger_cannot_start_a_new_run(): controller.initial_state(historical_plan) +def test_non_reserved_ledger_allows_cleanup_only(): + historical_plan = controller.load_plan(AUTHORIZATION, LEDGER) + reserved_plan = replace( + historical_plan, + ledger_state="RESERVED", + clients_may_run_concurrently=False, + ) + reserved_state = controller.initial_state(reserved_plan) + + forward_actions = controller.ACTION_STATES - controller.CLEANUP_ACTIONS - {"none"} + for action in forward_actions: + with pytest.raises(controller.RunControllerError, match="not reserved"): + controller.begin_action(reserved_state, historical_plan, action=action) + + cleanup_state = dict(reserved_state) + cleanup_state.update( + { + "phase": "CLEANING_FAILED", + "failure_code": "operator_cleanup", + "next_action": "cleanup_failure", + } + ) + cleaning = controller.begin_action( + cleanup_state, + historical_plan, + action="cleanup_failure", + ) + assert cleaning["phase"] == "CLEANING_FAILED" + assert cleaning["next_action"] == "none" + + def test_reserved_parallel_client_plan_cannot_start(tmp_path): ledger = tmp_path / "ledger.md" ledger.write_text( From c1e1f86228d5f5ec2affb82a689979a5176aa1dc Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:56:25 -0500 Subject: [PATCH 10/56] docs: authorize straightforward Gate 13 retry --- CHANGELOG.md | 5 + docs/RELEASE_READINESS.md | 30 ++- docs/REVIVAL.md | 14 +- .../gate13-20260831-b-cost-authorization.json | 224 ++++++++++++++++++ .../owner-budget-authorization-20260831.json | 39 +++ 5 files changed, 297 insertions(+), 15 deletions(-) create mode 100644 docs/evidence/gate13-20260831-b-cost-authorization.json create mode 100644 docs/evidence/owner-budget-authorization-20260831.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 4edf5ec6c..3095c3a6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and qualification evidence remains in `docs/REVIVAL_TEST_RESULTS.md`. ### Added +- The owner raised the current combined GCP/Fly public-alpha accounting epoch to USD 500 on + 2026-08-31. The existing USD 52 committed maximum remains charged. Fresh run + `gate13-20260831-b` now reserves USD 56 for a 16-hour route and sequential 6-hour Windows/Linux + clients, leaving USD 392; no resource has been created. Per-run preflight, hard deadlines, + exact cleanup, protected-resource, and evidence requirements are unchanged. - Gate 13 paid qualification now has durable source-bound native host jobs: an exact-current-user Windows Scheduled Task and a non-root transient Linux systemd service persist one attempt across operator disconnects, bound output, terminate the complete process tree on timeout or overflow, diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md index c3b99397d..3dfda9db1 100644 --- a/docs/RELEASE_READINESS.md +++ b/docs/RELEASE_READINESS.md @@ -52,16 +52,21 @@ proved both exact production archives downloaded on the intended clients, but ne advanced both clients before route acceptance, and consumed both hosts through lifecycle or diagnostic product launches. They are not reusable as fresh acceptance hosts. Exact cleanup now proves the route, both clients, all three disks, and both firewall rules absent; -the protected bootstrap remains running. The USD 52 maximum remains committed in the current -USD 100 epoch, leaving USD 48 without an explicit cleanup-backed owner reset. Source +the protected bootstrap remains running. The USD 52 maximum remains committed. The +[owner's 2026-08-31 authorization](evidence/owner-budget-authorization-20260831.json) raises the +current combined epoch ceiling to USD 500 without releasing that maximum, leaving USD 448 before +a new reservation. Source `0e16ac2` (built on native-host-job slice `2fe0de9`) now completes the persisted fail-closed run-state and durable host-execution prerequisite. Exact-current-user Windows Scheduled Task and non-root Linux systemd jobs survive operator disconnects, permit one attempt, bound output and the complete process tree, validate exact source/config/native-supervisor bindings, and revalidate canonical evidence before collection. The [software-prerequisite evidence](evidence/gate13-20260831-b-durable-host-job-prerequisite.json) binds the pushed source and independently reproduced 217-test Gate 13/desktop regression. It -created no cloud resources and authorizes no paid run. No later mandatory gate is unblocked until -a fresh source-bound run produces both complete 16-phase records and exact cleanup. +created no cloud resources and authorizes no paid run. [Run `gate13-20260831-b`](evidence/gate13-20260831-b-cost-authorization.json) +now reserves a separate USD 56 maximum at exact controller source `ee0c05f` and exact plan +`sha256:3f3f921ded6eed1729aff175f5c91b4effe1966a31c82bdbe41ed69075442d64`, leaving USD 392. +No resource has been created. No later mandatory gate is unblocked until this fresh source-bound +run produces both complete 16-phase records and exact cleanup. Do not work on the post-alpha items in the deferred table while an alpha gate can progress. Missing Docker, snapshots, local GPU hardware, or local host capacity is not an external @@ -93,7 +98,7 @@ longer consume the new authorization; later billing should still be recorded for | 10 | Implement automatic contributor model and block placement | PASSED | Signed bootstrap now installs one bounded `auto` worker. The local planner filters exact manifested candidates through owner policy and local resource ceilings, requires fresh authenticated replica coverage, targets the least-covered contiguous range with per-node jitter, reconciles exact-manifest launches through the existing artifact-verifying server and `WorkerSupervisor`, applies residency/cooldown/switch hysteresis, exposes placement reasons, and preserves an explicit operator pause across ineligibility or placement changes. A new or migrated worker must sign an expiring exact-manifest/range intent with fixed numeric resource claims and receive a remote DHT store acknowledgement (`exclude_self=True`) before entering the artifact path; invalid, rejected, or failed publication is fail-closed and cannot advance planner state, while a previously admitted placement is retained. Actual completed local generations feed exact-manifest demand, useful-throughput, and reliability through two bounded five-minute aggregate windows; no prompt, output, token ID, key, request ID, address, path, error, or per-request event is retained. Only a closed window with at least four completed routes may be signed by the separate router identity and published under the manifest-bound `demand-v1` DHT key with a 90-second lifetime and `exclude_self=True`. Consumers verify signature, exact schema/digest, lifetime, revocation, and replay ordering. The threshold-signed catalog may authorize 2–32 sorted RSA observer roots; missing or empty roots disable remote demand. Discovery discards unlisted identities before signature/replay work, excludes local and duplicate roots, isolates malformed records, requires two authorized roots, and medians at most 32 quantized observations. Observer keys are never generated or bundled: only a separately provisioned `route-demand.key` matching a signed root may publish, while ordinary nodes can consume without one. Any hot-edited root-list mismatch disables both publication and consumption until restart. Local utility is capped at 6 points and signed remote utility at 2, keeping the combined hint below the 10-point migration margin and 100-point replica step. Verified announcement and route-demand replay watermarks now survive restarts in one Windows-safe journal per raw manifest digest under the node data directory. Each strict journal is capped at 256 active identity scopes and 256 KiB, retains only public record kind, key ID, ordering tuple, record digest, and the bounded replay deadline, and is fsync-written through atomic replacement; malformed, duplicate, oversized, symlinked, non-regular, or unwritable state fails closed. The retained deadline prevents an older still-live record from returning after a short-lived newer record expires. The replay slice's 99-test focused protocol/discovery/planner/node-configuration matrix and 209-pass, 2-skip catalog/node/API superset pass. The Sybil slice's 122-test focused catalog/bootstrap/config/discovery matrix proves that 30 valid attacker keys plus one authorized root cannot reach threshold, two authorized roots aggregate without attacker weight, one high authorized vote cannot inflate a lower second vote, old catalogs remain signature-verifiable with remote demand disabled, and trust-epoch reload mismatches fail closed. A 190-pass, 1-skip catalog/protocol/planner/discovery/node/API superset also passes. Independent verification passed 146 focused tests and a 255-pass, 2-skip broader node/API superset, plus a native-Windows publication-boundary probe; formatting, import-order, import-smoke, and diff checks pass. The [explicit privacy review](AUTOMATIC_PLACEMENT_PRIVACY_V1.md) inventories collection, retention, public-key linkability, DHT/journal/API/log exposure, secure-deletion limits, and residual governance/host risks. Three executable privacy-contract tests fix the aggregate, intent, demand, replay, forbidden-field, and path-free warning schemas; the focused privacy/protocol/planner/discovery/node matrix passes 108 tests and the broader catalog/node/API matrix passes 258 tests with 2 skips. Independent privacy review passed 108 tests with 1 skip and a 225-pass, 2-skip broader subset; every caught observer-key exception and an unauthorized key produced no path, key ID, or exception detail, while prompt and identity-path schema injections failed closed. The [deterministic convergence and load acceptance](AUTOMATIC_PLACEMENT_ACCEPTANCE_V1.md) closes the remaining software gate: equal snapshots use node-specific 32-point model dispersion and range rendezvous ranks; a fixed 512-node cold cohort selects both models and every range below the 85% concentration boundary; two 4,096-node fresh-arrival cohorts remain below that boundary under maximum priority-aligned or standby demand; maximum demand causes zero incumbent migrations; one-replica loss migrates after residency without early reversal; rolling arrivals keep every model/block populated and repair an abrupt block loss. The alpha fails closed above 32 candidates or 512 blocks, permits one `auto` worker, clamps reconciliation to at least one second, and scans each candidate in one bounded pass. The focused planner/convergence/configuration matrix passes 78 tests and the broader catalog/protocol/discovery/node/API matrix passes 214 with 2 skips. A real Windows DHT round trip exposed and fixed a durable-replay multiprocessing regression: replay guards now omit/recreate their thread lock across serialization and reload persistent state; its 15-test protocol/network matrix passes. Independent verification reproduced the 78-test focus, passed an expanded 235-test matrix with 2 skips and the 15-test real-DHT probe, and exercised adversarial score, timing, 32-by-512 load, 1,000-case range-equivalence, and persistent replay-reload boundaries. This slice used no cloud resources and spent USD 0. | Gates 9–11 are passed. Gates 13–14 must now prove the packaged flow and real hardware ceilings using the published envelopes. | | 11 | Operate initial public alpha routes | PASSED | [Product-node run `route-20260830-j`](evidence/gate11node-20260830-a-lifecycle.json) installed the generic CommunityAI wheel on a bounded G2/L4 VM, verified the signed catalog, downloaded both exact manifested models directly from Hugging Face into one persistent shared cache, and used the product node's automatic workers to expose complete Qwen 24/24 primary and Gemma 35/35 standby routes. No model-specific image, cache mirror, or operator-transferred model artifact was used. The privacy-safe acceptance passed one-token primary inference, deliberate primary pause, automatic Gemma selection in 58.073 seconds, standby inference, Qwen restoration in 32.042 seconds, and restored inference. Both workers were stable before the drill. After Gate 13 released the L4, the preserved route was restored without changing its model cache or source, its ephemeral endpoint was rebound, both product-node services became active, and a fresh acceptance reproved Qwen 24/24 primary inference, automatic Gemma 35/35 fallback/inference, Qwen restoration, and restored inference. The protected bootstrap remains running. A corrected 4,800-second provider DELETE backstop was set for `2026-08-31T05:28:16.516Z`, earlier than the original deadline. [Post-backstop cleanup evidence](evidence/gate11route-20260830-j-backstop-cleanup.json) and an independent recheck prove the route instance, named disk, and both exact run-scoped firewall rules absent, all Gate 13 clients/disks absent, zero remaining route availability, and the protected bootstrap still running. The same-host standby is a bounded alpha fallback, not independent infrastructure redundancy; independent redundancy remains post-alpha. | Gate 11 acceptance evidence remains complete, but no product route is live after the corrected DELETE backstop. [Gate 13 run `gate13-20260831-a`](evidence/gate13-20260831-a-cost-authorization.json) now binds refreshed native authentication, fail-closed preflight, and a fresh USD 52 reservation for the replacement route and packaged clients. | | 12 | Create, publish, and bundle the minimal signed alpha catalog/bootstrap | PASSED | [Run `gate12-20260829-a`](evidence/gate12-20260829-alpha-catalog-publication.json) published the deterministic [`communityai-public-alpha-v1` bundle](../public-alpha/catalog-v1/bundle.json) from source `26be579`. Its threshold-one Ed25519 root signs sequence 1 with the exact qualified Qwen primary and Gemma standby manifests, one pinned public HTTPS mirror, one public seed, a one-route best-effort policy, and no unprovisioned route-demand roots. The canonical bundle binds five members and retains `complete_release_qualification=false`. All three public objects returned HTTP 200 with exact sizes, and a fresh empty consumer fetched them remotely, verified the signature/digests, and created the two-model `auto` node configuration. The private signing key remained ignored and uncommitted. The focused publication suite passes 32 tests, the catalog/bootstrap/model/desktop superset passes 92, and the run spent USD 0. | Preserve the branch-scoped mirror until a newly signed catalog sequence and packaged bootstrap migrate it. The Gate 11 acceptance and Gate 9 envelopes exist; [Gate 13 run `gate13-20260831-a`](evidence/gate13-20260831-a-cost-authorization.json) now authorizes the bounded replacement route and fresh packaged clients under the new epoch. Independent threshold holders and interchangeable mirror/seed governance are post-alpha. | -| 13 | Pass packaged clean-install inference on Windows and Linux | IN PROGRESS | [Prerequisite run `gate13-20260830-a-prerequisites`](evidence/gate13-20260830-a-prerequisites.json) established deterministic install archives, exact first-use bytes, strict provenance, and the canonical lifecycle contract. [Native-harness and production-package run `gate13-20260830-b`](evidence/gate13-20260830-b-native-harness-and-packages.json) now completes the native Windows Credential Manager/Job Object and Linux Secret Service/systemd-cgroup 16-phase adapters, exact worker and descendant cleanup proofs, 3,600-second acquisition bounds, and package/runtime/catalog cross-binding. Independent software review passed 134 focused tests plus a 113-pass broader matrix with 3 platform skips; the production-discovery correction passes 73 unittests, 4 pytest checks, self-test, formatting, and import checks. [Exact-source production run 33338872342](https://github.com/flujo-app/CommunityAI/actions/runs/33338872342) passed both jobs at source `1971f10` and published independently audited CUDA 12.4 archives: Windows `sha256:45e9cdb439bcb8a6d7ed67914a490f3bc2e12ea1981af944034d62d865f5adc6` (2,695,065,068 bytes) and Linux `sha256:f96d3ca651964380d4684855ab08682e8187b33386327ec3895cda25b43c2a00` (3,360,717,934 bytes). Pushed source `6787272` adds the fixed stdin-only artifact downloader and exact platform configs; its 42-test adversarial suite and independent race/special-member/live-wrapper audit pass. No cloud resource was created for these prerequisites. Real completed clean-host lifecycle evidence remains absent. [Run `gate13-20260831-a`](evidence/gate13-20260831-a-failed-attempt-and-cleanup.json) later verified both exact archives on fresh clients but produced neither canonical lifecycle record. Its state-free transient orchestration advanced both clients before route acceptance, and subsequent diagnostic/product launches consumed both hosts for fresh-host acceptance. Exact cleanup proves all three instances/disks and both firewalls absent while the protected bootstrap remains running. Source `ddfb7c6` adds a persisted authorization-bound state contract that enforces inventory-first reattachment, route acceptance, sequential Windows-then-Linux evidence, consumed-host failure semantics, deadline runway, and success only after both canonical records plus final absence. [Durable native-host-job prerequisite evidence](evidence/gate13-20260831-b-durable-host-job-prerequisite.json) binds pushed source `0e16ac2` and its base `2fe0de9`: exact-current-user Windows Scheduled Task and non-root Linux systemd jobs survive operator disconnects, permit exactly one attempt, bound stdout/stderr and complete-process-tree cleanup, exact-bind source/config/native inventory, and revalidate canonical evidence before collection. Local and independent nine-file matrices each pass 217 tests with 29 known warnings. No cloud resource was created and no lifecycle pass is claimed. | [Run `gate13-20260830-c` revision 13](evidence/gate13-20260830-c-cost-authorization.json) is stopped clean. The latest Windows host passed exact package audit, clean install, four desktop self-tests, and the packaged-node self-test, then failed before model acquisition because child stderr diagnostics contaminated strict JSON captured on stdout. [Attempt, cleanup, correction, and route-restoration evidence](evidence/gate13-20260830-c-windows-attempt-and-route-restore.json) proves zero cache bytes, no retained credential or product process, all four exact client instances/disks absent, the bootstrap running, and the temporary restored Qwen/Gemma product route. [Post-backstop cleanup evidence](evidence/gate11route-20260830-j-backstop-cleanup.json) now proves that route, its named disk, and both exact run-scoped firewall rules absent while every Gate 13 target remains absent and the protected bootstrap remains running. Pushed source `4818da3` separates captured stdout from a dedicated NUL stderr sink and passes 15 native tests plus independent high-volume, handle-leak, descendant, timeout, and Job Object probes, but it has not completed a paid clean-host lifecycle. The cleanup-backed 2026-08-31 owner reset releases the USD 98 historical maxima and opens a new USD 100 epoch for the next run; it does not authorize any particular resource or reuse the stopped record. A later native-auth recheck on 2026-08-31 obtained a real access token and passed Compute API access without exposing account or token material. [Run `gate13-20260831-a`](evidence/gate13-20260831-a-failed-attempt-and-cleanup.json) is failed and cleanup-proved; its USD 52 maximum remains committed, leaving USD 48 in the current epoch. Do not reuse its clients or authorization. The durable Windows Scheduled Task and Linux systemd host-job adapter prerequisite is complete at pushed source `0e16ac2`. Bind that exact source and a cheaper plan that fits USD 48—or obtain an explicit cleanup-backed owner reset—before a fresh create. The next run must accept the complete route first, run/collect/delete Windows Qwen, then run/collect/delete Linux Gemma, and finally prove exact route cleanup. | +| 13 | Pass packaged clean-install inference on Windows and Linux | IN PROGRESS | [Prerequisite run `gate13-20260830-a-prerequisites`](evidence/gate13-20260830-a-prerequisites.json) established deterministic install archives, exact first-use bytes, strict provenance, and the canonical lifecycle contract. [Native-harness and production-package run `gate13-20260830-b`](evidence/gate13-20260830-b-native-harness-and-packages.json) now completes the native Windows Credential Manager/Job Object and Linux Secret Service/systemd-cgroup 16-phase adapters, exact worker and descendant cleanup proofs, 3,600-second acquisition bounds, and package/runtime/catalog cross-binding. Independent software review passed 134 focused tests plus a 113-pass broader matrix with 3 platform skips; the production-discovery correction passes 73 unittests, 4 pytest checks, self-test, formatting, and import checks. [Exact-source production run 33338872342](https://github.com/flujo-app/CommunityAI/actions/runs/33338872342) passed both jobs at source `1971f10` and published independently audited CUDA 12.4 archives: Windows `sha256:45e9cdb439bcb8a6d7ed67914a490f3bc2e12ea1981af944034d62d865f5adc6` (2,695,065,068 bytes) and Linux `sha256:f96d3ca651964380d4684855ab08682e8187b33386327ec3895cda25b43c2a00` (3,360,717,934 bytes). Pushed source `6787272` adds the fixed stdin-only artifact downloader and exact platform configs; its 42-test adversarial suite and independent race/special-member/live-wrapper audit pass. No cloud resource was created for these prerequisites. Real completed clean-host lifecycle evidence remains absent. [Run `gate13-20260831-a`](evidence/gate13-20260831-a-failed-attempt-and-cleanup.json) later verified both exact archives on fresh clients but produced neither canonical lifecycle record. Its state-free transient orchestration advanced both clients before route acceptance, and subsequent diagnostic/product launches consumed both hosts for fresh-host acceptance. Exact cleanup proves all three instances/disks and both firewalls absent while the protected bootstrap remains running. Source `ddfb7c6` adds a persisted authorization-bound state contract that enforces inventory-first reattachment, route acceptance, sequential Windows-then-Linux evidence, consumed-host failure semantics, deadline runway, and success only after both canonical records plus final absence. [Durable native-host-job prerequisite evidence](evidence/gate13-20260831-b-durable-host-job-prerequisite.json) binds pushed source `0e16ac2` and its base `2fe0de9`: exact-current-user Windows Scheduled Task and non-root Linux systemd jobs survive operator disconnects, permit exactly one attempt, bound stdout/stderr and complete-process-tree cleanup, exact-bind source/config/native inventory, and revalidate canonical evidence before collection. Local and independent nine-file matrices each pass 217 tests with 29 known warnings. No cloud resource was created and no lifecycle pass is claimed. | [Run `gate13-20260830-c` revision 13](evidence/gate13-20260830-c-cost-authorization.json) is stopped clean. The latest Windows host passed exact package audit, clean install, four desktop self-tests, and the packaged-node self-test, then failed before model acquisition because child stderr diagnostics contaminated strict JSON captured on stdout. [Attempt, cleanup, correction, and route-restoration evidence](evidence/gate13-20260830-c-windows-attempt-and-route-restore.json) proves zero cache bytes, no retained credential or product process, all four exact client instances/disks absent, the bootstrap running, and the temporary restored Qwen/Gemma product route. [Post-backstop cleanup evidence](evidence/gate11route-20260830-j-backstop-cleanup.json) now proves that route, its named disk, and both exact run-scoped firewall rules absent while every Gate 13 target remains absent and the protected bootstrap remains running. Pushed source `4818da3` separates captured stdout from a dedicated NUL stderr sink and passes 15 native tests plus independent high-volume, handle-leak, descendant, timeout, and Job Object probes, but it has not completed a paid clean-host lifecycle. The cleanup-backed 2026-08-31 owner reset releases the USD 98 historical maxima and opens a new USD 100 epoch for the next run; it does not authorize any particular resource or reuse the stopped record. A later native-auth recheck on 2026-08-31 obtained a real access token and passed Compute API access without exposing account or token material. [Run `gate13-20260831-a`](evidence/gate13-20260831-a-failed-attempt-and-cleanup.json) is failed and cleanup-proved; its USD 52 maximum remains committed. The owner's 2026-08-31 ceiling increase leaves USD 448 before a new reservation. Do not reuse its clients or authorization. The durable Windows Scheduled Task and Linux systemd host-job adapter prerequisite is complete at pushed source `0e16ac2`. Run `gate13-20260831-b` is source-bound and reserved at USD 56. Revalidate native auth and exact absence, then execute only the straightforward route-first, Windows collect/delete, Linux collect/delete, route-delete sequence. The next run must accept the complete route first, run/collect/delete Windows Qwen, then run/collect/delete Linux Gemma, and finally prove exact route cleanup. | | 14 | Pass automatic-contribution and resource-control hardware checks | WAITING | [PR #11](https://github.com/flujo-app/CommunityAI/pull/11) and [PR #12](https://github.com/flujo-app/CommunityAI/pull/12) implemented the authenticated node-authoritative Sharing UI and atomic policy editing, but cross-model automatic placement and real packaged hardware evidence are absent. | After Gates 9–13, follow the [recovery runbook](RECOVERY_TEST_RUNBOOK.md) once for the clean-install product flow while validating model/block choice, exact selected-shard bytes, shared-cache affinity, download authorization, VRAM/storage/bandwidth/power limits, suspension, pause timing, cleanup, restart persistence, and unsupported telemetry on real packaged Windows/Linux hardware. | | 15 | Complete minimal alpha release engineering | WAITING | The desktop builder now emits a stable sorted `SHA256SUMS` inventory of exact regular-file bytes and safe relative in-bundle file symlinks, source/build/catalog-bound `provenance.json`, and `release-metadata.json` with explicit unsigned public-alpha, no-publisher-signature, no-authenticated-update, Windows/Linux-only, no-credits, and incomplete-qualification claims. Structural verification binds each safe file symlink to its canonical in-bundle target, digest, and size while rejecting changed, missing, extra, absolute, external, broken, cyclic, directory-linked/junction, special, traversal, or case-colliding payloads plus unsupported or noncanonical metadata. Exact-source builds also reject dirty relevant inputs, and the expected-input fresh-process check rejects rewritten commit/tree, workflow, platform, Python, PyInstaller, or catalog evidence. Production desktop CI is configured to verify and bundle the Gate 12 inputs, bind the exact clean Git commit/tree and workflow, revalidate every expected input separately, and upload all evidence on Windows/Linux. The focused release-input/artifact suite passes 15 tests, including fresh-process CLI, dirty-source, and canonical-rewrite checks, and the broader catalog/bootstrap/model/desktop subset passes 134. Independent verification reproduced all 134, passed 58 desktop unittests with two environment skips, formatting/import-order/YAML/diff checks, an expected Gate 12/workflow fresh-process probe, and real Windows junction rejection; no cloud was used. [The first PR #22 production-desktop run](https://github.com/flujo-app/CommunityAI/actions/runs/33273518744) reached packaging on both hosts and exposed two exact cross-platform defects: PyInstaller's legitimate relative internal Qt file symlink on Ubuntu and CRLF-transformed signed Gate 12 JSON on Windows. The follow-up binds safe internal file symlinks without accepting external or directory links, forces `public-alpha/**` to LF at checkout, and includes `.gitattributes` in the clean-source boundary. [The second run](https://github.com/flujo-app/CommunityAI/actions/runs/33274432423) proved the Ubuntu package and the Windows signed-bundle/provenance path, then exposed a stale desktop contribution-status schema 2 contract when the packaged node emitted schema 3 automatic-placement evidence. Source `fcd1f41` now strictly validates schema 3 placement and rejects stale schema 2 plus missing, extra, secret-bearing, or inconsistent placement data; its 50-test node/client/lifecycle/build focus and all 59 desktop unittests passed with two environment skips. [The final run](https://github.com/flujo-app/CommunityAI/actions/runs/33275216332) bound exact source `fcd1f417d1435557addb2d6cded9dac0827c7d8c` and completed both Windows and Ubuntu package jobs, including bundle build/smoke, independent checksum/provenance verification, the Windows packaged-node/native-credential/public-seed smoke, and artifact uploads; every PR style, test, and package check is green. Source `36d85d2` makes generic release-artifact fixtures select the supported Linux archive explicitly instead of inheriting the CI host platform; the 21-test local artifact suite and [PR #22 test run 33372581439](https://github.com/flujo-app/CommunityAI/actions/runs/33372581439) pass, without expanding the supported platform matrix. Clean-install lifecycle evidence remains absent. | Retain the verified Windows/Linux artifacts as engineering evidence, then test clean install, manual upgrade/reinstall, uninstall, retained-data choice for the persistent verified model cache, and recovery instructions on both platforms against a newly authorized live product-node route and the published Gate 9 envelopes. Do not mark passed from metadata/unit tests alone. Publisher signing and automatic authenticated update/rollback are post-alpha. | | 16 | Complete the bounded public-alpha safety canary | WAITING | [PR #13](https://github.com/flujo-app/CommunityAI/pull/13) and [PR #14](https://github.com/flujo-app/CommunityAI/pull/14) implemented bounded admission, privacy-safe aggregate health, training-off defaults, rollback procedures, and bounded routine rejection logs; no public canary has run. | After Gates 11–15, run a small monitored canary proving finite admission/timeouts, malformed-peer rejection, health reconstruction, privacy disclosure, route/catalog disable, and clean rollback. Exhaustive hostile-load, Sybil/collusion, partition, and long-soak campaigns are post-alpha. | @@ -116,8 +121,10 @@ longer consume the new authorization; later billing should still be recorded for ## Cloud authorization and spend ledger Authorization applies only to CommunityAI qualification and public-alpha infrastructure. -The ceiling is USD 100 combined across new temporary GCP and Fly resources in the current -owner-authorized accounting epoch. The existing +The [owner-authorized ceiling](evidence/owner-budget-authorization-20260831.json) is USD 500 +combined across new temporary GCP and Fly resources in the current accounting epoch. The +existing USD 52 committed maximum remains charged. The USD 56 `gate13-20260831-b` +reservation brings the committed maximum to USD 108 and leaves USD 392 unreserved. The existing GCP bootstrap's ordinary baseline cost is tracked separately; never delete it as test cleanup. Before every paid run, add an entry with a conservative maximum. After cleanup, replace @@ -130,7 +137,8 @@ maximum still consumes the current epoch because no reset or observed charge has | Run | Provider | Purpose | Maximum estimate | Observed cost | Cleanup proof | State | | --- | --- | --- | ---: | ---: | --- | --- | -| gate13-20260831-a | GCP | Gate 13 replacement product-node route plus fresh CPU Windows/Linux packaged lifecycles at route source `f64a388a47b098ac7f69d2affc59816376b43bb1` and exact package source `1971f106cc5bf90724d938c986a719ce2744f3e7` [plan sha256:313f5d34eefd64c71e265bdb7044d8ef5f56550360a7e9a7104265434292fd69] | USD 52.00 | — | [Failed attempt and cleanup proof](evidence/gate13-20260831-a-failed-attempt-and-cleanup.json) records verified archive downloads but no completed lifecycle, the non-durable orchestration failure, consumed-client semantics, and exact absence of the route, both clients, all three disks, and both firewalls while the protected bootstrap remains running. The USD 52 maximum remains committed until an explicit cleanup-backed owner reset; USD 48 remains in this epoch. | CLEANED-COMMITTED | +| gate13-20260831-b | GCP | Gate 13 durable route-first lifecycle: one 16-hour G2/L4 product route, then sequential fresh 6-hour Windows/Qwen and Linux/Gemma CPU clients at controller source `ee0c05f83035ad22015fb089f5cb30f1713076e6`, host-job source `0e16ac2aa088e0719e9c3c89451176544602bab3`, route source `f64a388a47b098ac7f69d2affc59816376b43bb1`, package source `1971f106cc5bf90724d938c986a719ce2744f3e7`, and plan `sha256:3f3f921ded6eed1729aff175f5c91b4effe1966a31c82bdbe41ed69075442d64` | USD 56.00 | — | [Cost authorization](evidence/gate13-20260831-b-cost-authorization.json) binds exact images, packages, manifests, controller/host-job/helper bytes, unique resources, nonconcurrent clients, hard DELETE deadlines, complete cleanup, and zero Fly/image/mirror/credits/macOS operations. No resource has been created. | RESERVED | +| gate13-20260831-a | GCP | Gate 13 replacement product-node route plus fresh CPU Windows/Linux packaged lifecycles at route source `f64a388a47b098ac7f69d2affc59816376b43bb1` and exact package source `1971f106cc5bf90724d938c986a719ce2744f3e7` [plan sha256:313f5d34eefd64c71e265bdb7044d8ef5f56550360a7e9a7104265434292fd69] | USD 52.00 | — | [Failed attempt and cleanup proof](evidence/gate13-20260831-a-failed-attempt-and-cleanup.json) records verified archive downloads but no completed lifecycle, the non-durable orchestration failure, consumed-client semantics, and exact absence of the route, both clients, all three disks, and both firewalls while the protected bootstrap remains running. The USD 52 maximum remains committed; after the owner raised the epoch ceiling to USD 500, USD 448 remains before a new reservation. | CLEANED-COMMITTED | | gate13-20260830-c | GCP | Gate 13 sequential clean packaged Qwen Windows and Gemma Linux lifecycles at exact package source `1971f106cc5bf90724d938c986a719ce2744f3e7`, temporarily suspending and later restoring the Gate 11 route while reusing its sole global L4 allocation on uniquely named fresh Windows and Linux clients [plan sha256:427bc1ed8a6645ad0650d91aaba7aa753d398fa84f56d57b50aca04c4e0cc955] | USD 26.00 | — | [Cost authorization](evidence/gate13-20260830-c-cost-authorization.json) binds the passed production archives/audits, pushed download-helper/config identities, exact Actions wrapper/inner archives, exact Qwen/Gemma manifests, no service accounts/scopes, direct model transfer, native credential stores, whole-tree containment, all 16 phases, exact cleanup targets, and zero Fly/image/mirror/credits/macOS work. Revision 13 records the final Windows pre-acquisition failure, pushed correction `4818da3`, complete native cleanup, all four exact client instance/disk absences, and successful Gate 11 route restoration. [Privacy-safe final state](evidence/gate13-20260830-c-windows-attempt-and-route-restore.json) proves the package audit and install boundary, zero model-cache bytes, no retained credential/process/path/endpoint/provider output, protected-bootstrap health, active Qwen/Gemma route services, and fresh primary/fallback/restoration inference. The two required 16-phase lifecycles remain incomplete. Complete cleanup permits the explicit owner reset on 2026-08-31; its USD 26 maximum is historical and delayed billing remains informational. This record authorizes no later provisioning. | CLEANED-RELEASED | | gate9-20260830-e | GCP | Gate 9 concurrent Qwen/Gemma Windows/Linux acquisition records and schema-v3 envelopes at pushed source `ba410f74f1cf625f1e1c34734b53e4514fa7c5ec`, reusing the separately authorized product route and using bounded isolated clients [plan sha256:04ba77ee68f4a895ae080a4ddcbf6805b502da6a95a4146734acbddff92de307] | USD 46.00 | — | [Passed envelopes and cleanup](evidence/gate9-20260830-e-edge-resource-envelopes.json) publish all four exact acquisition/envelope records and prove complete client cleanup; [cost authorization](evidence/gate9-20260830-e-cost-authorization.json) binds the exact wheel or exact-commit source archive, signed catalog/bootstrap, Qwen/Gemma manifests, owner-authorized parallel platform/model execution, 60-minute model windows, 90-minute client deletion backstops, exact cleanup targets, protected resources, and zero Fly/image/mirror operations. Native provider authentication was refreshed before the USD 18 Windows-client expansion and again before the zero-ceiling-increase Gemma memory retry; the exact plan permits one cache-preserving in-place resize to `e2-standard-8`. Complete cleanup permits the explicit owner reset on 2026-08-31; its USD 46 maximum is historical and delayed billing remains informational. | CLEANED-RELEASED | | route-20260830-j | GCP | Gate 11 signed-catalog product node route [workload gcp-product-node-route] [source e1d715fd47c852fa12ca50c76e8f4c6a0831fd78] [final runtime source 4cef141746705c3ee8bc8e017693855e0bc4871e] [plan sha256:1a0927e9d83a9a409ac2ea0232c4fceb14821d3f2c5eb87def88b8e7cdcb07d8] | USD 26.00 | — | [Passed live lifecycle](evidence/gate11node-20260830-a-lifecycle.json): generic runtime, signed catalog, direct Hugging Face artifacts, shared persistent cache, complete primary/standby routes, primary/fallback/restoration inference, stable workers, no model image, and protected-bootstrap health. [Gate 13 restoration evidence](evidence/gate13-20260830-c-windows-attempt-and-route-restore.json) proves the route was restored, both product services became active, fresh Qwen/Gemma primary/fallback/restoration inference passed, and a corrected 4,800-second DELETE backstop ended no later than the original deadline. [Post-backstop cleanup](evidence/gate11route-20260830-j-backstop-cleanup.json) proves the route instance, named disk, and both exact run-scoped firewall rules absent, all Gate 13 clients/disks absent, zero GPU use, and the protected bootstrap running; acceptance evidence is preserved but no product route is live. Complete cleanup permits the explicit owner reset on 2026-08-31; its USD 26 maximum is historical and delayed billing remains informational. | CLEANED-RELEASED | @@ -208,7 +216,11 @@ After the Gate 9 clients, Gate 11 product route, and Gate 13 clients were all cl the owner explicitly authorized another cleanup-backed reset for the next run on 2026-08-31. Their USD 98 conservative maxima remain historical evidence but no longer consume the new epoch; delayed observed charges remain informational. The next run starts with a new combined -authorization of **USD 100**. Every paid create still requires fresh native authentication, +authorization of **USD 100**. Later on 2026-08-31, the owner raised that current combined +epoch to **USD 500** without releasing the already committed USD 52 maximum. The dated +[authorization record](evidence/owner-budget-authorization-20260831.json) therefore leaves +USD 448 before a new reservation. The USD 56 `gate13-20260831-b` reservation now leaves +USD 392. Every paid create still requires fresh native authentication, an exact source-bound cost authorization, a conservative ledger reservation, and the existing fail-closed preflight and cleanup controls. diff --git a/docs/REVIVAL.md b/docs/REVIVAL.md index d84d9dc1c..62be0a65a 100644 --- a/docs/REVIVAL.md +++ b/docs/REVIVAL.md @@ -60,10 +60,12 @@ agent: directly, then attempt roughly 70B if that passes. This is permission to test those sizes, not permission to claim that an exact larger checkpoint works before its own model-specific evidence passes. -- New temporary GCP and Fly test resources share one combined **USD 100 maximum**. - Track conservative estimates and observed cost in - [`RELEASE_READINESS.md`](RELEASE_READINESS.md). Do not start a run that could exceed - the remaining balance. +- New temporary GCP and Fly test resources share one live owner-authorized combined + ceiling. The baseline is USD 100; on 2026-08-31 the owner raised the current accounting + epoch to **USD 500 maximum**. The already committed USD 52 maximum remains charged to + that epoch, leaving USD 448 before a new reservation. Track conservative estimates and + observed cost in [`RELEASE_READINESS.md`](RELEASE_READINESS.md). Do not start a run that + could exceed the remaining balance. - Use the existing `gcloud`, `flyctl`, and `gh` logins. Do not require the owner to copy provider tokens into environment variables when native CLI authentication works. - On Windows, every registry token, remote credential, and Linux script must follow the @@ -227,7 +229,7 @@ fresh-host records and final provider absence proof exist. ### Cloud safety rules - Before provisioning, record a conservative maximum estimate in the spend ledger and - confirm it fits under the combined USD 100 ceiling. + confirm it fits under the live combined ceiling recorded in the readiness tracker. - An explicit owner budget reset starts a new USD 100 accounting epoch only after every prior run is cleanup-proved. Preserve those historical rows as `CLEANED-RELEASED` rather than pretending their actual cost was zero; their maxima no longer consume the new epoch, @@ -249,7 +251,7 @@ Do not block on these while another roadmap item can proceed. Ask the owner only input is on the critical path: - a provider login expires and native CLI reauthentication is required; -- the next bounded cloud run does not fit under the remaining USD 100 ceiling; +- the next bounded cloud run does not fit under the remaining live owner-authorized ceiling; - platform code-signing/notarization credentials or a publisher identity are required; - production catalog signing needs independent human key holders; - an independent seed or mirror operator must accept operational responsibility; or diff --git a/docs/evidence/gate13-20260831-b-cost-authorization.json b/docs/evidence/gate13-20260831-b-cost-authorization.json new file mode 100644 index 000000000..73b6f64ce --- /dev/null +++ b/docs/evidence/gate13-20260831-b-cost-authorization.json @@ -0,0 +1,224 @@ +{ + "schema_version": 1, + "scope": "gate13-packaged-lifecycle-cost-authorization", + "run_id": "gate13-20260831-b", + "gate": 13, + "result": "authorized", + "recorded_at": "2026-08-31", + "source": { + "reservation_commit": "ee0c05f83035ad22015fb089f5cb30f1713076e6", + "durable_controller_commit": "ee0c05f83035ad22015fb089f5cb30f1713076e6", + "host_job_adapter_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "route_runtime_commit": "f64a388a47b098ac7f69d2affc59816376b43bb1", + "package_commit": "1971f106cc5bf90724d938c986a719ce2744f3e7", + "windows_lifecycle_helper_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "linux_lifecycle_helper_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3" + }, + "authorization": { + "combined_cloud_ceiling_usd": "500.00", + "ledger_committed_before_run_usd": "52.00", + "maximum_estimate_usd": "56.00", + "route_maximum_estimate_usd": "30.00", + "clients_maximum_estimate_usd": "26.00", + "remaining_after_run_maximum_usd": "392.00", + "reservation_recorded": true, + "provisioning_authorized_after_fail_closed_preflight": true, + "provider_calls_authorized_without_preflight": false, + "pricing_as_of": "2026-08-31", + "pricing_basis": "same-day accepted Gate 13 ceilings scaled conservatively by hard duration: the 16-hour G2/L4 route is rounded up to USD 30 from the prior 14-hour USD 26 ceiling, and the two sequential 6-hour e2-standard-8 CPU clients retain the prior USD 26 ceiling" + }, + "immutable_inputs": { + "durable_controller": { + "path": "scripts/gate13_run_controller.py", + "source_commit": "ee0c05f83035ad22015fb089f5cb30f1713076e6", + "sha256": "3a33f1c0782222d400296c944651ca80a58fb67b4df79ce6a7d9c7216fd23b84", + "bytes": 34143 + }, + "host_job_adapter": { + "path": "scripts/gate13_host_job.py", + "source_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "sha256": "44b26b0f9828121fed9eef65d830bc9c02760fb8f8ddbb31ed0f1491c1b5d9d4", + "bytes": 41294 + }, + "lifecycle_evidence_validator": { + "path": "scripts/gate13_packaged_lifecycle.py", + "source_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "sha256": "36e04fbb599dce57e2a7d9d105511e731ad0ceafaaf0b0820de7022711066157", + "bytes": 27268 + }, + "route_runtime_wheel": { + "filename": "drift-2.3.0.dev2-py3-none-any.whl", + "source_commit": "f64a388a47b098ac7f69d2affc59816376b43bb1", + "sha256": "2a4f30bad7ae897fed019bc7da330a09965adb35685d11abaeaebf7a1d40aa60", + "bytes": 389107, + "model_artifacts_embedded": false + }, + "windows_package": { + "sha256": "45e9cdb439bcb8a6d7ed67914a490f3bc2e12ea1981af944034d62d865f5adc6", + "bytes": 2695065068 + }, + "linux_package": { + "sha256": "f96d3ca651964380d4684855ab08682e8187b33386327ec3895cda25b43c2a00", + "bytes": 3360717934 + }, + "qwen_manifest": "sha256:3ba8528cb3c0d85e1ed048e0438a0d64cfbbc298944ed674caa6950d415f8e33", + "gemma_manifest": "sha256:2f8debbe0fcdf5af8d4c56c982210fa50aa584314968ae2617e2ccc2de9eafdd", + "windows_helper": { + "path": "scripts/gate13_windows_packaged_lifecycle.ps1", + "source_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "sha256": "9b860ff820851b31ac272ee4d28dd3a899f56014b8dfce45e912ba4fc9ab605c", + "bytes": 132097 + }, + "linux_helper": { + "path": "scripts/gate13_linux_packaged_lifecycle.py", + "source_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "sha256": "90f3af65bb4f77317f707a6b52e329e1d5f81cdeddcb9615a210ec9a5a4cf535", + "bytes": 113748 + }, + "windows_download_config": { + "path": "scripts/gate13_download_windows.json", + "sha256": "bf36b66bf22a8f4453ba481c5a4ad37d8e1856ad93ef25e5e105b566b39affc4", + "bytes": 293 + }, + "linux_download_config": { + "path": "scripts/gate13_download_linux.json", + "sha256": "c3e9dcd94ef0a8e61c95e650416a8c6ca8b169f4b6699a0acd06608e60da5550", + "bytes": 294 + } + }, + "provider_plan_digest": "sha256:3f3f921ded6eed1729aff175f5c91b4effe1966a31c82bdbe41ed69075442d64", + "provider_plan": { + "project": "community-ai-506321", + "route": { + "instance": "route-20260831-b-node", + "zone": "us-central1-a", + "machine_type": "g2-standard-8", + "accelerator": "1 x NVIDIA L4", + "image": "deeplearning-platform-release/common-cu129-ubuntu-2404-nvidia-580-v20260819", + "boot_disk_gib": 200, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 57600, + "termination_action": "DELETE", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "public_ports": [ + 31337, + 31338 + ], + "firewalls": [ + "route-20260831-b-dht", + "route-20260831-b-iap" + ], + "service_account": false, + "scopes": [], + "operator_access_target_tags": [ + "route-20260831-b-node", + "gate13-20260831-b-client" + ] + }, + "clients": [ + { + "platform": "windows", + "model": "Qwen3.5 2B", + "instance": "gate13-20260831-b-win", + "zone": "us-central1-a", + "machine_type": "e2-standard-8", + "image": "windows-server-2025-dc-v20260814", + "image_project": "windows-cloud", + "boot_disk_gib": 120, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 21600, + "termination_action": "DELETE", + "lifecycle_run_id": "gate13-20260831-b-windows", + "device_profile": "cpu", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "network_tag": "gate13-20260831-b-client" + }, + { + "platform": "linux", + "model": "Gemma 4 E2B IT", + "instance": "gate13-20260831-b-linux", + "zone": "us-central1-a", + "machine_type": "e2-standard-8", + "image": "ubuntu-2404-noble-amd64-v20260826", + "image_project": "ubuntu-os-cloud", + "boot_disk_gib": 120, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 21600, + "termination_action": "DELETE", + "lifecycle_run_id": "gate13-20260831-b-linux", + "device_profile": "cpu", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "network_tag": "gate13-20260831-b-client" + } + ], + "sequencing": { + "route_live_for_both_lifecycles": true, + "clients_may_run_concurrently": false, + "route_accepted_before_any_client_create": true, + "windows_collected_and_deleted_before_linux_create": true, + "all_16_phases_required_per_platform": true, + "exact_cleanup_before_pass": true + } + }, + "preflight": { + "read_only_checked_at": "2026-08-31T20:15:48Z", + "native_gcloud_token_refresh": true, + "compute_api_access": true, + "exact_target_instances_absent": true, + "exact_target_disks_absent": true, + "exact_target_firewalls_absent": true, + "machine_types_available": true, + "images_available": true, + "one_l4_quota_free": true, + "protected_bootstrap_running": true, + "final_software_tests_passed": 219, + "independent_review_matrix_before_final_reservation_guard": 218, + "independent_reservation_guard_finding_resolved": true, + "fresh_native_revalidation_immediately_before_create_required": true + }, + "cleanup": { + "delete_only_exact_run_resources": true, + "verify_absent": [ + "route-20260831-b-node", + "route-20260831-b-node boot disk", + "route-20260831-b-dht", + "route-20260831-b-iap", + "gate13-20260831-b-win", + "gate13-20260831-b-win boot disk", + "gate13-20260831-b-linux", + "gate13-20260831-b-linux boot disk" + ], + "protected_resources": [ + "communityai-bootstrap-1", + "all resources not named by this plan" + ], + "cleanup_required_on_success_or_failure": true + }, + "prohibited": { + "fly_operations": 0, + "model_specific_images_or_mirrors": 0, + "macos_work": 0, + "credits_or_payments_work": 0 + }, + "privacy": { + "credentials_retained": false, + "prompts_retained": false, + "outputs_retained": false, + "endpoints_retained": false, + "provider_output_retained": false, + "private_paths_retained": false + }, + "plan_revision": { + "revision": 1, + "previous_provider_plan_digest": null, + "reason": "fresh durable route-first, sequential-client run under the owner-raised USD 500 epoch ceiling; no failed-run resource or authorization is reused", + "resource_set_changed": true, + "cost_ceiling_changed": true + } +} diff --git a/docs/evidence/owner-budget-authorization-20260831.json b/docs/evidence/owner-budget-authorization-20260831.json new file mode 100644 index 000000000..5bccd5661 --- /dev/null +++ b/docs/evidence/owner-budget-authorization-20260831.json @@ -0,0 +1,39 @@ +{ + "schema_version": 1, + "scope": "combined-cloud-budget-epoch-authorization", + "recorded_at": "2026-08-31", + "timezone": "America/Bogota", + "result": "authorized", + "owner_decision": { + "combined_cloud_ceiling_usd": "500.00", + "authorized_on": "2026-08-31", + "providers": [ + "GCP", + "Fly" + ], + "purpose": "CommunityAI public inference alpha critical-path infrastructure and qualification" + }, + "epoch_state_at_authorization": { + "prior_ceiling_usd": "100.00", + "committed_maximum_usd": "52.00", + "unreserved_maximum_usd": "448.00", + "committed_run": "gate13-20260831-a" + }, + "controls_unchanged": { + "exact_source_bound_authorization_per_paid_run": true, + "fresh_native_auth_and_fail_closed_preflight_before_create": true, + "exact_run_scoped_resources_only": true, + "cleanup_and_absence_proof_required": true, + "protected_bootstrap_must_remain": "communityai-bootstrap-1", + "observed_cost_tracking_required": true + }, + "prohibited": { + "credits_or_payments_work": true, + "macos_work": true + }, + "claims": { + "specific_provider_run_authorized_by_this_record": false, + "budget_is_observed_spend": false, + "prior_committed_maximum_released": false + } +} From 0dc2345f7c0880bbb63b1d8951187e4e4b744a84 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:08:35 -0500 Subject: [PATCH 11/56] docs: reserve Gate 13 retry after clean pre-VM failure --- CHANGELOG.md | 9 +- docs/RELEASE_READINESS.md | 26 +- ...3-20260831-b-failed-start-and-cleanup.json | 62 +++++ .../gate13-20260831-c-cost-authorization.json | 224 ++++++++++++++++++ 4 files changed, 306 insertions(+), 15 deletions(-) create mode 100644 docs/evidence/gate13-20260831-b-failed-start-and-cleanup.json create mode 100644 docs/evidence/gate13-20260831-c-cost-authorization.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 3095c3a6d..91aa5b4d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,11 @@ and qualification evidence remains in `docs/REVIVAL_TEST_RESULTS.md`. ### Added - The owner raised the current combined GCP/Fly public-alpha accounting epoch to USD 500 on - 2026-08-31. The existing USD 52 committed maximum remains charged. Fresh run - `gate13-20260831-b` now reserves USD 56 for a 16-hour route and sequential 6-hour Windows/Linux - clients, leaving USD 392; no resource has been created. Per-run preflight, hard deadlines, - exact cleanup, protected-resource, and evidence requirements are unchanged. + 2026-08-31. The existing USD 52 committed maximum remains charged. Run + `gate13-20260831-b` reserved USD 56 but failed before VM creation when its two IAP tags reached + gcloud as one value; exact cleanup passed. Fresh run `gate13-20260831-c` reserves USD 56 with + corrected explicit tag arguments, leaving USD 336. Per-run preflight, hard deadlines, exact + cleanup, protected-resource, and evidence requirements are unchanged. - Gate 13 paid qualification now has durable source-bound native host jobs: an exact-current-user Windows Scheduled Task and a non-root transient Linux systemd service persist one attempt across operator disconnects, bound output, terminate the complete process tree on timeout or overflow, diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md index 3dfda9db1..ea8ea46f6 100644 --- a/docs/RELEASE_READINESS.md +++ b/docs/RELEASE_READINESS.md @@ -62,11 +62,13 @@ non-root Linux systemd jobs survive operator disconnects, permit one attempt, bo complete process tree, validate exact source/config/native-supervisor bindings, and revalidate canonical evidence before collection. The [software-prerequisite evidence](evidence/gate13-20260831-b-durable-host-job-prerequisite.json) binds the pushed source and independently reproduced 217-test Gate 13/desktop regression. It -created no cloud resources and authorizes no paid run. [Run `gate13-20260831-b`](evidence/gate13-20260831-b-cost-authorization.json) -now reserves a separate USD 56 maximum at exact controller source `ee0c05f` and exact plan -`sha256:3f3f921ded6eed1729aff175f5c91b4effe1966a31c82bdbe41ed69075442d64`, leaving USD 392. -No resource has been created. No later mandatory gate is unblocked until this fresh source-bound -run produces both complete 16-phase records and exact cleanup. +created no cloud resources and authorizes no paid run. [Run `gate13-20260831-b`](evidence/gate13-20260831-b-failed-start-and-cleanup.json) +failed before VM creation when the operator shell collapsed two IAP target tags into one value; +its exact firewall cleanup and every instance/disk/firewall absence passed. Its USD 56 maximum +remains committed. Fresh [run `gate13-20260831-c`](evidence/gate13-20260831-c-cost-authorization.json) +reserves another USD 56 at plan `sha256:07b6cd399ef7a9733602dfc19a741feddec8d15e5f4b5bac7347a192675f6d9c`, +leaving USD 336; no run-C resource has been created. No later mandatory gate is unblocked until +the fresh run produces both complete 16-phase records and exact cleanup. Do not work on the post-alpha items in the deferred table while an alpha gate can progress. Missing Docker, snapshots, local GPU hardware, or local host capacity is not an external @@ -98,7 +100,7 @@ longer consume the new authorization; later billing should still be recorded for | 10 | Implement automatic contributor model and block placement | PASSED | Signed bootstrap now installs one bounded `auto` worker. The local planner filters exact manifested candidates through owner policy and local resource ceilings, requires fresh authenticated replica coverage, targets the least-covered contiguous range with per-node jitter, reconciles exact-manifest launches through the existing artifact-verifying server and `WorkerSupervisor`, applies residency/cooldown/switch hysteresis, exposes placement reasons, and preserves an explicit operator pause across ineligibility or placement changes. A new or migrated worker must sign an expiring exact-manifest/range intent with fixed numeric resource claims and receive a remote DHT store acknowledgement (`exclude_self=True`) before entering the artifact path; invalid, rejected, or failed publication is fail-closed and cannot advance planner state, while a previously admitted placement is retained. Actual completed local generations feed exact-manifest demand, useful-throughput, and reliability through two bounded five-minute aggregate windows; no prompt, output, token ID, key, request ID, address, path, error, or per-request event is retained. Only a closed window with at least four completed routes may be signed by the separate router identity and published under the manifest-bound `demand-v1` DHT key with a 90-second lifetime and `exclude_self=True`. Consumers verify signature, exact schema/digest, lifetime, revocation, and replay ordering. The threshold-signed catalog may authorize 2–32 sorted RSA observer roots; missing or empty roots disable remote demand. Discovery discards unlisted identities before signature/replay work, excludes local and duplicate roots, isolates malformed records, requires two authorized roots, and medians at most 32 quantized observations. Observer keys are never generated or bundled: only a separately provisioned `route-demand.key` matching a signed root may publish, while ordinary nodes can consume without one. Any hot-edited root-list mismatch disables both publication and consumption until restart. Local utility is capped at 6 points and signed remote utility at 2, keeping the combined hint below the 10-point migration margin and 100-point replica step. Verified announcement and route-demand replay watermarks now survive restarts in one Windows-safe journal per raw manifest digest under the node data directory. Each strict journal is capped at 256 active identity scopes and 256 KiB, retains only public record kind, key ID, ordering tuple, record digest, and the bounded replay deadline, and is fsync-written through atomic replacement; malformed, duplicate, oversized, symlinked, non-regular, or unwritable state fails closed. The retained deadline prevents an older still-live record from returning after a short-lived newer record expires. The replay slice's 99-test focused protocol/discovery/planner/node-configuration matrix and 209-pass, 2-skip catalog/node/API superset pass. The Sybil slice's 122-test focused catalog/bootstrap/config/discovery matrix proves that 30 valid attacker keys plus one authorized root cannot reach threshold, two authorized roots aggregate without attacker weight, one high authorized vote cannot inflate a lower second vote, old catalogs remain signature-verifiable with remote demand disabled, and trust-epoch reload mismatches fail closed. A 190-pass, 1-skip catalog/protocol/planner/discovery/node/API superset also passes. Independent verification passed 146 focused tests and a 255-pass, 2-skip broader node/API superset, plus a native-Windows publication-boundary probe; formatting, import-order, import-smoke, and diff checks pass. The [explicit privacy review](AUTOMATIC_PLACEMENT_PRIVACY_V1.md) inventories collection, retention, public-key linkability, DHT/journal/API/log exposure, secure-deletion limits, and residual governance/host risks. Three executable privacy-contract tests fix the aggregate, intent, demand, replay, forbidden-field, and path-free warning schemas; the focused privacy/protocol/planner/discovery/node matrix passes 108 tests and the broader catalog/node/API matrix passes 258 tests with 2 skips. Independent privacy review passed 108 tests with 1 skip and a 225-pass, 2-skip broader subset; every caught observer-key exception and an unauthorized key produced no path, key ID, or exception detail, while prompt and identity-path schema injections failed closed. The [deterministic convergence and load acceptance](AUTOMATIC_PLACEMENT_ACCEPTANCE_V1.md) closes the remaining software gate: equal snapshots use node-specific 32-point model dispersion and range rendezvous ranks; a fixed 512-node cold cohort selects both models and every range below the 85% concentration boundary; two 4,096-node fresh-arrival cohorts remain below that boundary under maximum priority-aligned or standby demand; maximum demand causes zero incumbent migrations; one-replica loss migrates after residency without early reversal; rolling arrivals keep every model/block populated and repair an abrupt block loss. The alpha fails closed above 32 candidates or 512 blocks, permits one `auto` worker, clamps reconciliation to at least one second, and scans each candidate in one bounded pass. The focused planner/convergence/configuration matrix passes 78 tests and the broader catalog/protocol/discovery/node/API matrix passes 214 with 2 skips. A real Windows DHT round trip exposed and fixed a durable-replay multiprocessing regression: replay guards now omit/recreate their thread lock across serialization and reload persistent state; its 15-test protocol/network matrix passes. Independent verification reproduced the 78-test focus, passed an expanded 235-test matrix with 2 skips and the 15-test real-DHT probe, and exercised adversarial score, timing, 32-by-512 load, 1,000-case range-equivalence, and persistent replay-reload boundaries. This slice used no cloud resources and spent USD 0. | Gates 9–11 are passed. Gates 13–14 must now prove the packaged flow and real hardware ceilings using the published envelopes. | | 11 | Operate initial public alpha routes | PASSED | [Product-node run `route-20260830-j`](evidence/gate11node-20260830-a-lifecycle.json) installed the generic CommunityAI wheel on a bounded G2/L4 VM, verified the signed catalog, downloaded both exact manifested models directly from Hugging Face into one persistent shared cache, and used the product node's automatic workers to expose complete Qwen 24/24 primary and Gemma 35/35 standby routes. No model-specific image, cache mirror, or operator-transferred model artifact was used. The privacy-safe acceptance passed one-token primary inference, deliberate primary pause, automatic Gemma selection in 58.073 seconds, standby inference, Qwen restoration in 32.042 seconds, and restored inference. Both workers were stable before the drill. After Gate 13 released the L4, the preserved route was restored without changing its model cache or source, its ephemeral endpoint was rebound, both product-node services became active, and a fresh acceptance reproved Qwen 24/24 primary inference, automatic Gemma 35/35 fallback/inference, Qwen restoration, and restored inference. The protected bootstrap remains running. A corrected 4,800-second provider DELETE backstop was set for `2026-08-31T05:28:16.516Z`, earlier than the original deadline. [Post-backstop cleanup evidence](evidence/gate11route-20260830-j-backstop-cleanup.json) and an independent recheck prove the route instance, named disk, and both exact run-scoped firewall rules absent, all Gate 13 clients/disks absent, zero remaining route availability, and the protected bootstrap still running. The same-host standby is a bounded alpha fallback, not independent infrastructure redundancy; independent redundancy remains post-alpha. | Gate 11 acceptance evidence remains complete, but no product route is live after the corrected DELETE backstop. [Gate 13 run `gate13-20260831-a`](evidence/gate13-20260831-a-cost-authorization.json) now binds refreshed native authentication, fail-closed preflight, and a fresh USD 52 reservation for the replacement route and packaged clients. | | 12 | Create, publish, and bundle the minimal signed alpha catalog/bootstrap | PASSED | [Run `gate12-20260829-a`](evidence/gate12-20260829-alpha-catalog-publication.json) published the deterministic [`communityai-public-alpha-v1` bundle](../public-alpha/catalog-v1/bundle.json) from source `26be579`. Its threshold-one Ed25519 root signs sequence 1 with the exact qualified Qwen primary and Gemma standby manifests, one pinned public HTTPS mirror, one public seed, a one-route best-effort policy, and no unprovisioned route-demand roots. The canonical bundle binds five members and retains `complete_release_qualification=false`. All three public objects returned HTTP 200 with exact sizes, and a fresh empty consumer fetched them remotely, verified the signature/digests, and created the two-model `auto` node configuration. The private signing key remained ignored and uncommitted. The focused publication suite passes 32 tests, the catalog/bootstrap/model/desktop superset passes 92, and the run spent USD 0. | Preserve the branch-scoped mirror until a newly signed catalog sequence and packaged bootstrap migrate it. The Gate 11 acceptance and Gate 9 envelopes exist; [Gate 13 run `gate13-20260831-a`](evidence/gate13-20260831-a-cost-authorization.json) now authorizes the bounded replacement route and fresh packaged clients under the new epoch. Independent threshold holders and interchangeable mirror/seed governance are post-alpha. | -| 13 | Pass packaged clean-install inference on Windows and Linux | IN PROGRESS | [Prerequisite run `gate13-20260830-a-prerequisites`](evidence/gate13-20260830-a-prerequisites.json) established deterministic install archives, exact first-use bytes, strict provenance, and the canonical lifecycle contract. [Native-harness and production-package run `gate13-20260830-b`](evidence/gate13-20260830-b-native-harness-and-packages.json) now completes the native Windows Credential Manager/Job Object and Linux Secret Service/systemd-cgroup 16-phase adapters, exact worker and descendant cleanup proofs, 3,600-second acquisition bounds, and package/runtime/catalog cross-binding. Independent software review passed 134 focused tests plus a 113-pass broader matrix with 3 platform skips; the production-discovery correction passes 73 unittests, 4 pytest checks, self-test, formatting, and import checks. [Exact-source production run 33338872342](https://github.com/flujo-app/CommunityAI/actions/runs/33338872342) passed both jobs at source `1971f10` and published independently audited CUDA 12.4 archives: Windows `sha256:45e9cdb439bcb8a6d7ed67914a490f3bc2e12ea1981af944034d62d865f5adc6` (2,695,065,068 bytes) and Linux `sha256:f96d3ca651964380d4684855ab08682e8187b33386327ec3895cda25b43c2a00` (3,360,717,934 bytes). Pushed source `6787272` adds the fixed stdin-only artifact downloader and exact platform configs; its 42-test adversarial suite and independent race/special-member/live-wrapper audit pass. No cloud resource was created for these prerequisites. Real completed clean-host lifecycle evidence remains absent. [Run `gate13-20260831-a`](evidence/gate13-20260831-a-failed-attempt-and-cleanup.json) later verified both exact archives on fresh clients but produced neither canonical lifecycle record. Its state-free transient orchestration advanced both clients before route acceptance, and subsequent diagnostic/product launches consumed both hosts for fresh-host acceptance. Exact cleanup proves all three instances/disks and both firewalls absent while the protected bootstrap remains running. Source `ddfb7c6` adds a persisted authorization-bound state contract that enforces inventory-first reattachment, route acceptance, sequential Windows-then-Linux evidence, consumed-host failure semantics, deadline runway, and success only after both canonical records plus final absence. [Durable native-host-job prerequisite evidence](evidence/gate13-20260831-b-durable-host-job-prerequisite.json) binds pushed source `0e16ac2` and its base `2fe0de9`: exact-current-user Windows Scheduled Task and non-root Linux systemd jobs survive operator disconnects, permit exactly one attempt, bound stdout/stderr and complete-process-tree cleanup, exact-bind source/config/native inventory, and revalidate canonical evidence before collection. Local and independent nine-file matrices each pass 217 tests with 29 known warnings. No cloud resource was created and no lifecycle pass is claimed. | [Run `gate13-20260830-c` revision 13](evidence/gate13-20260830-c-cost-authorization.json) is stopped clean. The latest Windows host passed exact package audit, clean install, four desktop self-tests, and the packaged-node self-test, then failed before model acquisition because child stderr diagnostics contaminated strict JSON captured on stdout. [Attempt, cleanup, correction, and route-restoration evidence](evidence/gate13-20260830-c-windows-attempt-and-route-restore.json) proves zero cache bytes, no retained credential or product process, all four exact client instances/disks absent, the bootstrap running, and the temporary restored Qwen/Gemma product route. [Post-backstop cleanup evidence](evidence/gate11route-20260830-j-backstop-cleanup.json) now proves that route, its named disk, and both exact run-scoped firewall rules absent while every Gate 13 target remains absent and the protected bootstrap remains running. Pushed source `4818da3` separates captured stdout from a dedicated NUL stderr sink and passes 15 native tests plus independent high-volume, handle-leak, descendant, timeout, and Job Object probes, but it has not completed a paid clean-host lifecycle. The cleanup-backed 2026-08-31 owner reset releases the USD 98 historical maxima and opens a new USD 100 epoch for the next run; it does not authorize any particular resource or reuse the stopped record. A later native-auth recheck on 2026-08-31 obtained a real access token and passed Compute API access without exposing account or token material. [Run `gate13-20260831-a`](evidence/gate13-20260831-a-failed-attempt-and-cleanup.json) is failed and cleanup-proved; its USD 52 maximum remains committed. The owner's 2026-08-31 ceiling increase leaves USD 448 before a new reservation. Do not reuse its clients or authorization. The durable Windows Scheduled Task and Linux systemd host-job adapter prerequisite is complete at pushed source `0e16ac2`. Run `gate13-20260831-b` is source-bound and reserved at USD 56. Revalidate native auth and exact absence, then execute only the straightforward route-first, Windows collect/delete, Linux collect/delete, route-delete sequence. The next run must accept the complete route first, run/collect/delete Windows Qwen, then run/collect/delete Linux Gemma, and finally prove exact route cleanup. | +| 13 | Pass packaged clean-install inference on Windows and Linux | IN PROGRESS | [Prerequisite run `gate13-20260830-a-prerequisites`](evidence/gate13-20260830-a-prerequisites.json) established deterministic install archives, exact first-use bytes, strict provenance, and the canonical lifecycle contract. [Native-harness and production-package run `gate13-20260830-b`](evidence/gate13-20260830-b-native-harness-and-packages.json) now completes the native Windows Credential Manager/Job Object and Linux Secret Service/systemd-cgroup 16-phase adapters, exact worker and descendant cleanup proofs, 3,600-second acquisition bounds, and package/runtime/catalog cross-binding. Independent software review passed 134 focused tests plus a 113-pass broader matrix with 3 platform skips; the production-discovery correction passes 73 unittests, 4 pytest checks, self-test, formatting, and import checks. [Exact-source production run 33338872342](https://github.com/flujo-app/CommunityAI/actions/runs/33338872342) passed both jobs at source `1971f10` and published independently audited CUDA 12.4 archives: Windows `sha256:45e9cdb439bcb8a6d7ed67914a490f3bc2e12ea1981af944034d62d865f5adc6` (2,695,065,068 bytes) and Linux `sha256:f96d3ca651964380d4684855ab08682e8187b33386327ec3895cda25b43c2a00` (3,360,717,934 bytes). Pushed source `6787272` adds the fixed stdin-only artifact downloader and exact platform configs; its 42-test adversarial suite and independent race/special-member/live-wrapper audit pass. No cloud resource was created for these prerequisites. Real completed clean-host lifecycle evidence remains absent. [Run `gate13-20260831-a`](evidence/gate13-20260831-a-failed-attempt-and-cleanup.json) later verified both exact archives on fresh clients but produced neither canonical lifecycle record. Its state-free transient orchestration advanced both clients before route acceptance, and subsequent diagnostic/product launches consumed both hosts for fresh-host acceptance. Exact cleanup proves all three instances/disks and both firewalls absent while the protected bootstrap remains running. Source `ddfb7c6` adds a persisted authorization-bound state contract that enforces inventory-first reattachment, route acceptance, sequential Windows-then-Linux evidence, consumed-host failure semantics, deadline runway, and success only after both canonical records plus final absence. [Durable native-host-job prerequisite evidence](evidence/gate13-20260831-b-durable-host-job-prerequisite.json) binds pushed source `0e16ac2` and its base `2fe0de9`: exact-current-user Windows Scheduled Task and non-root Linux systemd jobs survive operator disconnects, permit exactly one attempt, bound stdout/stderr and complete-process-tree cleanup, exact-bind source/config/native inventory, and revalidate canonical evidence before collection. Local and independent nine-file matrices each pass 217 tests with 29 known warnings. No cloud resource was created and no lifecycle pass is claimed. | [Run `gate13-20260830-c` revision 13](evidence/gate13-20260830-c-cost-authorization.json) is stopped clean. The latest Windows host passed exact package audit, clean install, four desktop self-tests, and the packaged-node self-test, then failed before model acquisition because child stderr diagnostics contaminated strict JSON captured on stdout. [Attempt, cleanup, correction, and route-restoration evidence](evidence/gate13-20260830-c-windows-attempt-and-route-restore.json) proves zero cache bytes, no retained credential or product process, all four exact client instances/disks absent, the bootstrap running, and the temporary restored Qwen/Gemma product route. [Post-backstop cleanup evidence](evidence/gate11route-20260830-j-backstop-cleanup.json) now proves that route, its named disk, and both exact run-scoped firewall rules absent while every Gate 13 target remains absent and the protected bootstrap remains running. Pushed source `4818da3` separates captured stdout from a dedicated NUL stderr sink and passes 15 native tests plus independent high-volume, handle-leak, descendant, timeout, and Job Object probes, but it has not completed a paid clean-host lifecycle. The cleanup-backed 2026-08-31 owner reset releases the USD 98 historical maxima and opens a new USD 100 epoch for the next run; it does not authorize any particular resource or reuse the stopped record. A later native-auth recheck on 2026-08-31 obtained a real access token and passed Compute API access without exposing account or token material. [Run `gate13-20260831-a`](evidence/gate13-20260831-a-failed-attempt-and-cleanup.json) is failed and cleanup-proved; its USD 52 maximum remains committed. The owner's 2026-08-31 ceiling increase leaves USD 448 before a new reservation. Do not reuse its clients or authorization. The durable Windows Scheduled Task and Linux systemd host-job adapter prerequisite is complete at pushed source `0e16ac2`. Run `gate13-20260831-b` failed before VM creation and is cleanup-proved. Run `gate13-20260831-c` is source-bound and reserved at USD 56 after a fresh exact-absence preflight. Execute only the straightforward route-first, Windows collect/delete, Linux collect/delete, route-delete sequence. The next run must accept the complete route first, run/collect/delete Windows Qwen, then run/collect/delete Linux Gemma, and finally prove exact route cleanup. | | 14 | Pass automatic-contribution and resource-control hardware checks | WAITING | [PR #11](https://github.com/flujo-app/CommunityAI/pull/11) and [PR #12](https://github.com/flujo-app/CommunityAI/pull/12) implemented the authenticated node-authoritative Sharing UI and atomic policy editing, but cross-model automatic placement and real packaged hardware evidence are absent. | After Gates 9–13, follow the [recovery runbook](RECOVERY_TEST_RUNBOOK.md) once for the clean-install product flow while validating model/block choice, exact selected-shard bytes, shared-cache affinity, download authorization, VRAM/storage/bandwidth/power limits, suspension, pause timing, cleanup, restart persistence, and unsupported telemetry on real packaged Windows/Linux hardware. | | 15 | Complete minimal alpha release engineering | WAITING | The desktop builder now emits a stable sorted `SHA256SUMS` inventory of exact regular-file bytes and safe relative in-bundle file symlinks, source/build/catalog-bound `provenance.json`, and `release-metadata.json` with explicit unsigned public-alpha, no-publisher-signature, no-authenticated-update, Windows/Linux-only, no-credits, and incomplete-qualification claims. Structural verification binds each safe file symlink to its canonical in-bundle target, digest, and size while rejecting changed, missing, extra, absolute, external, broken, cyclic, directory-linked/junction, special, traversal, or case-colliding payloads plus unsupported or noncanonical metadata. Exact-source builds also reject dirty relevant inputs, and the expected-input fresh-process check rejects rewritten commit/tree, workflow, platform, Python, PyInstaller, or catalog evidence. Production desktop CI is configured to verify and bundle the Gate 12 inputs, bind the exact clean Git commit/tree and workflow, revalidate every expected input separately, and upload all evidence on Windows/Linux. The focused release-input/artifact suite passes 15 tests, including fresh-process CLI, dirty-source, and canonical-rewrite checks, and the broader catalog/bootstrap/model/desktop subset passes 134. Independent verification reproduced all 134, passed 58 desktop unittests with two environment skips, formatting/import-order/YAML/diff checks, an expected Gate 12/workflow fresh-process probe, and real Windows junction rejection; no cloud was used. [The first PR #22 production-desktop run](https://github.com/flujo-app/CommunityAI/actions/runs/33273518744) reached packaging on both hosts and exposed two exact cross-platform defects: PyInstaller's legitimate relative internal Qt file symlink on Ubuntu and CRLF-transformed signed Gate 12 JSON on Windows. The follow-up binds safe internal file symlinks without accepting external or directory links, forces `public-alpha/**` to LF at checkout, and includes `.gitattributes` in the clean-source boundary. [The second run](https://github.com/flujo-app/CommunityAI/actions/runs/33274432423) proved the Ubuntu package and the Windows signed-bundle/provenance path, then exposed a stale desktop contribution-status schema 2 contract when the packaged node emitted schema 3 automatic-placement evidence. Source `fcd1f41` now strictly validates schema 3 placement and rejects stale schema 2 plus missing, extra, secret-bearing, or inconsistent placement data; its 50-test node/client/lifecycle/build focus and all 59 desktop unittests passed with two environment skips. [The final run](https://github.com/flujo-app/CommunityAI/actions/runs/33275216332) bound exact source `fcd1f417d1435557addb2d6cded9dac0827c7d8c` and completed both Windows and Ubuntu package jobs, including bundle build/smoke, independent checksum/provenance verification, the Windows packaged-node/native-credential/public-seed smoke, and artifact uploads; every PR style, test, and package check is green. Source `36d85d2` makes generic release-artifact fixtures select the supported Linux archive explicitly instead of inheriting the CI host platform; the 21-test local artifact suite and [PR #22 test run 33372581439](https://github.com/flujo-app/CommunityAI/actions/runs/33372581439) pass, without expanding the supported platform matrix. Clean-install lifecycle evidence remains absent. | Retain the verified Windows/Linux artifacts as engineering evidence, then test clean install, manual upgrade/reinstall, uninstall, retained-data choice for the persistent verified model cache, and recovery instructions on both platforms against a newly authorized live product-node route and the published Gate 9 envelopes. Do not mark passed from metadata/unit tests alone. Publisher signing and automatic authenticated update/rollback are post-alpha. | | 16 | Complete the bounded public-alpha safety canary | WAITING | [PR #13](https://github.com/flujo-app/CommunityAI/pull/13) and [PR #14](https://github.com/flujo-app/CommunityAI/pull/14) implemented bounded admission, privacy-safe aggregate health, training-off defaults, rollback procedures, and bounded routine rejection logs; no public canary has run. | After Gates 11–15, run a small monitored canary proving finite admission/timeouts, malformed-peer rejection, health reconstruction, privacy disclosure, route/catalog disable, and clean rollback. Exhaustive hostile-load, Sybil/collusion, partition, and long-soak campaigns are post-alpha. | @@ -123,8 +125,9 @@ longer consume the new authorization; later billing should still be recorded for Authorization applies only to CommunityAI qualification and public-alpha infrastructure. The [owner-authorized ceiling](evidence/owner-budget-authorization-20260831.json) is USD 500 combined across new temporary GCP and Fly resources in the current accounting epoch. The -existing USD 52 committed maximum remains charged. The USD 56 `gate13-20260831-b` -reservation brings the committed maximum to USD 108 and leaves USD 392 unreserved. The existing +existing USD 52 committed maximum and cleaned-failed run B's USD 56 maximum remain charged. +The fresh USD 56 `gate13-20260831-c` reservation brings the committed maximum to USD 164 and +leaves USD 336 unreserved. The existing GCP bootstrap's ordinary baseline cost is tracked separately; never delete it as test cleanup. Before every paid run, add an entry with a conservative maximum. After cleanup, replace @@ -137,7 +140,8 @@ maximum still consumes the current epoch because no reset or observed charge has | Run | Provider | Purpose | Maximum estimate | Observed cost | Cleanup proof | State | | --- | --- | --- | ---: | ---: | --- | --- | -| gate13-20260831-b | GCP | Gate 13 durable route-first lifecycle: one 16-hour G2/L4 product route, then sequential fresh 6-hour Windows/Qwen and Linux/Gemma CPU clients at controller source `ee0c05f83035ad22015fb089f5cb30f1713076e6`, host-job source `0e16ac2aa088e0719e9c3c89451176544602bab3`, route source `f64a388a47b098ac7f69d2affc59816376b43bb1`, package source `1971f106cc5bf90724d938c986a719ce2744f3e7`, and plan `sha256:3f3f921ded6eed1729aff175f5c91b4effe1966a31c82bdbe41ed69075442d64` | USD 56.00 | — | [Cost authorization](evidence/gate13-20260831-b-cost-authorization.json) binds exact images, packages, manifests, controller/host-job/helper bytes, unique resources, nonconcurrent clients, hard DELETE deadlines, complete cleanup, and zero Fly/image/mirror/credits/macOS operations. No resource has been created. | RESERVED | +| gate13-20260831-c | GCP | Fresh Gate 13 durable route-first lifecycle with the same bounded 16-hour route and sequential 6-hour clients, new exact resources, and corrected explicit IAP target-tag arguments [plan `sha256:07b6cd399ef7a9733602dfc19a741feddec8d15e5f4b5bac7347a192675f6d9c`] | USD 56.00 | — | [Cost authorization](evidence/gate13-20260831-c-cost-authorization.json) records fresh native/provider preflight, exact absence, L4 quota 1/0, protected-bootstrap health, distinct names, hard deadlines, and the unchanged simple execution sequence. No resource has been created. | RESERVED | +| gate13-20260831-b | GCP | Gate 13 durable route-first lifecycle: one 16-hour G2/L4 product route, then sequential fresh 6-hour Windows/Qwen and Linux/Gemma CPU clients at controller source `ee0c05f83035ad22015fb089f5cb30f1713076e6`, host-job source `0e16ac2aa088e0719e9c3c89451176544602bab3`, route source `f64a388a47b098ac7f69d2affc59816376b43bb1`, package source `1971f106cc5bf90724d938c986a719ce2744f3e7`, and plan `sha256:3f3f921ded6eed1729aff175f5c91b4effe1966a31c82bdbe41ed69075442d64` | USD 56.00 | — | [Failed start and cleanup](evidence/gate13-20260831-b-failed-start-and-cleanup.json) records passed preflight and persisted intent, one transient DHT firewall, IAP-tag argument rejection before VM creation, exact firewall cleanup, all run resources absent, and protected-bootstrap health. | CLEANED-COMMITTED | | gate13-20260831-a | GCP | Gate 13 replacement product-node route plus fresh CPU Windows/Linux packaged lifecycles at route source `f64a388a47b098ac7f69d2affc59816376b43bb1` and exact package source `1971f106cc5bf90724d938c986a719ce2744f3e7` [plan sha256:313f5d34eefd64c71e265bdb7044d8ef5f56550360a7e9a7104265434292fd69] | USD 52.00 | — | [Failed attempt and cleanup proof](evidence/gate13-20260831-a-failed-attempt-and-cleanup.json) records verified archive downloads but no completed lifecycle, the non-durable orchestration failure, consumed-client semantics, and exact absence of the route, both clients, all three disks, and both firewalls while the protected bootstrap remains running. The USD 52 maximum remains committed; after the owner raised the epoch ceiling to USD 500, USD 448 remains before a new reservation. | CLEANED-COMMITTED | | gate13-20260830-c | GCP | Gate 13 sequential clean packaged Qwen Windows and Gemma Linux lifecycles at exact package source `1971f106cc5bf90724d938c986a719ce2744f3e7`, temporarily suspending and later restoring the Gate 11 route while reusing its sole global L4 allocation on uniquely named fresh Windows and Linux clients [plan sha256:427bc1ed8a6645ad0650d91aaba7aa753d398fa84f56d57b50aca04c4e0cc955] | USD 26.00 | — | [Cost authorization](evidence/gate13-20260830-c-cost-authorization.json) binds the passed production archives/audits, pushed download-helper/config identities, exact Actions wrapper/inner archives, exact Qwen/Gemma manifests, no service accounts/scopes, direct model transfer, native credential stores, whole-tree containment, all 16 phases, exact cleanup targets, and zero Fly/image/mirror/credits/macOS work. Revision 13 records the final Windows pre-acquisition failure, pushed correction `4818da3`, complete native cleanup, all four exact client instance/disk absences, and successful Gate 11 route restoration. [Privacy-safe final state](evidence/gate13-20260830-c-windows-attempt-and-route-restore.json) proves the package audit and install boundary, zero model-cache bytes, no retained credential/process/path/endpoint/provider output, protected-bootstrap health, active Qwen/Gemma route services, and fresh primary/fallback/restoration inference. The two required 16-phase lifecycles remain incomplete. Complete cleanup permits the explicit owner reset on 2026-08-31; its USD 26 maximum is historical and delayed billing remains informational. This record authorizes no later provisioning. | CLEANED-RELEASED | | gate9-20260830-e | GCP | Gate 9 concurrent Qwen/Gemma Windows/Linux acquisition records and schema-v3 envelopes at pushed source `ba410f74f1cf625f1e1c34734b53e4514fa7c5ec`, reusing the separately authorized product route and using bounded isolated clients [plan sha256:04ba77ee68f4a895ae080a4ddcbf6805b502da6a95a4146734acbddff92de307] | USD 46.00 | — | [Passed envelopes and cleanup](evidence/gate9-20260830-e-edge-resource-envelopes.json) publish all four exact acquisition/envelope records and prove complete client cleanup; [cost authorization](evidence/gate9-20260830-e-cost-authorization.json) binds the exact wheel or exact-commit source archive, signed catalog/bootstrap, Qwen/Gemma manifests, owner-authorized parallel platform/model execution, 60-minute model windows, 90-minute client deletion backstops, exact cleanup targets, protected resources, and zero Fly/image/mirror operations. Native provider authentication was refreshed before the USD 18 Windows-client expansion and again before the zero-ceiling-increase Gemma memory retry; the exact plan permits one cache-preserving in-place resize to `e2-standard-8`. Complete cleanup permits the explicit owner reset on 2026-08-31; its USD 46 maximum is historical and delayed billing remains informational. | CLEANED-RELEASED | @@ -219,8 +223,8 @@ epoch; delayed observed charges remain informational. The next run starts with a authorization of **USD 100**. Later on 2026-08-31, the owner raised that current combined epoch to **USD 500** without releasing the already committed USD 52 maximum. The dated [authorization record](evidence/owner-budget-authorization-20260831.json) therefore leaves -USD 448 before a new reservation. The USD 56 `gate13-20260831-b` reservation now leaves -USD 392. Every paid create still requires fresh native authentication, +USD 448 before a new reservation. The cleaned-committed USD 56 run-B maximum plus the fresh USD 56 run-C reservation now leave +USD 336. Every paid create still requires fresh native authentication, an exact source-bound cost authorization, a conservative ledger reservation, and the existing fail-closed preflight and cleanup controls. diff --git a/docs/evidence/gate13-20260831-b-failed-start-and-cleanup.json b/docs/evidence/gate13-20260831-b-failed-start-and-cleanup.json new file mode 100644 index 000000000..5cea9d854 --- /dev/null +++ b/docs/evidence/gate13-20260831-b-failed-start-and-cleanup.json @@ -0,0 +1,62 @@ +{ + "schema_version": 1, + "scope": "gate13-route-start-failure-and-cleanup", + "run_id": "gate13-20260831-b", + "gate": 13, + "result": "failed_cleaned", + "recorded_at": "2026-08-31T21:04:21Z", + "source": { + "controller_commit": "ee0c05f83035ad22015fb089f5cb30f1713076e6", + "authorization_commit": "c1e1f86", + "provider_plan_digest": "sha256:3f3f921ded6eed1729aff175f5c91b4effe1966a31c82bdbe41ed69075442d64" + }, + "stages": { + "native_auth_and_provider_preflight": "passed", + "intent_persisted_before_mutation": true, + "dht_firewall_create": "passed", + "iap_firewall_create": "failed", + "route_instance_create": "not_attempted", + "client_create": "not_attempted", + "cleanup": "passed" + }, + "failure": { + "code": "iap_target_tags_collapsed_by_operator_shell", + "classification": "operator_command_boundary", + "provider_or_environment_failure": false, + "detail": "The two exact IAP target tags reached gcloud as one space-joined value. The command failed before route instance creation." + }, + "cleanup": { + "route_instance_absent": true, + "route_disk_absent": true, + "windows_instance_and_disk_absent": true, + "linux_instance_and_disk_absent": true, + "dht_firewall_absent": true, + "iap_firewall_absent": true, + "protected_bootstrap_running": true + }, + "controller_terminal": { + "phase": "CLEANED_FAILURE", + "failure_code": "resources_disappeared_before_completion", + "cleanup_verified": true, + "revision": 2 + }, + "cost": { + "maximum_estimate_usd": "56.00", + "billable_instance_created": false, + "observed_cost_usd": null, + "maximum_remains_committed": true + }, + "privacy": { + "credentials_retained": false, + "provider_output_retained": false, + "private_paths_retained": false, + "endpoints_retained": false + }, + "claims": { + "route_accepted": false, + "lifecycle_started": false, + "gate_13_passed": false, + "credits_work": false, + "macos_work": false + } +} diff --git a/docs/evidence/gate13-20260831-c-cost-authorization.json b/docs/evidence/gate13-20260831-c-cost-authorization.json new file mode 100644 index 000000000..accbb13ec --- /dev/null +++ b/docs/evidence/gate13-20260831-c-cost-authorization.json @@ -0,0 +1,224 @@ +{ + "schema_version": 1, + "scope": "gate13-packaged-lifecycle-cost-authorization", + "run_id": "gate13-20260831-c", + "gate": 13, + "result": "authorized", + "recorded_at": "2026-08-31", + "source": { + "reservation_commit": "ee0c05f83035ad22015fb089f5cb30f1713076e6", + "durable_controller_commit": "ee0c05f83035ad22015fb089f5cb30f1713076e6", + "host_job_adapter_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "route_runtime_commit": "f64a388a47b098ac7f69d2affc59816376b43bb1", + "package_commit": "1971f106cc5bf90724d938c986a719ce2744f3e7", + "windows_lifecycle_helper_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "linux_lifecycle_helper_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3" + }, + "authorization": { + "combined_cloud_ceiling_usd": "500.00", + "ledger_committed_before_run_usd": "108.00", + "maximum_estimate_usd": "56.00", + "route_maximum_estimate_usd": "30.00", + "clients_maximum_estimate_usd": "26.00", + "remaining_after_run_maximum_usd": "336.00", + "reservation_recorded": true, + "provisioning_authorized_after_fail_closed_preflight": true, + "provider_calls_authorized_without_preflight": false, + "pricing_as_of": "2026-08-31", + "pricing_basis": "same-day accepted Gate 13 ceilings scaled conservatively by hard duration: the 16-hour G2/L4 route is rounded up to USD 30 from the prior 14-hour USD 26 ceiling, and the two sequential 6-hour e2-standard-8 CPU clients retain the prior USD 26 ceiling" + }, + "immutable_inputs": { + "durable_controller": { + "path": "scripts/gate13_run_controller.py", + "source_commit": "ee0c05f83035ad22015fb089f5cb30f1713076e6", + "sha256": "3a33f1c0782222d400296c944651ca80a58fb67b4df79ce6a7d9c7216fd23b84", + "bytes": 34143 + }, + "host_job_adapter": { + "path": "scripts/gate13_host_job.py", + "source_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "sha256": "44b26b0f9828121fed9eef65d830bc9c02760fb8f8ddbb31ed0f1491c1b5d9d4", + "bytes": 41294 + }, + "lifecycle_evidence_validator": { + "path": "scripts/gate13_packaged_lifecycle.py", + "source_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "sha256": "36e04fbb599dce57e2a7d9d105511e731ad0ceafaaf0b0820de7022711066157", + "bytes": 27268 + }, + "route_runtime_wheel": { + "filename": "drift-2.3.0.dev2-py3-none-any.whl", + "source_commit": "f64a388a47b098ac7f69d2affc59816376b43bb1", + "sha256": "2a4f30bad7ae897fed019bc7da330a09965adb35685d11abaeaebf7a1d40aa60", + "bytes": 389107, + "model_artifacts_embedded": false + }, + "windows_package": { + "sha256": "45e9cdb439bcb8a6d7ed67914a490f3bc2e12ea1981af944034d62d865f5adc6", + "bytes": 2695065068 + }, + "linux_package": { + "sha256": "f96d3ca651964380d4684855ab08682e8187b33386327ec3895cda25b43c2a00", + "bytes": 3360717934 + }, + "qwen_manifest": "sha256:3ba8528cb3c0d85e1ed048e0438a0d64cfbbc298944ed674caa6950d415f8e33", + "gemma_manifest": "sha256:2f8debbe0fcdf5af8d4c56c982210fa50aa584314968ae2617e2ccc2de9eafdd", + "windows_helper": { + "path": "scripts/gate13_windows_packaged_lifecycle.ps1", + "source_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "sha256": "9b860ff820851b31ac272ee4d28dd3a899f56014b8dfce45e912ba4fc9ab605c", + "bytes": 132097 + }, + "linux_helper": { + "path": "scripts/gate13_linux_packaged_lifecycle.py", + "source_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "sha256": "90f3af65bb4f77317f707a6b52e329e1d5f81cdeddcb9615a210ec9a5a4cf535", + "bytes": 113748 + }, + "windows_download_config": { + "path": "scripts/gate13_download_windows.json", + "sha256": "bf36b66bf22a8f4453ba481c5a4ad37d8e1856ad93ef25e5e105b566b39affc4", + "bytes": 293 + }, + "linux_download_config": { + "path": "scripts/gate13_download_linux.json", + "sha256": "c3e9dcd94ef0a8e61c95e650416a8c6ca8b169f4b6699a0acd06608e60da5550", + "bytes": 294 + } + }, + "provider_plan_digest": "sha256:07b6cd399ef7a9733602dfc19a741feddec8d15e5f4b5bac7347a192675f6d9c", + "provider_plan": { + "project": "community-ai-506321", + "route": { + "instance": "route-20260831-c-node", + "zone": "us-central1-a", + "machine_type": "g2-standard-8", + "accelerator": "1 x NVIDIA L4", + "image": "deeplearning-platform-release/common-cu129-ubuntu-2404-nvidia-580-v20260819", + "boot_disk_gib": 200, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 57600, + "termination_action": "DELETE", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "public_ports": [ + 31337, + 31338 + ], + "firewalls": [ + "route-20260831-c-dht", + "route-20260831-c-iap" + ], + "service_account": false, + "scopes": [], + "operator_access_target_tags": [ + "route-20260831-c-node", + "gate13-20260831-c-client" + ] + }, + "clients": [ + { + "platform": "windows", + "model": "Qwen3.5 2B", + "instance": "gate13-20260831-c-win", + "zone": "us-central1-a", + "machine_type": "e2-standard-8", + "image": "windows-server-2025-dc-v20260814", + "image_project": "windows-cloud", + "boot_disk_gib": 120, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 21600, + "termination_action": "DELETE", + "lifecycle_run_id": "gate13-20260831-c-windows", + "device_profile": "cpu", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "network_tag": "gate13-20260831-c-client" + }, + { + "platform": "linux", + "model": "Gemma 4 E2B IT", + "instance": "gate13-20260831-c-linux", + "zone": "us-central1-a", + "machine_type": "e2-standard-8", + "image": "ubuntu-2404-noble-amd64-v20260826", + "image_project": "ubuntu-os-cloud", + "boot_disk_gib": 120, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 21600, + "termination_action": "DELETE", + "lifecycle_run_id": "gate13-20260831-c-linux", + "device_profile": "cpu", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "network_tag": "gate13-20260831-c-client" + } + ], + "sequencing": { + "route_live_for_both_lifecycles": true, + "clients_may_run_concurrently": false, + "route_accepted_before_any_client_create": true, + "windows_collected_and_deleted_before_linux_create": true, + "all_16_phases_required_per_platform": true, + "exact_cleanup_before_pass": true + } + }, + "preflight": { + "read_only_checked_at": "2026-08-31T21:06:16Z", + "native_gcloud_token_refresh": true, + "compute_api_access": true, + "exact_target_instances_absent": true, + "exact_target_disks_absent": true, + "exact_target_firewalls_absent": true, + "machine_types_available": true, + "images_available": true, + "one_l4_quota_free": true, + "protected_bootstrap_running": true, + "final_software_tests_passed": 219, + "independent_review_matrix_before_final_reservation_guard": 218, + "independent_reservation_guard_finding_resolved": true, + "fresh_native_revalidation_immediately_before_create_required": true + }, + "cleanup": { + "delete_only_exact_run_resources": true, + "verify_absent": [ + "route-20260831-c-node", + "route-20260831-c-node boot disk", + "route-20260831-c-dht", + "route-20260831-c-iap", + "gate13-20260831-c-win", + "gate13-20260831-c-win boot disk", + "gate13-20260831-c-linux", + "gate13-20260831-c-linux boot disk" + ], + "protected_resources": [ + "communityai-bootstrap-1", + "all resources not named by this plan" + ], + "cleanup_required_on_success_or_failure": true + }, + "prohibited": { + "fly_operations": 0, + "model_specific_images_or_mirrors": 0, + "macos_work": 0, + "credits_or_payments_work": 0 + }, + "privacy": { + "credentials_retained": false, + "prompts_retained": false, + "outputs_retained": false, + "endpoints_retained": false, + "provider_output_retained": false, + "private_paths_retained": false + }, + "plan_revision": { + "revision": 1, + "previous_provider_plan_digest": null, + "reason": "fresh replacement after the prior identity failed before VM creation; exact resource names change and the corrected IAP target tags remain two explicit values", + "resource_set_changed": true, + "cost_ceiling_changed": true + } +} From 1a6509675454f6233d9e12919e25d9beb63c83d5 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:40:32 -0500 Subject: [PATCH 12/56] docs: reserve direct Gate 13 completion run --- docs/RELEASE_READINESS.md | 21 +- ...20260831-c-terminal-state-and-cleanup.json | 58 +++++ .../gate13-20260831-d-cost-authorization.json | 224 ++++++++++++++++++ 3 files changed, 295 insertions(+), 8 deletions(-) create mode 100644 docs/evidence/gate13-20260831-c-terminal-state-and-cleanup.json create mode 100644 docs/evidence/gate13-20260831-d-cost-authorization.json diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md index ea8ea46f6..5a32ef9f8 100644 --- a/docs/RELEASE_READINESS.md +++ b/docs/RELEASE_READINESS.md @@ -65,10 +65,14 @@ binds the pushed source and independently reproduced 217-test Gate 13/desktop re created no cloud resources and authorizes no paid run. [Run `gate13-20260831-b`](evidence/gate13-20260831-b-failed-start-and-cleanup.json) failed before VM creation when the operator shell collapsed two IAP target tags into one value; its exact firewall cleanup and every instance/disk/firewall absence passed. Its USD 56 maximum -remains committed. Fresh [run `gate13-20260831-c`](evidence/gate13-20260831-c-cost-authorization.json) -reserves another USD 56 at plan `sha256:07b6cd399ef7a9733602dfc19a741feddec8d15e5f4b5bac7347a192675f6d9c`, -leaving USD 336; no run-C resource has been created. No later mandatory gate is unblocked until -the fresh run produces both complete 16-phase records and exact cleanup. +remains committed. Run `gate13-20260831-c` was armed locally but reached terminal controller absence without a +durable provider execution record. Its [terminal-state and cleanup proof](evidence/gate13-20260831-c-terminal-state-and-cleanup.json) +retires the run ID, proves every exact resource absent, global GPU usage zero, and the protected +bootstrap running; its USD 56 maximum remains committed. Fresh +[run `gate13-20260831-d`](evidence/gate13-20260831-d-cost-authorization.json) reserves another +USD 56 at plan `sha256:d32050a51b8f696aa224fc7e748c9113e174e3c3069c1f8b2bc769b0c5ecea18`, leaving +USD 280. No later mandatory gate is unblocked until the fresh run produces both complete +16-phase records and exact cleanup. Do not work on the post-alpha items in the deferred table while an alpha gate can progress. Missing Docker, snapshots, local GPU hardware, or local host capacity is not an external @@ -125,9 +129,9 @@ longer consume the new authorization; later billing should still be recorded for Authorization applies only to CommunityAI qualification and public-alpha infrastructure. The [owner-authorized ceiling](evidence/owner-budget-authorization-20260831.json) is USD 500 combined across new temporary GCP and Fly resources in the current accounting epoch. The -existing USD 52 committed maximum and cleaned-failed run B's USD 56 maximum remain charged. -The fresh USD 56 `gate13-20260831-c` reservation brings the committed maximum to USD 164 and -leaves USD 336 unreserved. The existing +existing USD 52 committed maximum and the three cleaned-failed USD 56 Gate 13 runs remain +charged. The fresh USD 56 `gate13-20260831-d` reservation brings the committed maximum to USD 220 +and leaves USD 280 unreserved. The existing GCP bootstrap's ordinary baseline cost is tracked separately; never delete it as test cleanup. Before every paid run, add an entry with a conservative maximum. After cleanup, replace @@ -140,7 +144,8 @@ maximum still consumes the current epoch because no reset or observed charge has | Run | Provider | Purpose | Maximum estimate | Observed cost | Cleanup proof | State | | --- | --- | --- | ---: | ---: | --- | --- | -| gate13-20260831-c | GCP | Fresh Gate 13 durable route-first lifecycle with the same bounded 16-hour route and sequential 6-hour clients, new exact resources, and corrected explicit IAP target-tag arguments [plan `sha256:07b6cd399ef7a9733602dfc19a741feddec8d15e5f4b5bac7347a192675f6d9c`] | USD 56.00 | — | [Cost authorization](evidence/gate13-20260831-c-cost-authorization.json) records fresh native/provider preflight, exact absence, L4 quota 1/0, protected-bootstrap health, distinct names, hard deadlines, and the unchanged simple execution sequence. No resource has been created. | RESERVED | +| gate13-20260831-d | GCP | Fresh Gate 13 route-first lifecycle using the already-qualified durable controller and host jobs, one bounded 16-hour route, sequential fresh 6-hour Windows/Qwen and Linux/Gemma clients, and explicit provider argument arrays [plan `sha256:d32050a51b8f696aa224fc7e748c9113e174e3c3069c1f8b2bc769b0c5ecea18`] | USD 56.00 | — | [Cost authorization](evidence/gate13-20260831-d-cost-authorization.json) records native/provider preflight at 2026-08-31T21:38:14Z, every exact target absent, L4 quota 1/0, protected-bootstrap health, immutable inputs, hard deadlines, and the unchanged route → Windows → Linux → cleanup sequence. | RESERVED | +| gate13-20260831-c | GCP | Gate 13 durable route-first lifecycle with the same bounded 16-hour route and sequential 6-hour clients, new exact resources, and corrected explicit IAP target-tag arguments [plan `sha256:07b6cd399ef7a9733602dfc19a741feddec8d15e5f4b5bac7347a192675f6d9c`] | USD 56.00 | — | [Terminal-state and cleanup proof](evidence/gate13-20260831-c-terminal-state-and-cleanup.json) records a local terminal absence state without a durable provider execution record, retires the run ID without reset or reuse, proves every exact target absent, global GPU usage zero, and the protected bootstrap running. | CLEANED-COMMITTED | | gate13-20260831-b | GCP | Gate 13 durable route-first lifecycle: one 16-hour G2/L4 product route, then sequential fresh 6-hour Windows/Qwen and Linux/Gemma CPU clients at controller source `ee0c05f83035ad22015fb089f5cb30f1713076e6`, host-job source `0e16ac2aa088e0719e9c3c89451176544602bab3`, route source `f64a388a47b098ac7f69d2affc59816376b43bb1`, package source `1971f106cc5bf90724d938c986a719ce2744f3e7`, and plan `sha256:3f3f921ded6eed1729aff175f5c91b4effe1966a31c82bdbe41ed69075442d64` | USD 56.00 | — | [Failed start and cleanup](evidence/gate13-20260831-b-failed-start-and-cleanup.json) records passed preflight and persisted intent, one transient DHT firewall, IAP-tag argument rejection before VM creation, exact firewall cleanup, all run resources absent, and protected-bootstrap health. | CLEANED-COMMITTED | | gate13-20260831-a | GCP | Gate 13 replacement product-node route plus fresh CPU Windows/Linux packaged lifecycles at route source `f64a388a47b098ac7f69d2affc59816376b43bb1` and exact package source `1971f106cc5bf90724d938c986a719ce2744f3e7` [plan sha256:313f5d34eefd64c71e265bdb7044d8ef5f56550360a7e9a7104265434292fd69] | USD 52.00 | — | [Failed attempt and cleanup proof](evidence/gate13-20260831-a-failed-attempt-and-cleanup.json) records verified archive downloads but no completed lifecycle, the non-durable orchestration failure, consumed-client semantics, and exact absence of the route, both clients, all three disks, and both firewalls while the protected bootstrap remains running. The USD 52 maximum remains committed; after the owner raised the epoch ceiling to USD 500, USD 448 remains before a new reservation. | CLEANED-COMMITTED | | gate13-20260830-c | GCP | Gate 13 sequential clean packaged Qwen Windows and Gemma Linux lifecycles at exact package source `1971f106cc5bf90724d938c986a719ce2744f3e7`, temporarily suspending and later restoring the Gate 11 route while reusing its sole global L4 allocation on uniquely named fresh Windows and Linux clients [plan sha256:427bc1ed8a6645ad0650d91aaba7aa753d398fa84f56d57b50aca04c4e0cc955] | USD 26.00 | — | [Cost authorization](evidence/gate13-20260830-c-cost-authorization.json) binds the passed production archives/audits, pushed download-helper/config identities, exact Actions wrapper/inner archives, exact Qwen/Gemma manifests, no service accounts/scopes, direct model transfer, native credential stores, whole-tree containment, all 16 phases, exact cleanup targets, and zero Fly/image/mirror/credits/macOS work. Revision 13 records the final Windows pre-acquisition failure, pushed correction `4818da3`, complete native cleanup, all four exact client instance/disk absences, and successful Gate 11 route restoration. [Privacy-safe final state](evidence/gate13-20260830-c-windows-attempt-and-route-restore.json) proves the package audit and install boundary, zero model-cache bytes, no retained credential/process/path/endpoint/provider output, protected-bootstrap health, active Qwen/Gemma route services, and fresh primary/fallback/restoration inference. The two required 16-phase lifecycles remain incomplete. Complete cleanup permits the explicit owner reset on 2026-08-31; its USD 26 maximum is historical and delayed billing remains informational. This record authorizes no later provisioning. | CLEANED-RELEASED | diff --git a/docs/evidence/gate13-20260831-c-terminal-state-and-cleanup.json b/docs/evidence/gate13-20260831-c-terminal-state-and-cleanup.json new file mode 100644 index 000000000..12162d0c7 --- /dev/null +++ b/docs/evidence/gate13-20260831-c-terminal-state-and-cleanup.json @@ -0,0 +1,58 @@ +{ + "schema_version": 1, + "scope": "gate13-run-controller-terminal-and-cleanup", + "run_id": "gate13-20260831-c", + "gate": 13, + "result": "failed_cleaned", + "recorded_at": "2026-08-31T21:38:14Z", + "source": { + "authorization_commit": "0dc2345f7c0880bbb63b1d8951187e4e4b744a84", + "authorization_sha256": "sha256:3bb9f79d91d9ef4df2e0082f0358f5edfef199e80f15359560013a10a8b758bd", + "provider_plan_digest": "sha256:07b6cd399ef7a9733602dfc19a741feddec8d15e5f4b5bac7347a192675f6d9c" + }, + "controller_terminal": { + "phase": "CLEANED_FAILURE", + "failure_code": "resources_disappeared_before_completion", + "cleanup_verified": true, + "revision": 2, + "next_action": "none", + "windows_consumed": false, + "linux_consumed": false + }, + "classification": { + "category": "local_orchestration_state", + "durable_provider_run_record_present": false, + "product_failure_evidenced": false, + "reusable_run_id": false, + "detail": "The reserved run was armed locally and reached a terminal absence state without a durable provider execution record. It is retired rather than reset or reused." + }, + "provider_reconciliation": { + "checked_at": "2026-08-31T21:38:14Z", + "route_instance_absent": true, + "route_disk_absent": true, + "windows_instance_and_disk_absent": true, + "linux_instance_and_disk_absent": true, + "dht_firewall_absent": true, + "iap_firewall_absent": true, + "global_gpu_limit": 1, + "global_gpu_usage": 0, + "protected_bootstrap_running": true + }, + "cost": { + "maximum_estimate_usd": "56.00", + "observed_cost_usd": null, + "maximum_remains_committed": true + }, + "privacy": { + "credentials_retained": false, + "provider_output_retained": false, + "private_paths_retained": false, + "endpoints_retained": false + }, + "claims": { + "route_accepted": false, + "windows_lifecycle_passed": false, + "linux_lifecycle_passed": false, + "gate_13_passed": false + } +} diff --git a/docs/evidence/gate13-20260831-d-cost-authorization.json b/docs/evidence/gate13-20260831-d-cost-authorization.json new file mode 100644 index 000000000..5915c2f36 --- /dev/null +++ b/docs/evidence/gate13-20260831-d-cost-authorization.json @@ -0,0 +1,224 @@ +{ + "schema_version": 1, + "scope": "gate13-packaged-lifecycle-cost-authorization", + "run_id": "gate13-20260831-d", + "gate": 13, + "result": "authorized", + "recorded_at": "2026-08-31", + "source": { + "reservation_commit": "0dc2345f7c0880bbb63b1d8951187e4e4b744a84", + "durable_controller_commit": "ee0c05f83035ad22015fb089f5cb30f1713076e6", + "host_job_adapter_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "route_runtime_commit": "f64a388a47b098ac7f69d2affc59816376b43bb1", + "package_commit": "1971f106cc5bf90724d938c986a719ce2744f3e7", + "windows_lifecycle_helper_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "linux_lifecycle_helper_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3" + }, + "authorization": { + "combined_cloud_ceiling_usd": "500.00", + "ledger_committed_before_run_usd": "164.00", + "maximum_estimate_usd": "56.00", + "route_maximum_estimate_usd": "30.00", + "clients_maximum_estimate_usd": "26.00", + "remaining_after_run_maximum_usd": "280.00", + "reservation_recorded": true, + "provisioning_authorized_after_fail_closed_preflight": true, + "provider_calls_authorized_without_preflight": false, + "pricing_as_of": "2026-08-31", + "pricing_basis": "same-day accepted Gate 13 ceilings scaled conservatively by hard duration: the 16-hour G2/L4 route is rounded up to USD 30 from the prior 14-hour USD 26 ceiling, and the two sequential 6-hour e2-standard-8 CPU clients retain the prior USD 26 ceiling" + }, + "immutable_inputs": { + "durable_controller": { + "path": "scripts/gate13_run_controller.py", + "source_commit": "ee0c05f83035ad22015fb089f5cb30f1713076e6", + "sha256": "3a33f1c0782222d400296c944651ca80a58fb67b4df79ce6a7d9c7216fd23b84", + "bytes": 34143 + }, + "host_job_adapter": { + "path": "scripts/gate13_host_job.py", + "source_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "sha256": "44b26b0f9828121fed9eef65d830bc9c02760fb8f8ddbb31ed0f1491c1b5d9d4", + "bytes": 41294 + }, + "lifecycle_evidence_validator": { + "path": "scripts/gate13_packaged_lifecycle.py", + "source_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "sha256": "36e04fbb599dce57e2a7d9d105511e731ad0ceafaaf0b0820de7022711066157", + "bytes": 27268 + }, + "route_runtime_wheel": { + "filename": "drift-2.3.0.dev2-py3-none-any.whl", + "source_commit": "f64a388a47b098ac7f69d2affc59816376b43bb1", + "sha256": "2a4f30bad7ae897fed019bc7da330a09965adb35685d11abaeaebf7a1d40aa60", + "bytes": 389107, + "model_artifacts_embedded": false + }, + "windows_package": { + "sha256": "45e9cdb439bcb8a6d7ed67914a490f3bc2e12ea1981af944034d62d865f5adc6", + "bytes": 2695065068 + }, + "linux_package": { + "sha256": "f96d3ca651964380d4684855ab08682e8187b33386327ec3895cda25b43c2a00", + "bytes": 3360717934 + }, + "qwen_manifest": "sha256:3ba8528cb3c0d85e1ed048e0438a0d64cfbbc298944ed674caa6950d415f8e33", + "gemma_manifest": "sha256:2f8debbe0fcdf5af8d4c56c982210fa50aa584314968ae2617e2ccc2de9eafdd", + "windows_helper": { + "path": "scripts/gate13_windows_packaged_lifecycle.ps1", + "source_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "sha256": "9b860ff820851b31ac272ee4d28dd3a899f56014b8dfce45e912ba4fc9ab605c", + "bytes": 132097 + }, + "linux_helper": { + "path": "scripts/gate13_linux_packaged_lifecycle.py", + "source_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "sha256": "90f3af65bb4f77317f707a6b52e329e1d5f81cdeddcb9615a210ec9a5a4cf535", + "bytes": 113748 + }, + "windows_download_config": { + "path": "scripts/gate13_download_windows.json", + "sha256": "bf36b66bf22a8f4453ba481c5a4ad37d8e1856ad93ef25e5e105b566b39affc4", + "bytes": 293 + }, + "linux_download_config": { + "path": "scripts/gate13_download_linux.json", + "sha256": "c3e9dcd94ef0a8e61c95e650416a8c6ca8b169f4b6699a0acd06608e60da5550", + "bytes": 294 + } + }, + "provider_plan_digest": "sha256:d32050a51b8f696aa224fc7e748c9113e174e3c3069c1f8b2bc769b0c5ecea18", + "provider_plan": { + "project": "community-ai-506321", + "route": { + "instance": "route-20260831-d-node", + "zone": "us-central1-a", + "machine_type": "g2-standard-8", + "accelerator": "1 x NVIDIA L4", + "image": "deeplearning-platform-release/common-cu129-ubuntu-2404-nvidia-580-v20260819", + "boot_disk_gib": 200, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 57600, + "termination_action": "DELETE", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "public_ports": [ + 31337, + 31338 + ], + "firewalls": [ + "route-20260831-d-dht", + "route-20260831-d-iap" + ], + "service_account": false, + "scopes": [], + "operator_access_target_tags": [ + "route-20260831-d-node", + "gate13-20260831-d-client" + ] + }, + "clients": [ + { + "platform": "windows", + "model": "Qwen3.5 2B", + "instance": "gate13-20260831-d-win", + "zone": "us-central1-a", + "machine_type": "e2-standard-8", + "image": "windows-server-2025-dc-v20260814", + "image_project": "windows-cloud", + "boot_disk_gib": 120, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 21600, + "termination_action": "DELETE", + "lifecycle_run_id": "gate13-20260831-d-windows", + "device_profile": "cpu", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "network_tag": "gate13-20260831-d-client" + }, + { + "platform": "linux", + "model": "Gemma 4 E2B IT", + "instance": "gate13-20260831-d-linux", + "zone": "us-central1-a", + "machine_type": "e2-standard-8", + "image": "ubuntu-2404-noble-amd64-v20260826", + "image_project": "ubuntu-os-cloud", + "boot_disk_gib": 120, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 21600, + "termination_action": "DELETE", + "lifecycle_run_id": "gate13-20260831-d-linux", + "device_profile": "cpu", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "network_tag": "gate13-20260831-d-client" + } + ], + "sequencing": { + "route_live_for_both_lifecycles": true, + "clients_may_run_concurrently": false, + "route_accepted_before_any_client_create": true, + "windows_collected_and_deleted_before_linux_create": true, + "all_16_phases_required_per_platform": true, + "exact_cleanup_before_pass": true + } + }, + "preflight": { + "read_only_checked_at": "2026-08-31T21:38:14Z", + "native_gcloud_token_refresh": true, + "compute_api_access": true, + "exact_target_instances_absent": true, + "exact_target_disks_absent": true, + "exact_target_firewalls_absent": true, + "machine_types_available": true, + "images_available": true, + "one_l4_quota_free": true, + "protected_bootstrap_running": true, + "final_software_tests_passed": 219, + "independent_review_matrix_before_final_reservation_guard": 218, + "independent_reservation_guard_finding_resolved": true, + "fresh_native_revalidation_immediately_before_create_required": true + }, + "cleanup": { + "delete_only_exact_run_resources": true, + "verify_absent": [ + "route-20260831-d-node", + "route-20260831-d-node boot disk", + "route-20260831-d-dht", + "route-20260831-d-iap", + "gate13-20260831-d-win", + "gate13-20260831-d-win boot disk", + "gate13-20260831-d-linux", + "gate13-20260831-d-linux boot disk" + ], + "protected_resources": [ + "communityai-bootstrap-1", + "all resources not named by this plan" + ], + "cleanup_required_on_success_or_failure": true + }, + "prohibited": { + "fly_operations": 0, + "model_specific_images_or_mirrors": 0, + "macos_work": 0, + "credits_or_payments_work": 0 + }, + "privacy": { + "credentials_retained": false, + "prompts_retained": false, + "outputs_retained": false, + "endpoints_retained": false, + "provider_output_retained": false, + "private_paths_retained": false + }, + "plan_revision": { + "revision": 1, + "previous_provider_plan_digest": null, + "reason": "fresh replacement after the prior identity failed before VM creation; exact resource names change and the corrected IAP target tags remain two explicit values", + "resource_set_changed": true, + "cost_ceiling_changed": true + } +} From 3a1d1fcaa8430e3c0bfc47910666f11989eb0a3d Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:20:27 -0500 Subject: [PATCH 13/56] gate13: make Windows host job headless --- scripts/gate13_host_job.py | 13 ++++++++++--- tests/test_gate13_host_job.py | 7 ++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/scripts/gate13_host_job.py b/scripts/gate13_host_job.py index 6cfe19b10..a659fcdc4 100644 --- a/scripts/gate13_host_job.py +++ b/scripts/gate13_host_job.py @@ -847,7 +847,7 @@ def _windows_register_script(config: HostJobConfig) -> str: ), ( "$principal = New-ScheduledTaskPrincipal -UserId $currentUser " - "-LogonType Interactive -RunLevel Limited" + "-LogonType S4U -RunLevel Limited" ), ( "$settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew " @@ -878,6 +878,13 @@ def _windows_snapshot_script(config: HostJobConfig) -> str: "$identity = [Security.Principal.WindowsIdentity]::GetCurrent()", "$currentUser = [string]$identity.Name", "$leafUser = $currentUser.Substring($currentUser.LastIndexOf('\\') + 1)", + "$taskSid = ''", + "try {", + " $taskAccount = [Security.Principal.NTAccount]::new([string]$task.Principal.UserId)", + " $taskSid = $taskAccount.Translate([Security.Principal.SecurityIdentifier]).Value", + "} catch {", + " $taskSid = ''", + "}", "$action = @($task.Actions)[0]", f"$expectedLimit = [Xml.XmlConvert]::ToString([TimeSpan]::FromSeconds({config.max_run_seconds + 2 * SUPERVISOR_GRACE_SECONDS}))", ( @@ -885,8 +892,8 @@ def _windows_snapshot_script(config: HostJobConfig) -> str: f"($action.Execute -eq {_ps_quote(os.fspath(config.python_executable))}) -and " f"($action.Arguments -eq {_ps_quote(arguments)}) -and " f"(-not $identity.IsSystem) -and ($leafUser -ieq {_ps_quote(config.host_user)}) -and " - "($task.Principal.UserId -ieq $currentUser) -and " - "($task.Principal.LogonType -eq 'Interactive') -and " + "($taskSid -eq $identity.User.Value) -and " + "($task.Principal.LogonType -eq 'S4U') -and " "($task.Principal.RunLevel -eq 'Limited') -and " "($task.Settings.MultipleInstances -eq 'IgnoreNew') -and " "($task.Settings.ExecutionTimeLimit -eq $expectedLimit)" diff --git a/tests/test_gate13_host_job.py b/tests/test_gate13_host_job.py index ee49a73b5..84848a47e 100644 --- a/tests/test_gate13_host_job.py +++ b/tests/test_gate13_host_job.py @@ -144,7 +144,7 @@ def test_windows_task_is_bounded_ordinary_user_single_instance(config_factory): script = host_job._windows_register_script(config) assert "New-ScheduledTaskPrincipal -UserId $currentUser" in script - assert "-LogonType Interactive -RunLevel Limited" in script + assert "-LogonType S4U -RunLevel Limited" in script assert "$identity.IsSystem" in script assert "'SYSTEM'" not in script assert "-MultipleInstances IgnoreNew" in script @@ -157,9 +157,10 @@ def test_windows_task_is_bounded_ordinary_user_single_instance(config_factory): snapshot = host_job._windows_snapshot_script(config) assert "MultipleInstances -eq 'IgnoreNew'" in snapshot assert "ExecutionTimeLimit -eq $expectedLimit" in snapshot - assert "LogonType -eq 'Interactive'" in snapshot + assert "LogonType -eq 'S4U'" in snapshot + assert "$taskSid -eq $identity.User.Value" in snapshot + assert "NTAccount]::new([string]$task.Principal.UserId)" in snapshot assert "RunLevel -eq 'Limited'" in snapshot - assert "$task.Principal.UserId -ieq $currentUser" in snapshot @pytest.mark.skipif(sys.platform != "win32", reason="requires Windows PowerShell parser") From 7ee3c58f8c4b81814e25b1bf8c9d07467c85ac8c Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:21:29 -0500 Subject: [PATCH 14/56] docs: authorize Gate 13 Windows supervisor repair --- ...ndows-supervisor-repair-authorization.json | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 docs/evidence/gate13-20260831-d-windows-supervisor-repair-authorization.json diff --git a/docs/evidence/gate13-20260831-d-windows-supervisor-repair-authorization.json b/docs/evidence/gate13-20260831-d-windows-supervisor-repair-authorization.json new file mode 100644 index 000000000..008ba5454 --- /dev/null +++ b/docs/evidence/gate13-20260831-d-windows-supervisor-repair-authorization.json @@ -0,0 +1,54 @@ +{ + "schema_version": 1, + "scope": "gate13-windows-supervisor-repair-authorization", + "run_id": "gate13-20260831-d", + "recorded_at": "2026-08-31T22:20:51Z", + "objective": "Launch the already-authorized Windows packaged lifecycle exactly once under a headless ordinary-user supervisor.", + "finding": { + "task_name": "communityai-gate13-gate13-20260831-d-windows", + "registered_principal": "M", + "registered_logon_type": "Interactive", + "registered_run_level": "Limited", + "scheduler_state": "Ready", + "scheduler_last_result_decimal": 267011, + "scheduler_last_result_hex": "0x41303", + "scheduler_interpretation": "task_has_not_yet_run", + "status_record_present": false, + "terminal_record_present": false, + "evidence_record_present": false, + "lifecycle_attempt_consumed": false, + "additional_cloud_resources_required": false + }, + "root_cause": [ + "Interactive scheduled tasks do not start from this headless SSH operator session.", + "Task Scheduler canonicalized the registered principal to a leaf account name, while the observer compared it to the qualified current identity string." + ], + "repair_binding": { + "implementation_commit": "3a1d1fcaa8430e3c0bfc47910666f11989eb0a3d", + "host_adapter_path": "scripts/gate13_host_job.py", + "host_adapter_sha256": "sha256:922a9365269a2c1f0aee09473b15fdcc9b9a08522cfd5e9934145b4966b0ad31", + "host_adapter_bytes": 41592, + "replacement_logon_type": "S4U", + "principal_match": "resolved_principal_sid_equals_current_identity_sid", + "unchanged_entrypoint_sha256": "sha256:9b860ff820851b31ac272ee4d28dd3a899f56014b8dfce45e912ba4fc9ab605c", + "unchanged_lifecycle_config_sha256": "sha256:a926514b35b10baab742ff270db606fef2f7ddf58317e1023f12b1cd66affbd0" + }, + "authorized_actions": [ + "Verify the exact old task name, action, ordinary-user principal, Interactive logon type, Limited run level, Ready state, 0x41303 last result, and absence of status, terminal, and evidence records.", + "Unregister only that exact never-run task.", + "Replace only the host adapter and its digest binding in host-job.json.", + "Register and start the same exact task once with S4U, Limited run level, and SID-based principal verification.", + "Fail closed if any lifecycle output appeared before repair or if the old task binding differs." + ], + "invariants": [ + "The paid Windows VM is not recreated.", + "The lifecycle entrypoint, package, model, route acceptance, evidence contract, and one-attempt ceiling are unchanged.", + "No second lifecycle attempt is authorized.", + "Windows evidence must still be collected and the Windows VM deleted before Linux is created." + ], + "added_cost_usd": "0.00", + "tests": { + "command": ".\\.venv-cuda\\Scripts\\python.exe -m pytest -q tests/test_gate13_host_job.py", + "result": "23 passed" + } +} From 56a96ea43b0b99e24b8abce9af08debdd059013f Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:32:30 -0500 Subject: [PATCH 15/56] docs: retire failed Gate 13 run d --- docs/RELEASE_READINESS.md | 18 +++-- ...20260831-d-failed-attempt-and-cleanup.json | 76 +++++++++++++++++++ 2 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 docs/evidence/gate13-20260831-d-failed-attempt-and-cleanup.json diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md index 5a32ef9f8..898ae1c3d 100644 --- a/docs/RELEASE_READINESS.md +++ b/docs/RELEASE_READINESS.md @@ -68,11 +68,17 @@ its exact firewall cleanup and every instance/disk/firewall absence passed. Its remains committed. Run `gate13-20260831-c` was armed locally but reached terminal controller absence without a durable provider execution record. Its [terminal-state and cleanup proof](evidence/gate13-20260831-c-terminal-state-and-cleanup.json) retires the run ID, proves every exact resource absent, global GPU usage zero, and the protected -bootstrap running; its USD 56 maximum remains committed. Fresh -[run `gate13-20260831-d`](evidence/gate13-20260831-d-cost-authorization.json) reserves another -USD 56 at plan `sha256:d32050a51b8f696aa224fc7e748c9113e174e3c3069c1f8b2bc769b0c5ecea18`, leaving -USD 280. No later mandatory gate is unblocked until the fresh run produces both complete -16-phase records and exact cleanup. +bootstrap running; its USD 56 maximum remains committed. [Run `gate13-20260831-d`](evidence/gate13-20260831-d-failed-attempt-and-cleanup.json) +accepted the complete replacement product route before creating Windows, then exposed two +Gate 13 orchestration defects: the Interactive scheduled task could not start headlessly and +its observer compared noncanonical account names; source `3a1d1fc` changes the task to S4U +and binds its principal by SID. The repaired supervisor then ran, but the one-attempt lifecycle +failed immediately because the operator had staged the downloader without first downloading +the exact archive. Windows, route, disks, and firewalls are absent, L4 usage is zero, and the +protected bootstrap remains running. Its USD 56 maximum remains committed, bringing the epoch +total to USD 220 and leaving USD 280. No later mandatory gate is unblocked until a fresh run +downloads each exact archive before arming its host job and produces both complete 16-phase +records plus exact cleanup. Do not work on the post-alpha items in the deferred table while an alpha gate can progress. Missing Docker, snapshots, local GPU hardware, or local host capacity is not an external @@ -144,7 +150,7 @@ maximum still consumes the current epoch because no reset or observed charge has | Run | Provider | Purpose | Maximum estimate | Observed cost | Cleanup proof | State | | --- | --- | --- | ---: | ---: | --- | --- | -| gate13-20260831-d | GCP | Fresh Gate 13 route-first lifecycle using the already-qualified durable controller and host jobs, one bounded 16-hour route, sequential fresh 6-hour Windows/Qwen and Linux/Gemma clients, and explicit provider argument arrays [plan `sha256:d32050a51b8f696aa224fc7e748c9113e174e3c3069c1f8b2bc769b0c5ecea18`] | USD 56.00 | — | [Cost authorization](evidence/gate13-20260831-d-cost-authorization.json) records native/provider preflight at 2026-08-31T21:38:14Z, every exact target absent, L4 quota 1/0, protected-bootstrap health, immutable inputs, hard deadlines, and the unchanged route → Windows → Linux → cleanup sequence. | RESERVED | +| gate13-20260831-d | GCP | Fresh Gate 13 route-first lifecycle using the durable controller and host jobs, one bounded route and sequential clients [plan `sha256:d32050a51b8f696aa224fc7e748c9113e174e3c3069c1f8b2bc769b0c5ecea18`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-d-failed-attempt-and-cleanup.json) records passed route acceptance, the corrected headless S4U supervisor, one consumed Windows attempt that failed because its archive had not been downloaded, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | | gate13-20260831-c | GCP | Gate 13 durable route-first lifecycle with the same bounded 16-hour route and sequential 6-hour clients, new exact resources, and corrected explicit IAP target-tag arguments [plan `sha256:07b6cd399ef7a9733602dfc19a741feddec8d15e5f4b5bac7347a192675f6d9c`] | USD 56.00 | — | [Terminal-state and cleanup proof](evidence/gate13-20260831-c-terminal-state-and-cleanup.json) records a local terminal absence state without a durable provider execution record, retires the run ID without reset or reuse, proves every exact target absent, global GPU usage zero, and the protected bootstrap running. | CLEANED-COMMITTED | | gate13-20260831-b | GCP | Gate 13 durable route-first lifecycle: one 16-hour G2/L4 product route, then sequential fresh 6-hour Windows/Qwen and Linux/Gemma CPU clients at controller source `ee0c05f83035ad22015fb089f5cb30f1713076e6`, host-job source `0e16ac2aa088e0719e9c3c89451176544602bab3`, route source `f64a388a47b098ac7f69d2affc59816376b43bb1`, package source `1971f106cc5bf90724d938c986a719ce2744f3e7`, and plan `sha256:3f3f921ded6eed1729aff175f5c91b4effe1966a31c82bdbe41ed69075442d64` | USD 56.00 | — | [Failed start and cleanup](evidence/gate13-20260831-b-failed-start-and-cleanup.json) records passed preflight and persisted intent, one transient DHT firewall, IAP-tag argument rejection before VM creation, exact firewall cleanup, all run resources absent, and protected-bootstrap health. | CLEANED-COMMITTED | | gate13-20260831-a | GCP | Gate 13 replacement product-node route plus fresh CPU Windows/Linux packaged lifecycles at route source `f64a388a47b098ac7f69d2affc59816376b43bb1` and exact package source `1971f106cc5bf90724d938c986a719ce2744f3e7` [plan sha256:313f5d34eefd64c71e265bdb7044d8ef5f56550360a7e9a7104265434292fd69] | USD 52.00 | — | [Failed attempt and cleanup proof](evidence/gate13-20260831-a-failed-attempt-and-cleanup.json) records verified archive downloads but no completed lifecycle, the non-durable orchestration failure, consumed-client semantics, and exact absence of the route, both clients, all three disks, and both firewalls while the protected bootstrap remains running. The USD 52 maximum remains committed; after the owner raised the epoch ceiling to USD 500, USD 448 remains before a new reservation. | CLEANED-COMMITTED | diff --git a/docs/evidence/gate13-20260831-d-failed-attempt-and-cleanup.json b/docs/evidence/gate13-20260831-d-failed-attempt-and-cleanup.json new file mode 100644 index 000000000..118cca646 --- /dev/null +++ b/docs/evidence/gate13-20260831-d-failed-attempt-and-cleanup.json @@ -0,0 +1,76 @@ +{ + "schema_version": 1, + "scope": "gate13-failed-attempt-and-cleanup", + "run_id": "gate13-20260831-d", + "recorded_at": "2026-08-31T22:29:33Z", + "result": "failed_cleanup_verified", + "gate": 13, + "route_acceptance": { + "completed_before_client_create": true, + "result": "passed", + "evidence_digest": "sha256:1a8f1adc3bdf876466e4c4a6f66e35a1650286476cc0fd499092bd393f333624", + "primary_model": "Qwen3.5 2B", + "standby_model": "Gemma 4 E2B IT", + "fallback_and_restoration_passed": true, + "prompts_retained": false, + "outputs_retained": false, + "credentials_retained": false + }, + "windows_attempt": { + "lifecycle_run_id": "gate13-20260831-d-windows", + "attempt_ordinal": 1, + "result": "failed", + "failure_code": "windows_packaged_lifecycle_failed", + "terminal_failure_code": "lifecycle_failed", + "terminal_exit_code": 2, + "canonical_lifecycle_evidence_present": false, + "failure_record_sha256": "sha256:8d44d0f9529cf358e8941d1114265d654f537ab1cdc75a6944abe6a3b7f9b53e", + "linux_client_created": false + }, + "findings": [ + { + "class": "host_supervisor", + "cause": "The Interactive scheduled task never ran from a headless SSH session, and principal observation compared a scheduler-normalized leaf name to a qualified identity.", + "proof": "The old task remained Ready with Task Scheduler result 0x41303 and no status, terminal, evidence, or stderr record.", + "correction_commit": "3a1d1fcaa8430e3c0bfc47910666f11989eb0a3d", + "correction": "Use S4U with Limited run level and compare resolved principal SID to the current identity SID.", + "repaired_before_lifecycle_attempt": true + }, + { + "class": "client_prerequisite", + "cause": "The exact package downloader and config were staged, but the 2,695,065,068-byte archive itself was not downloaded before the one-attempt job was armed.", + "proof": "The repaired S4U supervisor ran, wrote its status and terminal records, and the lifecycle failed immediately with its bounded failure record; no canonical phase record was produced.", + "correction": "Download and verify the exact archive on the fresh client before host-job start; verify its fixed size and SHA-256 before consuming the attempt.", + "gcp_related": false + } + ], + "gate11_comparison": { + "repeated_gate11_gcp_issue": false, + "gate11_quota_fix_reused": true, + "route_product_acceptance_passed": true, + "gcp_route_or_quota_blocker": false, + "delay_class": "gate13_operator_and_supervisor_orchestration" + }, + "cleanup": { + "native_windows_task_absent": true, + "exact_instances_absent": true, + "exact_disks_absent": true, + "exact_firewalls_absent": true, + "regional_l4_limit": 1, + "regional_l4_usage": 0, + "verified_at": "2026-08-31T22:29:33Z", + "protected_bootstrap_deleted": false + }, + "cost": { + "maximum_committed_usd": "56.00", + "observed_cost_usd": null, + "additional_supervisor_repair_cost_usd": "0.00" + }, + "claims": { + "gate_13_passed": false, + "windows_lifecycle_passed": false, + "linux_lifecycle_started": false, + "run_id_reusable": false, + "resources_reusable": false + } +} From 0f481d73e712ea14eb022906e2e0debc0a6e9931 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:33:07 -0500 Subject: [PATCH 16/56] gate13: keep route setup reusable --- scripts/gate13_route_setup.sh | 72 +++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 scripts/gate13_route_setup.sh diff --git a/scripts/gate13_route_setup.sh b/scripts/gate13_route_setup.sh new file mode 100644 index 000000000..1874b2756 --- /dev/null +++ b/scripts/gate13_route_setup.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 +root=/tmp/gate13-route +wheel="$root/drift-2.3.0.dev2-py3-none-any.whl" +test "$(stat -c %s "$wheel")" = "389107" +test "$(sha256sum "$wheel" | cut -d' ' -f1)" = "2a4f30bad7ae897fed019bc7da330a09965adb35685d11abaeaebf7a1d40aa60" +test "$(sha256sum "$root/configure_product_route_node.py" | cut -d' ' -f1)" = "fc385f74e02ca955203b1fc5e8ae493c7f4ccd31bd7383c2ae0a1c461c91363e" +test "$(sha256sum "$root/gate11_product_node_acceptance.py" | cut -d' ' -f1)" = "bdcc9f499a7cd6b727c0e33a0c4c2b0e71e76e28f3f21cb99804a8f39edfa0d2" +apt-get update -qq +DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3-venv python3-pip curl +if ! id communityai >/dev/null 2>&1; then + useradd --system --create-home --home-dir /srv/communityai --shell /usr/sbin/nologin communityai +fi +install -d -m 0755 /opt/communityai +if [ ! -x /opt/communityai/venv/bin/drift ]; then + python3 -m venv /opt/communityai/venv + /opt/communityai/venv/bin/pip install --no-cache-dir "$wheel[api]" +fi +chmod -R a+rX /opt/communityai/venv +install -d -o communityai -g communityai -m 0700 /srv/communityai/qwen /srv/communityai/gemma /srv/communityai/cache +install -d -o root -g root -m 0755 /opt/communityai/bootstrap +cp -a "$root/catalog-v1/." /opt/communityai/bootstrap/ +public_ip="$(curl -fsS -H 'Metadata-Flavor: Google' http://metadata.google.internal/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip)" +test -n "$public_ip" +for role in qwen gemma; do + data="/srv/communityai/$role" + sudo -u communityai /opt/communityai/venv/bin/drift bootstrap /opt/communityai/bootstrap/catalog-bootstrap.json --data_dir "$data" --node_config "$data/node-config.json" >/dev/null +done +/opt/communityai/venv/bin/python "$root/configure_product_route_node.py" --config /srv/communityai/qwen/node-config.json --role primary --public-ip "$public_ip" --cache-root /srv/communityai/cache >/dev/null +/opt/communityai/venv/bin/python "$root/configure_product_route_node.py" --config /srv/communityai/gemma/node-config.json --role standby --public-ip "$public_ip" --cache-root /srv/communityai/cache >/dev/null +chown -R communityai:communityai /srv/communityai +cat >/etc/systemd/system/communityai-qwen.service <<'UNIT' +[Unit] +Description=CommunityAI Qwen public route +After=network-online.target +Wants=network-online.target +[Service] +Type=simple +User=communityai +Group=communityai +WorkingDirectory=/srv/communityai/qwen +ExecStart=/opt/communityai/venv/bin/drift node --config /srv/communityai/qwen/node-config.json --data_dir /srv/communityai/qwen --host 127.0.0.1 --port 8081 +Restart=on-failure +RestartSec=5 +TimeoutStopSec=30 +LimitCORE=0 +[Install] +WantedBy=multi-user.target +UNIT +cat >/etc/systemd/system/communityai-gemma.service <<'UNIT' +[Unit] +Description=CommunityAI Gemma public route +After=network-online.target +Wants=network-online.target +[Service] +Type=simple +User=communityai +Group=communityai +WorkingDirectory=/srv/communityai/gemma +ExecStart=/opt/communityai/venv/bin/drift node --config /srv/communityai/gemma/node-config.json --data_dir /srv/communityai/gemma --host 127.0.0.1 --port 8082 +Restart=on-failure +RestartSec=5 +TimeoutStopSec=30 +LimitCORE=0 +[Install] +WantedBy=multi-user.target +UNIT +systemctl daemon-reload +systemctl enable --now communityai-qwen.service communityai-gemma.service +rm -rf "$root" +printf 'route-setup=started\n' From a561c5846fd59b3f04448e6210ed7a1cf1d335c7 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:36:45 -0500 Subject: [PATCH 17/56] docs: reserve corrected Gate 13 run e --- docs/RELEASE_READINESS.md | 14 +- .../gate13-20260831-e-cost-authorization.json | 230 ++++++++++++++++++ 2 files changed, 238 insertions(+), 6 deletions(-) create mode 100644 docs/evidence/gate13-20260831-e-cost-authorization.json diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md index 898ae1c3d..92bc81a15 100644 --- a/docs/RELEASE_READINESS.md +++ b/docs/RELEASE_READINESS.md @@ -76,9 +76,10 @@ and binds its principal by SID. The repaired supervisor then ran, but the one-at failed immediately because the operator had staged the downloader without first downloading the exact archive. Windows, route, disks, and firewalls are absent, L4 usage is zero, and the protected bootstrap remains running. Its USD 56 maximum remains committed, bringing the epoch -total to USD 220 and leaving USD 280. No later mandatory gate is unblocked until a fresh run -downloads each exact archive before arming its host job and produces both complete 16-phase -records plus exact cleanup. +total to USD 220. Fresh [run `gate13-20260831-e`](evidence/gate13-20260831-e-cost-authorization.json) +reserves USD 56 and leaves USD 224. No later mandatory gate is unblocked until it downloads each +exact archive before arming its host job and produces both complete 16-phase records plus exact +cleanup. Do not work on the post-alpha items in the deferred table while an alpha gate can progress. Missing Docker, snapshots, local GPU hardware, or local host capacity is not an external @@ -110,7 +111,7 @@ longer consume the new authorization; later billing should still be recorded for | 10 | Implement automatic contributor model and block placement | PASSED | Signed bootstrap now installs one bounded `auto` worker. The local planner filters exact manifested candidates through owner policy and local resource ceilings, requires fresh authenticated replica coverage, targets the least-covered contiguous range with per-node jitter, reconciles exact-manifest launches through the existing artifact-verifying server and `WorkerSupervisor`, applies residency/cooldown/switch hysteresis, exposes placement reasons, and preserves an explicit operator pause across ineligibility or placement changes. A new or migrated worker must sign an expiring exact-manifest/range intent with fixed numeric resource claims and receive a remote DHT store acknowledgement (`exclude_self=True`) before entering the artifact path; invalid, rejected, or failed publication is fail-closed and cannot advance planner state, while a previously admitted placement is retained. Actual completed local generations feed exact-manifest demand, useful-throughput, and reliability through two bounded five-minute aggregate windows; no prompt, output, token ID, key, request ID, address, path, error, or per-request event is retained. Only a closed window with at least four completed routes may be signed by the separate router identity and published under the manifest-bound `demand-v1` DHT key with a 90-second lifetime and `exclude_self=True`. Consumers verify signature, exact schema/digest, lifetime, revocation, and replay ordering. The threshold-signed catalog may authorize 2–32 sorted RSA observer roots; missing or empty roots disable remote demand. Discovery discards unlisted identities before signature/replay work, excludes local and duplicate roots, isolates malformed records, requires two authorized roots, and medians at most 32 quantized observations. Observer keys are never generated or bundled: only a separately provisioned `route-demand.key` matching a signed root may publish, while ordinary nodes can consume without one. Any hot-edited root-list mismatch disables both publication and consumption until restart. Local utility is capped at 6 points and signed remote utility at 2, keeping the combined hint below the 10-point migration margin and 100-point replica step. Verified announcement and route-demand replay watermarks now survive restarts in one Windows-safe journal per raw manifest digest under the node data directory. Each strict journal is capped at 256 active identity scopes and 256 KiB, retains only public record kind, key ID, ordering tuple, record digest, and the bounded replay deadline, and is fsync-written through atomic replacement; malformed, duplicate, oversized, symlinked, non-regular, or unwritable state fails closed. The retained deadline prevents an older still-live record from returning after a short-lived newer record expires. The replay slice's 99-test focused protocol/discovery/planner/node-configuration matrix and 209-pass, 2-skip catalog/node/API superset pass. The Sybil slice's 122-test focused catalog/bootstrap/config/discovery matrix proves that 30 valid attacker keys plus one authorized root cannot reach threshold, two authorized roots aggregate without attacker weight, one high authorized vote cannot inflate a lower second vote, old catalogs remain signature-verifiable with remote demand disabled, and trust-epoch reload mismatches fail closed. A 190-pass, 1-skip catalog/protocol/planner/discovery/node/API superset also passes. Independent verification passed 146 focused tests and a 255-pass, 2-skip broader node/API superset, plus a native-Windows publication-boundary probe; formatting, import-order, import-smoke, and diff checks pass. The [explicit privacy review](AUTOMATIC_PLACEMENT_PRIVACY_V1.md) inventories collection, retention, public-key linkability, DHT/journal/API/log exposure, secure-deletion limits, and residual governance/host risks. Three executable privacy-contract tests fix the aggregate, intent, demand, replay, forbidden-field, and path-free warning schemas; the focused privacy/protocol/planner/discovery/node matrix passes 108 tests and the broader catalog/node/API matrix passes 258 tests with 2 skips. Independent privacy review passed 108 tests with 1 skip and a 225-pass, 2-skip broader subset; every caught observer-key exception and an unauthorized key produced no path, key ID, or exception detail, while prompt and identity-path schema injections failed closed. The [deterministic convergence and load acceptance](AUTOMATIC_PLACEMENT_ACCEPTANCE_V1.md) closes the remaining software gate: equal snapshots use node-specific 32-point model dispersion and range rendezvous ranks; a fixed 512-node cold cohort selects both models and every range below the 85% concentration boundary; two 4,096-node fresh-arrival cohorts remain below that boundary under maximum priority-aligned or standby demand; maximum demand causes zero incumbent migrations; one-replica loss migrates after residency without early reversal; rolling arrivals keep every model/block populated and repair an abrupt block loss. The alpha fails closed above 32 candidates or 512 blocks, permits one `auto` worker, clamps reconciliation to at least one second, and scans each candidate in one bounded pass. The focused planner/convergence/configuration matrix passes 78 tests and the broader catalog/protocol/discovery/node/API matrix passes 214 with 2 skips. A real Windows DHT round trip exposed and fixed a durable-replay multiprocessing regression: replay guards now omit/recreate their thread lock across serialization and reload persistent state; its 15-test protocol/network matrix passes. Independent verification reproduced the 78-test focus, passed an expanded 235-test matrix with 2 skips and the 15-test real-DHT probe, and exercised adversarial score, timing, 32-by-512 load, 1,000-case range-equivalence, and persistent replay-reload boundaries. This slice used no cloud resources and spent USD 0. | Gates 9–11 are passed. Gates 13–14 must now prove the packaged flow and real hardware ceilings using the published envelopes. | | 11 | Operate initial public alpha routes | PASSED | [Product-node run `route-20260830-j`](evidence/gate11node-20260830-a-lifecycle.json) installed the generic CommunityAI wheel on a bounded G2/L4 VM, verified the signed catalog, downloaded both exact manifested models directly from Hugging Face into one persistent shared cache, and used the product node's automatic workers to expose complete Qwen 24/24 primary and Gemma 35/35 standby routes. No model-specific image, cache mirror, or operator-transferred model artifact was used. The privacy-safe acceptance passed one-token primary inference, deliberate primary pause, automatic Gemma selection in 58.073 seconds, standby inference, Qwen restoration in 32.042 seconds, and restored inference. Both workers were stable before the drill. After Gate 13 released the L4, the preserved route was restored without changing its model cache or source, its ephemeral endpoint was rebound, both product-node services became active, and a fresh acceptance reproved Qwen 24/24 primary inference, automatic Gemma 35/35 fallback/inference, Qwen restoration, and restored inference. The protected bootstrap remains running. A corrected 4,800-second provider DELETE backstop was set for `2026-08-31T05:28:16.516Z`, earlier than the original deadline. [Post-backstop cleanup evidence](evidence/gate11route-20260830-j-backstop-cleanup.json) and an independent recheck prove the route instance, named disk, and both exact run-scoped firewall rules absent, all Gate 13 clients/disks absent, zero remaining route availability, and the protected bootstrap still running. The same-host standby is a bounded alpha fallback, not independent infrastructure redundancy; independent redundancy remains post-alpha. | Gate 11 acceptance evidence remains complete, but no product route is live after the corrected DELETE backstop. [Gate 13 run `gate13-20260831-a`](evidence/gate13-20260831-a-cost-authorization.json) now binds refreshed native authentication, fail-closed preflight, and a fresh USD 52 reservation for the replacement route and packaged clients. | | 12 | Create, publish, and bundle the minimal signed alpha catalog/bootstrap | PASSED | [Run `gate12-20260829-a`](evidence/gate12-20260829-alpha-catalog-publication.json) published the deterministic [`communityai-public-alpha-v1` bundle](../public-alpha/catalog-v1/bundle.json) from source `26be579`. Its threshold-one Ed25519 root signs sequence 1 with the exact qualified Qwen primary and Gemma standby manifests, one pinned public HTTPS mirror, one public seed, a one-route best-effort policy, and no unprovisioned route-demand roots. The canonical bundle binds five members and retains `complete_release_qualification=false`. All three public objects returned HTTP 200 with exact sizes, and a fresh empty consumer fetched them remotely, verified the signature/digests, and created the two-model `auto` node configuration. The private signing key remained ignored and uncommitted. The focused publication suite passes 32 tests, the catalog/bootstrap/model/desktop superset passes 92, and the run spent USD 0. | Preserve the branch-scoped mirror until a newly signed catalog sequence and packaged bootstrap migrate it. The Gate 11 acceptance and Gate 9 envelopes exist; [Gate 13 run `gate13-20260831-a`](evidence/gate13-20260831-a-cost-authorization.json) now authorizes the bounded replacement route and fresh packaged clients under the new epoch. Independent threshold holders and interchangeable mirror/seed governance are post-alpha. | -| 13 | Pass packaged clean-install inference on Windows and Linux | IN PROGRESS | [Prerequisite run `gate13-20260830-a-prerequisites`](evidence/gate13-20260830-a-prerequisites.json) established deterministic install archives, exact first-use bytes, strict provenance, and the canonical lifecycle contract. [Native-harness and production-package run `gate13-20260830-b`](evidence/gate13-20260830-b-native-harness-and-packages.json) now completes the native Windows Credential Manager/Job Object and Linux Secret Service/systemd-cgroup 16-phase adapters, exact worker and descendant cleanup proofs, 3,600-second acquisition bounds, and package/runtime/catalog cross-binding. Independent software review passed 134 focused tests plus a 113-pass broader matrix with 3 platform skips; the production-discovery correction passes 73 unittests, 4 pytest checks, self-test, formatting, and import checks. [Exact-source production run 33338872342](https://github.com/flujo-app/CommunityAI/actions/runs/33338872342) passed both jobs at source `1971f10` and published independently audited CUDA 12.4 archives: Windows `sha256:45e9cdb439bcb8a6d7ed67914a490f3bc2e12ea1981af944034d62d865f5adc6` (2,695,065,068 bytes) and Linux `sha256:f96d3ca651964380d4684855ab08682e8187b33386327ec3895cda25b43c2a00` (3,360,717,934 bytes). Pushed source `6787272` adds the fixed stdin-only artifact downloader and exact platform configs; its 42-test adversarial suite and independent race/special-member/live-wrapper audit pass. No cloud resource was created for these prerequisites. Real completed clean-host lifecycle evidence remains absent. [Run `gate13-20260831-a`](evidence/gate13-20260831-a-failed-attempt-and-cleanup.json) later verified both exact archives on fresh clients but produced neither canonical lifecycle record. Its state-free transient orchestration advanced both clients before route acceptance, and subsequent diagnostic/product launches consumed both hosts for fresh-host acceptance. Exact cleanup proves all three instances/disks and both firewalls absent while the protected bootstrap remains running. Source `ddfb7c6` adds a persisted authorization-bound state contract that enforces inventory-first reattachment, route acceptance, sequential Windows-then-Linux evidence, consumed-host failure semantics, deadline runway, and success only after both canonical records plus final absence. [Durable native-host-job prerequisite evidence](evidence/gate13-20260831-b-durable-host-job-prerequisite.json) binds pushed source `0e16ac2` and its base `2fe0de9`: exact-current-user Windows Scheduled Task and non-root Linux systemd jobs survive operator disconnects, permit exactly one attempt, bound stdout/stderr and complete-process-tree cleanup, exact-bind source/config/native inventory, and revalidate canonical evidence before collection. Local and independent nine-file matrices each pass 217 tests with 29 known warnings. No cloud resource was created and no lifecycle pass is claimed. | [Run `gate13-20260830-c` revision 13](evidence/gate13-20260830-c-cost-authorization.json) is stopped clean. The latest Windows host passed exact package audit, clean install, four desktop self-tests, and the packaged-node self-test, then failed before model acquisition because child stderr diagnostics contaminated strict JSON captured on stdout. [Attempt, cleanup, correction, and route-restoration evidence](evidence/gate13-20260830-c-windows-attempt-and-route-restore.json) proves zero cache bytes, no retained credential or product process, all four exact client instances/disks absent, the bootstrap running, and the temporary restored Qwen/Gemma product route. [Post-backstop cleanup evidence](evidence/gate11route-20260830-j-backstop-cleanup.json) now proves that route, its named disk, and both exact run-scoped firewall rules absent while every Gate 13 target remains absent and the protected bootstrap remains running. Pushed source `4818da3` separates captured stdout from a dedicated NUL stderr sink and passes 15 native tests plus independent high-volume, handle-leak, descendant, timeout, and Job Object probes, but it has not completed a paid clean-host lifecycle. The cleanup-backed 2026-08-31 owner reset releases the USD 98 historical maxima and opens a new USD 100 epoch for the next run; it does not authorize any particular resource or reuse the stopped record. A later native-auth recheck on 2026-08-31 obtained a real access token and passed Compute API access without exposing account or token material. [Run `gate13-20260831-a`](evidence/gate13-20260831-a-failed-attempt-and-cleanup.json) is failed and cleanup-proved; its USD 52 maximum remains committed. The owner's 2026-08-31 ceiling increase leaves USD 448 before a new reservation. Do not reuse its clients or authorization. The durable Windows Scheduled Task and Linux systemd host-job adapter prerequisite is complete at pushed source `0e16ac2`. Run `gate13-20260831-b` failed before VM creation and is cleanup-proved. Run `gate13-20260831-c` is source-bound and reserved at USD 56 after a fresh exact-absence preflight. Execute only the straightforward route-first, Windows collect/delete, Linux collect/delete, route-delete sequence. The next run must accept the complete route first, run/collect/delete Windows Qwen, then run/collect/delete Linux Gemma, and finally prove exact route cleanup. | +| 13 | Pass packaged clean-install inference on Windows and Linux | IN PROGRESS | [Prerequisite run `gate13-20260830-a-prerequisites`](evidence/gate13-20260830-a-prerequisites.json) established deterministic install archives, exact first-use bytes, strict provenance, and the canonical lifecycle contract. [Native-harness and production-package run `gate13-20260830-b`](evidence/gate13-20260830-b-native-harness-and-packages.json) now completes the native Windows Credential Manager/Job Object and Linux Secret Service/systemd-cgroup 16-phase adapters, exact worker and descendant cleanup proofs, 3,600-second acquisition bounds, and package/runtime/catalog cross-binding. Independent software review passed 134 focused tests plus a 113-pass broader matrix with 3 platform skips; the production-discovery correction passes 73 unittests, 4 pytest checks, self-test, formatting, and import checks. [Exact-source production run 33338872342](https://github.com/flujo-app/CommunityAI/actions/runs/33338872342) passed both jobs at source `1971f10` and published independently audited CUDA 12.4 archives: Windows `sha256:45e9cdb439bcb8a6d7ed67914a490f3bc2e12ea1981af944034d62d865f5adc6` (2,695,065,068 bytes) and Linux `sha256:f96d3ca651964380d4684855ab08682e8187b33386327ec3895cda25b43c2a00` (3,360,717,934 bytes). Pushed source `6787272` adds the fixed stdin-only artifact downloader and exact platform configs; its 42-test adversarial suite and independent race/special-member/live-wrapper audit pass. No cloud resource was created for these prerequisites. Real completed clean-host lifecycle evidence remains absent. [Run `gate13-20260831-a`](evidence/gate13-20260831-a-failed-attempt-and-cleanup.json) later verified both exact archives on fresh clients but produced neither canonical lifecycle record. Its state-free transient orchestration advanced both clients before route acceptance, and subsequent diagnostic/product launches consumed both hosts for fresh-host acceptance. Exact cleanup proves all three instances/disks and both firewalls absent while the protected bootstrap remains running. Source `ddfb7c6` adds a persisted authorization-bound state contract that enforces inventory-first reattachment, route acceptance, sequential Windows-then-Linux evidence, consumed-host failure semantics, deadline runway, and success only after both canonical records plus final absence. [Durable native-host-job prerequisite evidence](evidence/gate13-20260831-b-durable-host-job-prerequisite.json) binds pushed source `0e16ac2` and its base `2fe0de9`: exact-current-user Windows Scheduled Task and non-root Linux systemd jobs survive operator disconnects, permit exactly one attempt, bound stdout/stderr and complete-process-tree cleanup, exact-bind source/config/native inventory, and revalidate canonical evidence before collection. Local and independent nine-file matrices each pass 217 tests with 29 known warnings. No cloud resource was created and no lifecycle pass is claimed. | [Run `gate13-20260830-c` revision 13](evidence/gate13-20260830-c-cost-authorization.json) is stopped clean. The latest Windows host passed exact package audit, clean install, four desktop self-tests, and the packaged-node self-test, then failed before model acquisition because child stderr diagnostics contaminated strict JSON captured on stdout. [Attempt, cleanup, correction, and route-restoration evidence](evidence/gate13-20260830-c-windows-attempt-and-route-restore.json) proves zero cache bytes, no retained credential or product process, all four exact client instances/disks absent, the bootstrap running, and the temporary restored Qwen/Gemma product route. [Post-backstop cleanup evidence](evidence/gate11route-20260830-j-backstop-cleanup.json) now proves that route, its named disk, and both exact run-scoped firewall rules absent while every Gate 13 target remains absent and the protected bootstrap remains running. Pushed source `4818da3` separates captured stdout from a dedicated NUL stderr sink and passes 15 native tests plus independent high-volume, handle-leak, descendant, timeout, and Job Object probes, but it has not completed a paid clean-host lifecycle. The cleanup-backed 2026-08-31 owner reset releases the USD 98 historical maxima and opens a new USD 100 epoch for the next run; it does not authorize any particular resource or reuse the stopped record. A later native-auth recheck on 2026-08-31 obtained a real access token and passed Compute API access without exposing account or token material. [Run `gate13-20260831-a`](evidence/gate13-20260831-a-failed-attempt-and-cleanup.json) is failed and cleanup-proved; its USD 52 maximum remains committed. The owner's 2026-08-31 ceiling increase leaves USD 448 before a new reservation. Do not reuse its clients or authorization. The durable Windows Scheduled Task and Linux systemd host-job adapter prerequisite is complete at pushed source `0e16ac2`. Run `gate13-20260831-b` failed before VM creation and is cleanup-proved. Runs `gate13-20260831-c` and `gate13-20260831-d` are retired and cleanup-proved. Run `gate13-20260831-e` is source-bound and reserved at USD 56 after a fresh exact-absence preflight. Execute only the straightforward route-first, archive-download-before-start, Windows collect/delete, Linux collect/delete, route-delete sequence. The next run must accept the complete route first, run/collect/delete Windows Qwen, then run/collect/delete Linux Gemma, and finally prove exact route cleanup. | | 14 | Pass automatic-contribution and resource-control hardware checks | WAITING | [PR #11](https://github.com/flujo-app/CommunityAI/pull/11) and [PR #12](https://github.com/flujo-app/CommunityAI/pull/12) implemented the authenticated node-authoritative Sharing UI and atomic policy editing, but cross-model automatic placement and real packaged hardware evidence are absent. | After Gates 9–13, follow the [recovery runbook](RECOVERY_TEST_RUNBOOK.md) once for the clean-install product flow while validating model/block choice, exact selected-shard bytes, shared-cache affinity, download authorization, VRAM/storage/bandwidth/power limits, suspension, pause timing, cleanup, restart persistence, and unsupported telemetry on real packaged Windows/Linux hardware. | | 15 | Complete minimal alpha release engineering | WAITING | The desktop builder now emits a stable sorted `SHA256SUMS` inventory of exact regular-file bytes and safe relative in-bundle file symlinks, source/build/catalog-bound `provenance.json`, and `release-metadata.json` with explicit unsigned public-alpha, no-publisher-signature, no-authenticated-update, Windows/Linux-only, no-credits, and incomplete-qualification claims. Structural verification binds each safe file symlink to its canonical in-bundle target, digest, and size while rejecting changed, missing, extra, absolute, external, broken, cyclic, directory-linked/junction, special, traversal, or case-colliding payloads plus unsupported or noncanonical metadata. Exact-source builds also reject dirty relevant inputs, and the expected-input fresh-process check rejects rewritten commit/tree, workflow, platform, Python, PyInstaller, or catalog evidence. Production desktop CI is configured to verify and bundle the Gate 12 inputs, bind the exact clean Git commit/tree and workflow, revalidate every expected input separately, and upload all evidence on Windows/Linux. The focused release-input/artifact suite passes 15 tests, including fresh-process CLI, dirty-source, and canonical-rewrite checks, and the broader catalog/bootstrap/model/desktop subset passes 134. Independent verification reproduced all 134, passed 58 desktop unittests with two environment skips, formatting/import-order/YAML/diff checks, an expected Gate 12/workflow fresh-process probe, and real Windows junction rejection; no cloud was used. [The first PR #22 production-desktop run](https://github.com/flujo-app/CommunityAI/actions/runs/33273518744) reached packaging on both hosts and exposed two exact cross-platform defects: PyInstaller's legitimate relative internal Qt file symlink on Ubuntu and CRLF-transformed signed Gate 12 JSON on Windows. The follow-up binds safe internal file symlinks without accepting external or directory links, forces `public-alpha/**` to LF at checkout, and includes `.gitattributes` in the clean-source boundary. [The second run](https://github.com/flujo-app/CommunityAI/actions/runs/33274432423) proved the Ubuntu package and the Windows signed-bundle/provenance path, then exposed a stale desktop contribution-status schema 2 contract when the packaged node emitted schema 3 automatic-placement evidence. Source `fcd1f41` now strictly validates schema 3 placement and rejects stale schema 2 plus missing, extra, secret-bearing, or inconsistent placement data; its 50-test node/client/lifecycle/build focus and all 59 desktop unittests passed with two environment skips. [The final run](https://github.com/flujo-app/CommunityAI/actions/runs/33275216332) bound exact source `fcd1f417d1435557addb2d6cded9dac0827c7d8c` and completed both Windows and Ubuntu package jobs, including bundle build/smoke, independent checksum/provenance verification, the Windows packaged-node/native-credential/public-seed smoke, and artifact uploads; every PR style, test, and package check is green. Source `36d85d2` makes generic release-artifact fixtures select the supported Linux archive explicitly instead of inheriting the CI host platform; the 21-test local artifact suite and [PR #22 test run 33372581439](https://github.com/flujo-app/CommunityAI/actions/runs/33372581439) pass, without expanding the supported platform matrix. Clean-install lifecycle evidence remains absent. | Retain the verified Windows/Linux artifacts as engineering evidence, then test clean install, manual upgrade/reinstall, uninstall, retained-data choice for the persistent verified model cache, and recovery instructions on both platforms against a newly authorized live product-node route and the published Gate 9 envelopes. Do not mark passed from metadata/unit tests alone. Publisher signing and automatic authenticated update/rollback are post-alpha. | | 16 | Complete the bounded public-alpha safety canary | WAITING | [PR #13](https://github.com/flujo-app/CommunityAI/pull/13) and [PR #14](https://github.com/flujo-app/CommunityAI/pull/14) implemented bounded admission, privacy-safe aggregate health, training-off defaults, rollback procedures, and bounded routine rejection logs; no public canary has run. | After Gates 11–15, run a small monitored canary proving finite admission/timeouts, malformed-peer rejection, health reconstruction, privacy disclosure, route/catalog disable, and clean rollback. Exhaustive hostile-load, Sybil/collusion, partition, and long-soak campaigns are post-alpha. | @@ -136,8 +137,8 @@ Authorization applies only to CommunityAI qualification and public-alpha infrast The [owner-authorized ceiling](evidence/owner-budget-authorization-20260831.json) is USD 500 combined across new temporary GCP and Fly resources in the current accounting epoch. The existing USD 52 committed maximum and the three cleaned-failed USD 56 Gate 13 runs remain -charged. The fresh USD 56 `gate13-20260831-d` reservation brings the committed maximum to USD 220 -and leaves USD 280 unreserved. The existing +charged. The fresh USD 56 `gate13-20260831-e` reservation brings the committed maximum to USD 276 +and leaves USD 224 unreserved. The existing GCP bootstrap's ordinary baseline cost is tracked separately; never delete it as test cleanup. Before every paid run, add an entry with a conservative maximum. After cleanup, replace @@ -150,6 +151,7 @@ maximum still consumes the current epoch because no reset or observed charge has | Run | Provider | Purpose | Maximum estimate | Observed cost | Cleanup proof | State | | --- | --- | --- | ---: | ---: | --- | --- | +| gate13-20260831-e | GCP | Fresh Gate 13 route-first lifecycle with pinned reusable route setup, corrected S4U/SID Windows host job, explicit archive download-and-hash prerequisite, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:32ac935c0163aa8afb080e87eb65bed234610bde1609b36bb651e9f4346b5221`] | USD 56.00 | — | [Cost authorization](evidence/gate13-20260831-e-cost-authorization.json) records exact target absence, L4 quota 1/0, protected-bootstrap health, current immutable images, fixed inputs, hard deadlines, and download-before-start sequencing. | RESERVED | | gate13-20260831-d | GCP | Fresh Gate 13 route-first lifecycle using the durable controller and host jobs, one bounded route and sequential clients [plan `sha256:d32050a51b8f696aa224fc7e748c9113e174e3c3069c1f8b2bc769b0c5ecea18`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-d-failed-attempt-and-cleanup.json) records passed route acceptance, the corrected headless S4U supervisor, one consumed Windows attempt that failed because its archive had not been downloaded, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | | gate13-20260831-c | GCP | Gate 13 durable route-first lifecycle with the same bounded 16-hour route and sequential 6-hour clients, new exact resources, and corrected explicit IAP target-tag arguments [plan `sha256:07b6cd399ef7a9733602dfc19a741feddec8d15e5f4b5bac7347a192675f6d9c`] | USD 56.00 | — | [Terminal-state and cleanup proof](evidence/gate13-20260831-c-terminal-state-and-cleanup.json) records a local terminal absence state without a durable provider execution record, retires the run ID without reset or reuse, proves every exact target absent, global GPU usage zero, and the protected bootstrap running. | CLEANED-COMMITTED | | gate13-20260831-b | GCP | Gate 13 durable route-first lifecycle: one 16-hour G2/L4 product route, then sequential fresh 6-hour Windows/Qwen and Linux/Gemma CPU clients at controller source `ee0c05f83035ad22015fb089f5cb30f1713076e6`, host-job source `0e16ac2aa088e0719e9c3c89451176544602bab3`, route source `f64a388a47b098ac7f69d2affc59816376b43bb1`, package source `1971f106cc5bf90724d938c986a719ce2744f3e7`, and plan `sha256:3f3f921ded6eed1729aff175f5c91b4effe1966a31c82bdbe41ed69075442d64` | USD 56.00 | — | [Failed start and cleanup](evidence/gate13-20260831-b-failed-start-and-cleanup.json) records passed preflight and persisted intent, one transient DHT firewall, IAP-tag argument rejection before VM creation, exact firewall cleanup, all run resources absent, and protected-bootstrap health. | CLEANED-COMMITTED | diff --git a/docs/evidence/gate13-20260831-e-cost-authorization.json b/docs/evidence/gate13-20260831-e-cost-authorization.json new file mode 100644 index 000000000..b4ef00f14 --- /dev/null +++ b/docs/evidence/gate13-20260831-e-cost-authorization.json @@ -0,0 +1,230 @@ +{ + "schema_version": 1, + "scope": "gate13-packaged-lifecycle-cost-authorization", + "run_id": "gate13-20260831-e", + "gate": 13, + "result": "authorized", + "recorded_at": "2026-08-31", + "source": { + "reservation_commit": "0f481d73e712ea14eb022906e2e0debc0a6e9931", + "durable_controller_commit": "ee0c05f83035ad22015fb089f5cb30f1713076e6", + "host_job_adapter_commit": "3a1d1fcaa8430e3c0bfc47910666f11989eb0a3d", + "route_runtime_commit": "f64a388a47b098ac7f69d2affc59816376b43bb1", + "package_commit": "1971f106cc5bf90724d938c986a719ce2744f3e7", + "windows_lifecycle_helper_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "linux_lifecycle_helper_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3" + }, + "authorization": { + "combined_cloud_ceiling_usd": "500.00", + "ledger_committed_before_run_usd": "220.00", + "maximum_estimate_usd": "56.00", + "route_maximum_estimate_usd": "30.00", + "clients_maximum_estimate_usd": "26.00", + "remaining_after_run_maximum_usd": "224.00", + "reservation_recorded": true, + "provisioning_authorized_after_fail_closed_preflight": true, + "provider_calls_authorized_without_preflight": false, + "pricing_as_of": "2026-08-31", + "pricing_basis": "unchanged same-day Gate 13 ceilings: one bounded 16-hour G2/L4 route rounded to USD 30 and two sequential bounded 6-hour e2-standard-8 CPU clients at USD 26 total; each exact package archive must be downloaded and hash-verified before its one-attempt host job is armed" + }, + "immutable_inputs": { + "durable_controller": { + "path": "scripts/gate13_run_controller.py", + "source_commit": "ee0c05f83035ad22015fb089f5cb30f1713076e6", + "sha256": "3a33f1c0782222d400296c944651ca80a58fb67b4df79ce6a7d9c7216fd23b84", + "bytes": 34143 + }, + "host_job_adapter": { + "path": "scripts/gate13_host_job.py", + "source_commit": "3a1d1fcaa8430e3c0bfc47910666f11989eb0a3d", + "sha256": "922a9365269a2c1f0aee09473b15fdcc9b9a08522cfd5e9934145b4966b0ad31", + "bytes": 41592 + }, + "lifecycle_evidence_validator": { + "path": "scripts/gate13_packaged_lifecycle.py", + "source_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "sha256": "36e04fbb599dce57e2a7d9d105511e731ad0ceafaaf0b0820de7022711066157", + "bytes": 27268 + }, + "route_runtime_wheel": { + "filename": "drift-2.3.0.dev2-py3-none-any.whl", + "source_commit": "f64a388a47b098ac7f69d2affc59816376b43bb1", + "sha256": "2a4f30bad7ae897fed019bc7da330a09965adb35685d11abaeaebf7a1d40aa60", + "bytes": 389107, + "model_artifacts_embedded": false + }, + "windows_package": { + "sha256": "45e9cdb439bcb8a6d7ed67914a490f3bc2e12ea1981af944034d62d865f5adc6", + "bytes": 2695065068 + }, + "linux_package": { + "sha256": "f96d3ca651964380d4684855ab08682e8187b33386327ec3895cda25b43c2a00", + "bytes": 3360717934 + }, + "qwen_manifest": "sha256:3ba8528cb3c0d85e1ed048e0438a0d64cfbbc298944ed674caa6950d415f8e33", + "gemma_manifest": "sha256:2f8debbe0fcdf5af8d4c56c982210fa50aa584314968ae2617e2ccc2de9eafdd", + "windows_helper": { + "path": "scripts/gate13_windows_packaged_lifecycle.ps1", + "source_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "sha256": "9b860ff820851b31ac272ee4d28dd3a899f56014b8dfce45e912ba4fc9ab605c", + "bytes": 132097 + }, + "linux_helper": { + "path": "scripts/gate13_linux_packaged_lifecycle.py", + "source_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "sha256": "90f3af65bb4f77317f707a6b52e329e1d5f81cdeddcb9615a210ec9a5a4cf535", + "bytes": 113748 + }, + "windows_download_config": { + "path": "scripts/gate13_download_windows.json", + "sha256": "bf36b66bf22a8f4453ba481c5a4ad37d8e1856ad93ef25e5e105b566b39affc4", + "bytes": 293 + }, + "linux_download_config": { + "path": "scripts/gate13_download_linux.json", + "sha256": "c3e9dcd94ef0a8e61c95e650416a8c6ca8b169f4b6699a0acd06608e60da5550", + "bytes": 294 + }, + "route_setup": { + "path": "scripts/gate13_route_setup.sh", + "source_commit": "0f481d73e712ea14eb022906e2e0debc0a6e9931", + "sha256": "1972d54efe5b9ffb73c3d96e005edc238f7f57aaf720b202d091e541c82e044a", + "bytes": 3371 + } + }, + "provider_plan_digest": "sha256:32ac935c0163aa8afb080e87eb65bed234610bde1609b36bb651e9f4346b5221", + "provider_plan": { + "project": "community-ai-506321", + "route": { + "instance": "route-20260831-e-node", + "zone": "us-central1-a", + "machine_type": "g2-standard-8", + "accelerator": "1 x NVIDIA L4", + "image": "deeplearning-platform-release/common-cu129-ubuntu-2404-nvidia-580-v20260831", + "boot_disk_gib": 200, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 57600, + "termination_action": "DELETE", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "public_ports": [ + 31337, + 31338 + ], + "firewalls": [ + "route-20260831-e-dht", + "route-20260831-e-iap" + ], + "service_account": false, + "scopes": [], + "operator_access_target_tags": [ + "route-20260831-e-node", + "gate13-20260831-e-client" + ] + }, + "clients": [ + { + "platform": "windows", + "model": "Qwen3.5 2B", + "instance": "gate13-20260831-e-win", + "zone": "us-central1-a", + "machine_type": "e2-standard-8", + "image": "windows-server-2025-dc-v20260814", + "image_project": "windows-cloud", + "boot_disk_gib": 120, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 21600, + "termination_action": "DELETE", + "lifecycle_run_id": "gate13-20260831-e-windows", + "device_profile": "cpu", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "network_tag": "gate13-20260831-e-client" + }, + { + "platform": "linux", + "model": "Gemma 4 E2B IT", + "instance": "gate13-20260831-e-linux", + "zone": "us-central1-a", + "machine_type": "e2-standard-8", + "image": "ubuntu-2404-noble-amd64-v20260826", + "image_project": "ubuntu-os-cloud", + "boot_disk_gib": 120, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 21600, + "termination_action": "DELETE", + "lifecycle_run_id": "gate13-20260831-e-linux", + "device_profile": "cpu", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "network_tag": "gate13-20260831-e-client" + } + ], + "sequencing": { + "route_live_for_both_lifecycles": true, + "clients_may_run_concurrently": false, + "route_accepted_before_any_client_create": true, + "windows_collected_and_deleted_before_linux_create": true, + "all_16_phases_required_per_platform": true, + "exact_cleanup_before_pass": true + } + }, + "preflight": { + "read_only_checked_at": "2026-08-31T22:35:03Z", + "native_gcloud_token_refresh": true, + "compute_api_access": true, + "exact_target_instances_absent": true, + "exact_target_disks_absent": true, + "exact_target_firewalls_absent": true, + "machine_types_available": true, + "images_available": true, + "one_l4_quota_free": true, + "protected_bootstrap_running": true, + "final_software_tests_passed": 23, + "independent_review_matrix_before_final_reservation_guard": 0, + "independent_reservation_guard_finding_resolved": true, + "fresh_native_revalidation_immediately_before_create_required": true + }, + "cleanup": { + "delete_only_exact_run_resources": true, + "verify_absent": [ + "route-20260831-e-node", + "route-20260831-e-node boot disk", + "route-20260831-e-dht", + "route-20260831-e-iap", + "gate13-20260831-e-win", + "gate13-20260831-e-win boot disk", + "gate13-20260831-e-linux", + "gate13-20260831-e-linux boot disk" + ], + "protected_resources": [ + "communityai-bootstrap-1", + "all resources not named by this plan" + ], + "cleanup_required_on_success_or_failure": true + }, + "prohibited": { + "fly_operations": 0, + "model_specific_images_or_mirrors": 0, + "macos_work": 0, + "credits_or_payments_work": 0 + }, + "privacy": { + "credentials_retained": false, + "prompts_retained": false, + "outputs_retained": false, + "endpoints_retained": false, + "provider_output_retained": false, + "private_paths_retained": false + }, + "plan_revision": { + "revision": 2, + "previous_provider_plan_digest": "sha256:d32050a51b8f696aa224fc7e748c9113e174e3c3069c1f8b2bc769b0c5ecea18", + "reason": "fresh replacement after run d consumed Windows without a downloaded archive; exact resources change, the fixed S4U/SID host adapter is pinned, and each archive must be downloaded and verified before host-job start", + "resource_set_changed": true, + "cost_ceiling_changed": false + } +} From e547e2b275ba0e30c1ba895a989eda4e14e60ca9 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:40:57 -0500 Subject: [PATCH 18/56] docs: move Gate 13 route to available zone --- docs/RELEASE_READINESS.md | 2 +- .../gate13-20260831-e-cost-authorization.json | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md index 92bc81a15..b124e29dd 100644 --- a/docs/RELEASE_READINESS.md +++ b/docs/RELEASE_READINESS.md @@ -151,7 +151,7 @@ maximum still consumes the current epoch because no reset or observed charge has | Run | Provider | Purpose | Maximum estimate | Observed cost | Cleanup proof | State | | --- | --- | --- | ---: | ---: | --- | --- | -| gate13-20260831-e | GCP | Fresh Gate 13 route-first lifecycle with pinned reusable route setup, corrected S4U/SID Windows host job, explicit archive download-and-hash prerequisite, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:32ac935c0163aa8afb080e87eb65bed234610bde1609b36bb651e9f4346b5221`] | USD 56.00 | — | [Cost authorization](evidence/gate13-20260831-e-cost-authorization.json) records exact target absence, L4 quota 1/0, protected-bootstrap health, current immutable images, fixed inputs, hard deadlines, and download-before-start sequencing. | RESERVED | +| gate13-20260831-e | GCP | Fresh Gate 13 route-first lifecycle with pinned reusable route setup, corrected S4U/SID Windows host job, explicit archive download-and-hash prerequisite, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:0b8c423090e27ec792c55287676d753db4042a5817d25d550d2ef5f2e4b4119b`] | USD 56.00 | — | [Cost authorization](evidence/gate13-20260831-e-cost-authorization.json) records exact target absence, L4 quota 1/0, protected-bootstrap health, current immutable images, fixed inputs, hard deadlines, and download-before-start sequencing. | RESERVED | | gate13-20260831-d | GCP | Fresh Gate 13 route-first lifecycle using the durable controller and host jobs, one bounded route and sequential clients [plan `sha256:d32050a51b8f696aa224fc7e748c9113e174e3c3069c1f8b2bc769b0c5ecea18`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-d-failed-attempt-and-cleanup.json) records passed route acceptance, the corrected headless S4U supervisor, one consumed Windows attempt that failed because its archive had not been downloaded, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | | gate13-20260831-c | GCP | Gate 13 durable route-first lifecycle with the same bounded 16-hour route and sequential 6-hour clients, new exact resources, and corrected explicit IAP target-tag arguments [plan `sha256:07b6cd399ef7a9733602dfc19a741feddec8d15e5f4b5bac7347a192675f6d9c`] | USD 56.00 | — | [Terminal-state and cleanup proof](evidence/gate13-20260831-c-terminal-state-and-cleanup.json) records a local terminal absence state without a durable provider execution record, retires the run ID without reset or reuse, proves every exact target absent, global GPU usage zero, and the protected bootstrap running. | CLEANED-COMMITTED | | gate13-20260831-b | GCP | Gate 13 durable route-first lifecycle: one 16-hour G2/L4 product route, then sequential fresh 6-hour Windows/Qwen and Linux/Gemma CPU clients at controller source `ee0c05f83035ad22015fb089f5cb30f1713076e6`, host-job source `0e16ac2aa088e0719e9c3c89451176544602bab3`, route source `f64a388a47b098ac7f69d2affc59816376b43bb1`, package source `1971f106cc5bf90724d938c986a719ce2744f3e7`, and plan `sha256:3f3f921ded6eed1729aff175f5c91b4effe1966a31c82bdbe41ed69075442d64` | USD 56.00 | — | [Failed start and cleanup](evidence/gate13-20260831-b-failed-start-and-cleanup.json) records passed preflight and persisted intent, one transient DHT firewall, IAP-tag argument rejection before VM creation, exact firewall cleanup, all run resources absent, and protected-bootstrap health. | CLEANED-COMMITTED | diff --git a/docs/evidence/gate13-20260831-e-cost-authorization.json b/docs/evidence/gate13-20260831-e-cost-authorization.json index b4ef00f14..a713674b9 100644 --- a/docs/evidence/gate13-20260831-e-cost-authorization.json +++ b/docs/evidence/gate13-20260831-e-cost-authorization.json @@ -92,12 +92,12 @@ "bytes": 3371 } }, - "provider_plan_digest": "sha256:32ac935c0163aa8afb080e87eb65bed234610bde1609b36bb651e9f4346b5221", + "provider_plan_digest": "sha256:0b8c423090e27ec792c55287676d753db4042a5817d25d550d2ef5f2e4b4119b", "provider_plan": { "project": "community-ai-506321", "route": { "instance": "route-20260831-e-node", - "zone": "us-central1-a", + "zone": "us-central1-b", "machine_type": "g2-standard-8", "accelerator": "1 x NVIDIA L4", "image": "deeplearning-platform-release/common-cu129-ubuntu-2404-nvidia-580-v20260831", @@ -173,7 +173,7 @@ } }, "preflight": { - "read_only_checked_at": "2026-08-31T22:35:03Z", + "read_only_checked_at": "2026-08-31T22:40:11Z", "native_gcloud_token_refresh": true, "compute_api_access": true, "exact_target_instances_absent": true, @@ -221,9 +221,9 @@ "private_paths_retained": false }, "plan_revision": { - "revision": 2, - "previous_provider_plan_digest": "sha256:d32050a51b8f696aa224fc7e748c9113e174e3c3069c1f8b2bc769b0c5ecea18", - "reason": "fresh replacement after run d consumed Windows without a downloaded archive; exact resources change, the fixed S4U/SID host adapter is pinned, and each archive must be downloaded and verified before host-job start", + "revision": 3, + "previous_provider_plan_digest": "sha256:32ac935c0163aa8afb080e87eb65bed234610bde1609b36bb651e9f4346b5221", + "reason": "the exact us-central1-a route request created no VM or disk and returned L4 stockout with provider-reported capacity in us-central1-b; only the route zone changes at unchanged cost while clients remain in us-central1-a", "resource_set_changed": true, "cost_ceiling_changed": false } From 6b27858e3a8f6658ef87880e1b5cfbe74068185c Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:41:48 -0500 Subject: [PATCH 19/56] docs: keep Gate 13 resources in available zone --- docs/RELEASE_READINESS.md | 2 +- .../gate13-20260831-e-cost-authorization.json | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md index b124e29dd..8f9b6bc22 100644 --- a/docs/RELEASE_READINESS.md +++ b/docs/RELEASE_READINESS.md @@ -151,7 +151,7 @@ maximum still consumes the current epoch because no reset or observed charge has | Run | Provider | Purpose | Maximum estimate | Observed cost | Cleanup proof | State | | --- | --- | --- | ---: | ---: | --- | --- | -| gate13-20260831-e | GCP | Fresh Gate 13 route-first lifecycle with pinned reusable route setup, corrected S4U/SID Windows host job, explicit archive download-and-hash prerequisite, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:0b8c423090e27ec792c55287676d753db4042a5817d25d550d2ef5f2e4b4119b`] | USD 56.00 | — | [Cost authorization](evidence/gate13-20260831-e-cost-authorization.json) records exact target absence, L4 quota 1/0, protected-bootstrap health, current immutable images, fixed inputs, hard deadlines, and download-before-start sequencing. | RESERVED | +| gate13-20260831-e | GCP | Fresh Gate 13 route-first lifecycle with pinned reusable route setup, corrected S4U/SID Windows host job, explicit archive download-and-hash prerequisite, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:9ca0fa516017c4a3709a467752f779bcb3bbc0a7c790f9bc61de56d385804c62`] | USD 56.00 | — | [Cost authorization](evidence/gate13-20260831-e-cost-authorization.json) records exact target absence, L4 quota 1/0, protected-bootstrap health, current immutable images, fixed inputs, hard deadlines, and download-before-start sequencing. | RESERVED | | gate13-20260831-d | GCP | Fresh Gate 13 route-first lifecycle using the durable controller and host jobs, one bounded route and sequential clients [plan `sha256:d32050a51b8f696aa224fc7e748c9113e174e3c3069c1f8b2bc769b0c5ecea18`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-d-failed-attempt-and-cleanup.json) records passed route acceptance, the corrected headless S4U supervisor, one consumed Windows attempt that failed because its archive had not been downloaded, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | | gate13-20260831-c | GCP | Gate 13 durable route-first lifecycle with the same bounded 16-hour route and sequential 6-hour clients, new exact resources, and corrected explicit IAP target-tag arguments [plan `sha256:07b6cd399ef7a9733602dfc19a741feddec8d15e5f4b5bac7347a192675f6d9c`] | USD 56.00 | — | [Terminal-state and cleanup proof](evidence/gate13-20260831-c-terminal-state-and-cleanup.json) records a local terminal absence state without a durable provider execution record, retires the run ID without reset or reuse, proves every exact target absent, global GPU usage zero, and the protected bootstrap running. | CLEANED-COMMITTED | | gate13-20260831-b | GCP | Gate 13 durable route-first lifecycle: one 16-hour G2/L4 product route, then sequential fresh 6-hour Windows/Qwen and Linux/Gemma CPU clients at controller source `ee0c05f83035ad22015fb089f5cb30f1713076e6`, host-job source `0e16ac2aa088e0719e9c3c89451176544602bab3`, route source `f64a388a47b098ac7f69d2affc59816376b43bb1`, package source `1971f106cc5bf90724d938c986a719ce2744f3e7`, and plan `sha256:3f3f921ded6eed1729aff175f5c91b4effe1966a31c82bdbe41ed69075442d64` | USD 56.00 | — | [Failed start and cleanup](evidence/gate13-20260831-b-failed-start-and-cleanup.json) records passed preflight and persisted intent, one transient DHT firewall, IAP-tag argument rejection before VM creation, exact firewall cleanup, all run resources absent, and protected-bootstrap health. | CLEANED-COMMITTED | diff --git a/docs/evidence/gate13-20260831-e-cost-authorization.json b/docs/evidence/gate13-20260831-e-cost-authorization.json index a713674b9..99820a7ee 100644 --- a/docs/evidence/gate13-20260831-e-cost-authorization.json +++ b/docs/evidence/gate13-20260831-e-cost-authorization.json @@ -92,7 +92,7 @@ "bytes": 3371 } }, - "provider_plan_digest": "sha256:0b8c423090e27ec792c55287676d753db4042a5817d25d550d2ef5f2e4b4119b", + "provider_plan_digest": "sha256:9ca0fa516017c4a3709a467752f779bcb3bbc0a7c790f9bc61de56d385804c62", "provider_plan": { "project": "community-ai-506321", "route": { @@ -128,7 +128,7 @@ "platform": "windows", "model": "Qwen3.5 2B", "instance": "gate13-20260831-e-win", - "zone": "us-central1-a", + "zone": "us-central1-b", "machine_type": "e2-standard-8", "image": "windows-server-2025-dc-v20260814", "image_project": "windows-cloud", @@ -147,7 +147,7 @@ "platform": "linux", "model": "Gemma 4 E2B IT", "instance": "gate13-20260831-e-linux", - "zone": "us-central1-a", + "zone": "us-central1-b", "machine_type": "e2-standard-8", "image": "ubuntu-2404-noble-amd64-v20260826", "image_project": "ubuntu-os-cloud", @@ -221,9 +221,9 @@ "private_paths_retained": false }, "plan_revision": { - "revision": 3, - "previous_provider_plan_digest": "sha256:32ac935c0163aa8afb080e87eb65bed234610bde1609b36bb651e9f4346b5221", - "reason": "the exact us-central1-a route request created no VM or disk and returned L4 stockout with provider-reported capacity in us-central1-b; only the route zone changes at unchanged cost while clients remain in us-central1-a", + "revision": 4, + "previous_provider_plan_digest": "sha256:0b8c423090e27ec792c55287676d753db4042a5817d25d550d2ef5f2e4b4119b", + "reason": "the controller requires route and sequential clients in one exact zone; after the us-central1-a L4 stockout created no VM or disk, the complete run moves to provider-recommended us-central1-b at unchanged cost", "resource_set_changed": true, "cost_ceiling_changed": false } From 2b895f59cabb8b294c8afad09dbcacfb51a0db6b Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:47:12 -0500 Subject: [PATCH 20/56] gate13: retain bounded Windows failure phase --- ...20260831-e-failed-attempt-and-cleanup.json | 95 +++++++++++++++++++ scripts/gate13_windows_packaged_lifecycle.ps1 | 18 +++- .../test_gate13_windows_packaged_lifecycle.py | 3 + 3 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 docs/evidence/gate13-20260831-e-failed-attempt-and-cleanup.json diff --git a/docs/evidence/gate13-20260831-e-failed-attempt-and-cleanup.json b/docs/evidence/gate13-20260831-e-failed-attempt-and-cleanup.json new file mode 100644 index 000000000..0092ed872 --- /dev/null +++ b/docs/evidence/gate13-20260831-e-failed-attempt-and-cleanup.json @@ -0,0 +1,95 @@ +{ + "schema_version": 1, + "scope": "gate13-failed-attempt-and-cleanup", + "run_id": "gate13-20260831-e", + "recorded_at": "2026-08-31T23:45:44Z", + "result": "failed_cleanup_verified", + "gate": 13, + "route_acceptance": { + "completed_before_client_create": true, + "result": "passed", + "evidence_digest": "sha256:064e1699bff2d0998c37ec6a3be5d37f8b6400a12de9627ea13fddb302508e55", + "primary_model": "Qwen3.5 2B", + "standby_model": "Gemma 4 E2B IT", + "fallback_and_restoration_passed": true, + "prompts_retained": false, + "outputs_retained": false, + "credentials_retained": false + }, + "windows_attempt": { + "lifecycle_run_id": "gate13-20260831-e-windows", + "attempt_ordinal": 1, + "result": "failed", + "failure_code": "windows_packaged_lifecycle_failed", + "terminal_failure_code": "lifecycle_failed", + "terminal_exit_code": 2, + "started_at_unix": 1788219070, + "finished_at_unix": 1788219581, + "elapsed_seconds": 511, + "canonical_lifecycle_evidence_present": false, + "failure_record_sha256": "sha256:8d44d0f9529cf358e8941d1114265d654f537ab1cdc75a6944abe6a3b7f9b53e", + "failure_stderr_bytes": 0, + "linux_client_created": false + }, + "pre_start_proofs": { + "windows_archive_bytes": 2695065068, + "windows_archive_sha256": "45e9cdb439bcb8a6d7ed67914a490f3bc2e12ea1981af944034d62d865f5adc6", + "archive_verified_before_attempt": true, + "ordinary_ssh_identity_verified": true, + "ordinary_ssh_identity_admin": false, + "scheduler_task_s4u": true, + "scheduler_task_run_level": "Limited", + "scheduler_running_observed_before_operator_demoted_account": true + }, + "findings": [ + { + "class": "windows_ssh_authorization", + "cause": "The Windows OpenSSH service allowed only Administrators and OpenSSH Users; removing M from Administrators without adding it to OpenSSH Users made the valid public key appear rejected.", + "proof": "The OpenSSH operational log reported that M was not allowed because none of the user's groups were listed in AllowGroups. Adding M to OpenSSH Users restored SSH while an independent token check reported is-admin=false.", + "correction": "Provision M in the existing OpenSSH Users group before removing it from Administrators.", + "gcp_related": false + }, + { + "class": "windows_supervisor_boundary", + "cause": "Windows Server 2025 denies an ordinary account access to the ScheduledTasks CIM provider and denies task registration in the root folder, even when the task principal is the same ordinary user.", + "proof": "Get-ScheduledTask failed with CIM access denied and a direct Task Scheduler COM registration probe failed with E_ACCESSDENIED. A privileged bootstrap could register the exact S4U/Limited task, which then ran durably.", + "correction": "Do not ask the ordinary account to provision its own native supervisor. For the next run, keep the bounded host adapter in the ordinary foreground and keep the IAP SSH transport durable at the operator boundary.", + "gcp_related": false + }, + { + "class": "lifecycle_diagnosability", + "cause": "The Windows lifecycle catch path emitted one generic failure record and failure cleanup removed the phase workspace, so the completed attempt could not identify the failed acceptance phase.", + "proof": "After 511 seconds the only lifecycle output was the 91-byte generic failure record and stderr was empty.", + "correction": "Emit only the hard-coded current phase name with the generic failure code; never emit exception text, paths, endpoints, prompts, outputs, or credentials.", + "gcp_related": false + } + ], + "gate11_comparison": { + "repeated_gate11_gcp_issue": false, + "gate11_quota_fix_reused": true, + "route_product_acceptance_passed": true, + "gcp_route_or_quota_blocker": false, + "delay_class": "gate13_windows_operator_boundary_and_packaged_lifecycle" + }, + "cleanup": { + "native_windows_task_absent_with_instance": true, + "exact_instances_absent": true, + "exact_disks_absent": true, + "exact_firewalls_absent": true, + "regional_l4_limit": 1, + "regional_l4_usage": 0, + "verified_at": "2026-08-31T23:45:44Z", + "protected_bootstrap_deleted": false + }, + "cost": { + "maximum_committed_usd": "56.00", + "observed_cost_usd": null + }, + "claims": { + "gate_13_passed": false, + "windows_lifecycle_passed": false, + "linux_lifecycle_started": false, + "run_id_reusable": false, + "resources_reusable": false + } +} diff --git a/scripts/gate13_windows_packaged_lifecycle.ps1 b/scripts/gate13_windows_packaged_lifecycle.ps1 index e516c2065..fc9741178 100644 --- a/scripts/gate13_windows_packaged_lifecycle.ps1 +++ b/scripts/gate13_windows_packaged_lifecycle.ps1 @@ -36,6 +36,7 @@ $script:LifecycleProcess = $null $script:LifecycleAcquisitionInvoked = $false $script:LifecycleOwnWorkRoot = $false $script:LifecycleOwnPersistentRoot = $false +$script:LifecycleFailurePhase = "initialization" function Initialize-Gate13NativeHost { if ($null -ne ("Gate13.NativeHost" -as [type])) { @@ -1147,6 +1148,7 @@ function Measure-Gate13Phase { [Parameter(Mandatory = $true)] [string] $Name, [Parameter(Mandatory = $true)] [scriptblock] $Action ) + $script:LifecycleFailurePhase = $Name $timer = [System.Diagnostics.Stopwatch]::StartNew() $facts = & $Action $timer.Stop() @@ -3212,6 +3214,7 @@ function Invoke-Gate13WindowsPackagedLifecycle { } })) + $script:LifecycleFailurePhase = "evidence_validation" $document = [ordered]@{ schema_version = 1 run_id = $state.Audit.RunId @@ -3278,10 +3281,19 @@ function Start-Gate13WindowsPackagedLifecycle { return 0 } catch { + $failurePhase = [string]$script:LifecycleFailurePhase + if ($failurePhase -notmatch '^[a-z_]{1,64}$') { + $failurePhase = "initialization" + } Invoke-Gate13FailureCleanup - [Console]::Out.WriteLine( - '{"failure_code":"windows_packaged_lifecycle_failed","result":"failed","schema_version":1}' - ) + [Console]::Out.WriteLine(( + [ordered]@{ + failure_code = "windows_packaged_lifecycle_failed" + failure_phase = $failurePhase + result = "failed" + schema_version = 1 + } | ConvertTo-Json -Compress + )) return 2 } finally { diff --git a/tests/test_gate13_windows_packaged_lifecycle.py b/tests/test_gate13_windows_packaged_lifecycle.py index 77fafc63b..e396e04c9 100644 --- a/tests/test_gate13_windows_packaged_lifecycle.py +++ b/tests/test_gate13_windows_packaged_lifecycle.py @@ -604,6 +604,9 @@ def test_adapter_contains_exact_safety_and_lifecycle_contracts(): positions = [lifecycle.index(f'-Name "{phase}"') for phase in phases] assert positions == sorted(positions) assert lifecycle.count('-Name "') >= len(phases) + assert "$script:LifecycleFailurePhase = $Name" in lifecycle + assert 'failure_phase = $failurePhase' in lifecycle + assert "ConvertTo-Json -Compress" in lifecycle for required in ( "CreateSuspended", From 6feb635d17bcd39687f68b9c50b3bcd91f6455ac Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:50:46 -0500 Subject: [PATCH 21/56] docs: authorize direct Gate 13 completion run --- docs/RELEASE_READINESS.md | 9 +- .../gate13-20260831-f-cost-authorization.json | 232 ++++++++++++++++++ 2 files changed, 237 insertions(+), 4 deletions(-) create mode 100644 docs/evidence/gate13-20260831-f-cost-authorization.json diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md index 8f9b6bc22..4039c30e2 100644 --- a/docs/RELEASE_READINESS.md +++ b/docs/RELEASE_READINESS.md @@ -136,9 +136,9 @@ longer consume the new authorization; later billing should still be recorded for Authorization applies only to CommunityAI qualification and public-alpha infrastructure. The [owner-authorized ceiling](evidence/owner-budget-authorization-20260831.json) is USD 500 combined across new temporary GCP and Fly resources in the current accounting epoch. The -existing USD 52 committed maximum and the three cleaned-failed USD 56 Gate 13 runs remain -charged. The fresh USD 56 `gate13-20260831-e` reservation brings the committed maximum to USD 276 -and leaves USD 224 unreserved. The existing +existing USD 52 committed maximum and the four cleaned-failed USD 56 Gate 13 runs remain +charged. The fresh USD 56 `gate13-20260831-f` reservation brings the committed maximum to USD 332 +and leaves USD 168 unreserved. The existing GCP bootstrap's ordinary baseline cost is tracked separately; never delete it as test cleanup. Before every paid run, add an entry with a conservative maximum. After cleanup, replace @@ -151,7 +151,8 @@ maximum still consumes the current epoch because no reset or observed charge has | Run | Provider | Purpose | Maximum estimate | Observed cost | Cleanup proof | State | | --- | --- | --- | ---: | ---: | --- | --- | -| gate13-20260831-e | GCP | Fresh Gate 13 route-first lifecycle with pinned reusable route setup, corrected S4U/SID Windows host job, explicit archive download-and-hash prerequisite, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:9ca0fa516017c4a3709a467752f779bcb3bbc0a7c790f9bc61de56d385804c62`] | USD 56.00 | — | [Cost authorization](evidence/gate13-20260831-e-cost-authorization.json) records exact target absence, L4 quota 1/0, protected-bootstrap health, current immutable images, fixed inputs, hard deadlines, and download-before-start sequencing. | RESERVED | +| gate13-20260831-f | GCP | Fresh Gate 13 route-first lifecycle using one durable foreground host-adapter execution over IAP SSH as each ordinary OS user, exact archive preflight, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:c9a2aafc84940df901a7db1755af2e684f845b78dcdfac04332cfed36388ba25`] | USD 56.00 | — | [Cost authorization](evidence/gate13-20260831-f-cost-authorization.json) records exact target absence, L4 quota 1/0, protected-bootstrap health, immutable inputs, phase-bounded failure output, hard deadlines, and download-before-execute sequencing. | RESERVED | +| gate13-20260831-e | GCP | Fresh Gate 13 route-first lifecycle with pinned reusable route setup, corrected S4U/SID Windows host job, explicit archive download-and-hash prerequisite, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:9ca0fa516017c4a3709a467752f779bcb3bbc0a7c790f9bc61de56d385804c62`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-e-failed-attempt-and-cleanup.json) records passed route acceptance, the exact Windows archive preflight, ordinary-user SSH repair, a durable S4U/Limited lifecycle failure with opaque phase output, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | | gate13-20260831-d | GCP | Fresh Gate 13 route-first lifecycle using the durable controller and host jobs, one bounded route and sequential clients [plan `sha256:d32050a51b8f696aa224fc7e748c9113e174e3c3069c1f8b2bc769b0c5ecea18`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-d-failed-attempt-and-cleanup.json) records passed route acceptance, the corrected headless S4U supervisor, one consumed Windows attempt that failed because its archive had not been downloaded, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | | gate13-20260831-c | GCP | Gate 13 durable route-first lifecycle with the same bounded 16-hour route and sequential 6-hour clients, new exact resources, and corrected explicit IAP target-tag arguments [plan `sha256:07b6cd399ef7a9733602dfc19a741feddec8d15e5f4b5bac7347a192675f6d9c`] | USD 56.00 | — | [Terminal-state and cleanup proof](evidence/gate13-20260831-c-terminal-state-and-cleanup.json) records a local terminal absence state without a durable provider execution record, retires the run ID without reset or reuse, proves every exact target absent, global GPU usage zero, and the protected bootstrap running. | CLEANED-COMMITTED | | gate13-20260831-b | GCP | Gate 13 durable route-first lifecycle: one 16-hour G2/L4 product route, then sequential fresh 6-hour Windows/Qwen and Linux/Gemma CPU clients at controller source `ee0c05f83035ad22015fb089f5cb30f1713076e6`, host-job source `0e16ac2aa088e0719e9c3c89451176544602bab3`, route source `f64a388a47b098ac7f69d2affc59816376b43bb1`, package source `1971f106cc5bf90724d938c986a719ce2744f3e7`, and plan `sha256:3f3f921ded6eed1729aff175f5c91b4effe1966a31c82bdbe41ed69075442d64` | USD 56.00 | — | [Failed start and cleanup](evidence/gate13-20260831-b-failed-start-and-cleanup.json) records passed preflight and persisted intent, one transient DHT firewall, IAP-tag argument rejection before VM creation, exact firewall cleanup, all run resources absent, and protected-bootstrap health. | CLEANED-COMMITTED | diff --git a/docs/evidence/gate13-20260831-f-cost-authorization.json b/docs/evidence/gate13-20260831-f-cost-authorization.json new file mode 100644 index 000000000..d55afdf6b --- /dev/null +++ b/docs/evidence/gate13-20260831-f-cost-authorization.json @@ -0,0 +1,232 @@ +{ + "schema_version": 1, + "scope": "gate13-packaged-lifecycle-cost-authorization", + "run_id": "gate13-20260831-f", + "gate": 13, + "result": "authorized", + "recorded_at": "2026-08-31", + "source": { + "reservation_commit": "2b895f59cabb8b294c8afad09dbcacfb51a0db6b", + "durable_controller_commit": "ee0c05f83035ad22015fb089f5cb30f1713076e6", + "host_job_adapter_commit": "3a1d1fcaa8430e3c0bfc47910666f11989eb0a3d", + "route_runtime_commit": "f64a388a47b098ac7f69d2affc59816376b43bb1", + "package_commit": "1971f106cc5bf90724d938c986a719ce2744f3e7", + "windows_lifecycle_helper_commit": "2b895f59cabb8b294c8afad09dbcacfb51a0db6b", + "linux_lifecycle_helper_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3" + }, + "authorization": { + "combined_cloud_ceiling_usd": "500.00", + "ledger_committed_before_run_usd": "276.00", + "maximum_estimate_usd": "56.00", + "route_maximum_estimate_usd": "30.00", + "clients_maximum_estimate_usd": "26.00", + "remaining_after_run_maximum_usd": "168.00", + "reservation_recorded": true, + "provisioning_authorized_after_fail_closed_preflight": true, + "provider_calls_authorized_without_preflight": false, + "pricing_as_of": "2026-08-31", + "pricing_basis": "unchanged same-day Gate 13 ceilings: one bounded 16-hour G2/L4 route rounded to USD 30 and two sequential bounded 6-hour e2-standard-8 CPU clients at USD 26 total; each exact package archive is downloaded and hash-verified before one durable foreground host-adapter execution over IAP SSH as the ordinary OS user" + }, + "immutable_inputs": { + "durable_controller": { + "path": "scripts/gate13_run_controller.py", + "source_commit": "ee0c05f83035ad22015fb089f5cb30f1713076e6", + "sha256": "3a33f1c0782222d400296c944651ca80a58fb67b4df79ce6a7d9c7216fd23b84", + "bytes": 34143 + }, + "host_job_adapter": { + "path": "scripts/gate13_host_job.py", + "source_commit": "3a1d1fcaa8430e3c0bfc47910666f11989eb0a3d", + "sha256": "922a9365269a2c1f0aee09473b15fdcc9b9a08522cfd5e9934145b4966b0ad31", + "bytes": 41592 + }, + "lifecycle_evidence_validator": { + "path": "scripts/gate13_packaged_lifecycle.py", + "source_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "sha256": "36e04fbb599dce57e2a7d9d105511e731ad0ceafaaf0b0820de7022711066157", + "bytes": 27268 + }, + "route_runtime_wheel": { + "filename": "drift-2.3.0.dev2-py3-none-any.whl", + "source_commit": "f64a388a47b098ac7f69d2affc59816376b43bb1", + "sha256": "2a4f30bad7ae897fed019bc7da330a09965adb35685d11abaeaebf7a1d40aa60", + "bytes": 389107, + "model_artifacts_embedded": false + }, + "windows_package": { + "sha256": "45e9cdb439bcb8a6d7ed67914a490f3bc2e12ea1981af944034d62d865f5adc6", + "bytes": 2695065068 + }, + "linux_package": { + "sha256": "f96d3ca651964380d4684855ab08682e8187b33386327ec3895cda25b43c2a00", + "bytes": 3360717934 + }, + "qwen_manifest": "sha256:3ba8528cb3c0d85e1ed048e0438a0d64cfbbc298944ed674caa6950d415f8e33", + "gemma_manifest": "sha256:2f8debbe0fcdf5af8d4c56c982210fa50aa584314968ae2617e2ccc2de9eafdd", + "windows_helper": { + "path": "scripts/gate13_windows_packaged_lifecycle.ps1", + "source_commit": "2b895f59cabb8b294c8afad09dbcacfb51a0db6b", + "sha256": "d6363fe00867f2b6ffccc855197ee078d808fa7890ec0234e73050db9d5aa6e1", + "bytes": 132564 + }, + "linux_helper": { + "path": "scripts/gate13_linux_packaged_lifecycle.py", + "source_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "sha256": "90f3af65bb4f77317f707a6b52e329e1d5f81cdeddcb9615a210ec9a5a4cf535", + "bytes": 113748 + }, + "windows_download_config": { + "path": "scripts/gate13_download_windows.json", + "sha256": "bf36b66bf22a8f4453ba481c5a4ad37d8e1856ad93ef25e5e105b566b39affc4", + "bytes": 293 + }, + "linux_download_config": { + "path": "scripts/gate13_download_linux.json", + "sha256": "c3e9dcd94ef0a8e61c95e650416a8c6ca8b169f4b6699a0acd06608e60da5550", + "bytes": 294 + }, + "route_setup": { + "path": "scripts/gate13_route_setup.sh", + "source_commit": "0f481d73e712ea14eb022906e2e0debc0a6e9931", + "sha256": "1972d54efe5b9ffb73c3d96e005edc238f7f57aaf720b202d091e541c82e044a", + "bytes": 3371 + } + }, + "provider_plan_digest": "sha256:c9a2aafc84940df901a7db1755af2e684f845b78dcdfac04332cfed36388ba25", + "provider_plan": { + "project": "community-ai-506321", + "route": { + "instance": "route-20260831-f-node", + "zone": "us-central1-b", + "machine_type": "g2-standard-8", + "accelerator": "1 x NVIDIA L4", + "image": "deeplearning-platform-release/common-cu129-ubuntu-2404-nvidia-580-v20260831", + "boot_disk_gib": 200, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 57600, + "termination_action": "DELETE", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "public_ports": [ + 31337, + 31338 + ], + "firewalls": [ + "route-20260831-f-dht", + "route-20260831-f-iap" + ], + "service_account": false, + "scopes": [], + "operator_access_target_tags": [ + "route-20260831-f-node", + "gate13-20260831-f-client" + ] + }, + "clients": [ + { + "platform": "windows", + "model": "Qwen3.5 2B", + "instance": "gate13-20260831-f-win", + "zone": "us-central1-b", + "machine_type": "e2-standard-8", + "image": "windows-server-2025-dc-v20260814", + "image_project": "windows-cloud", + "boot_disk_gib": 120, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 21600, + "termination_action": "DELETE", + "lifecycle_run_id": "gate13-20260831-f-windows", + "device_profile": "cpu", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "network_tag": "gate13-20260831-f-client", + "host_execution": "durable_iap_ssh_foreground_as_ordinary_user" + }, + { + "platform": "linux", + "model": "Gemma 4 E2B IT", + "instance": "gate13-20260831-f-linux", + "zone": "us-central1-b", + "machine_type": "e2-standard-8", + "image": "ubuntu-2404-noble-amd64-v20260826", + "image_project": "ubuntu-os-cloud", + "boot_disk_gib": 120, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 21600, + "termination_action": "DELETE", + "lifecycle_run_id": "gate13-20260831-f-linux", + "device_profile": "cpu", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "network_tag": "gate13-20260831-f-client", + "host_execution": "durable_iap_ssh_foreground_as_ordinary_user" + } + ], + "sequencing": { + "route_live_for_both_lifecycles": true, + "clients_may_run_concurrently": false, + "route_accepted_before_any_client_create": true, + "windows_collected_and_deleted_before_linux_create": true, + "all_16_phases_required_per_platform": true, + "exact_cleanup_before_pass": true, + "client_host_adapter_mode": "foreground_execute_over_durable_iap_ssh" + } + }, + "preflight": { + "read_only_checked_at": "2026-08-31T23:49:33Z", + "native_gcloud_token_refresh": true, + "compute_api_access": true, + "exact_target_instances_absent": true, + "exact_target_disks_absent": true, + "exact_target_firewalls_absent": true, + "machine_types_available": true, + "images_available": true, + "one_l4_quota_free": true, + "protected_bootstrap_running": true, + "final_software_tests_passed": 39, + "independent_review_matrix_before_final_reservation_guard": 0, + "independent_reservation_guard_finding_resolved": true, + "fresh_native_revalidation_immediately_before_create_required": true + }, + "cleanup": { + "delete_only_exact_run_resources": true, + "verify_absent": [ + "route-20260831-e-node", + "route-20260831-e-node boot disk", + "route-20260831-e-dht", + "route-20260831-e-iap", + "gate13-20260831-e-win", + "gate13-20260831-e-win boot disk", + "gate13-20260831-e-linux", + "gate13-20260831-e-linux boot disk" + ], + "protected_resources": [ + "communityai-bootstrap-1", + "all resources not named by this plan" + ], + "cleanup_required_on_success_or_failure": true + }, + "prohibited": { + "fly_operations": 0, + "model_specific_images_or_mirrors": 0, + "macos_work": 0, + "credits_or_payments_work": 0 + }, + "privacy": { + "credentials_retained": false, + "prompts_retained": false, + "outputs_retained": false, + "endpoints_retained": false, + "provider_output_retained": false, + "private_paths_retained": false + }, + "plan_revision": { + "revision": 1, + "reason": "replace the Windows Scheduled Tasks provisioning boundary with one durable foreground host-adapter execution over IAP SSH as the ordinary user; retain exact archives, route-first ordering, all sixteen phases, costs, privacy, and cleanup", + "resource_set_changed": true, + "cost_ceiling_changed": false + } +} From 8c56feced219c521818ef6ad79ec830fc6b30a20 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:49:20 -0500 Subject: [PATCH 22/56] gate13: preserve Windows user runtime environment --- docs/RELEASE_READINESS.md | 7 +- ...20260831-f-failed-attempt-and-cleanup.json | 96 +++++++++++++++++++ scripts/gate13_host_job.py | 30 +++++- scripts/gate13_windows_packaged_lifecycle.ps1 | 14 +++ tests/test_gate13_host_job.py | 22 +++++ .../test_gate13_windows_packaged_lifecycle.py | 3 + 6 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 docs/evidence/gate13-20260831-f-failed-attempt-and-cleanup.json diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md index 4039c30e2..811496fac 100644 --- a/docs/RELEASE_READINESS.md +++ b/docs/RELEASE_READINESS.md @@ -136,9 +136,8 @@ longer consume the new authorization; later billing should still be recorded for Authorization applies only to CommunityAI qualification and public-alpha infrastructure. The [owner-authorized ceiling](evidence/owner-budget-authorization-20260831.json) is USD 500 combined across new temporary GCP and Fly resources in the current accounting epoch. The -existing USD 52 committed maximum and the four cleaned-failed USD 56 Gate 13 runs remain -charged. The fresh USD 56 `gate13-20260831-f` reservation brings the committed maximum to USD 332 -and leaves USD 168 unreserved. The existing +existing USD 52 committed maximum and the five cleaned-failed USD 56 Gate 13 runs remain +charged, for a committed maximum of USD 332 and USD 168 unreserved. The existing GCP bootstrap's ordinary baseline cost is tracked separately; never delete it as test cleanup. Before every paid run, add an entry with a conservative maximum. After cleanup, replace @@ -151,7 +150,7 @@ maximum still consumes the current epoch because no reset or observed charge has | Run | Provider | Purpose | Maximum estimate | Observed cost | Cleanup proof | State | | --- | --- | --- | ---: | ---: | --- | --- | -| gate13-20260831-f | GCP | Fresh Gate 13 route-first lifecycle using one durable foreground host-adapter execution over IAP SSH as each ordinary OS user, exact archive preflight, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:c9a2aafc84940df901a7db1755af2e684f845b78dcdfac04332cfed36388ba25`] | USD 56.00 | — | [Cost authorization](evidence/gate13-20260831-f-cost-authorization.json) records exact target absence, L4 quota 1/0, protected-bootstrap health, immutable inputs, phase-bounded failure output, hard deadlines, and download-before-execute sequencing. | RESERVED | +| gate13-20260831-f | GCP | Fresh Gate 13 route-first lifecycle using one durable foreground host-adapter execution over IAP SSH as each ordinary OS user, exact archive preflight, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:c9a2aafc84940df901a7db1755af2e684f845b78dcdfac04332cfed36388ba25`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-f-failed-attempt-and-cleanup.json) records passed route acceptance, exact Windows archive and staged-input verification, the same bounded `signed_bootstrap` failure under a direct ordinary-user launch as under S4U, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | | gate13-20260831-e | GCP | Fresh Gate 13 route-first lifecycle with pinned reusable route setup, corrected S4U/SID Windows host job, explicit archive download-and-hash prerequisite, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:9ca0fa516017c4a3709a467752f779bcb3bbc0a7c790f9bc61de56d385804c62`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-e-failed-attempt-and-cleanup.json) records passed route acceptance, the exact Windows archive preflight, ordinary-user SSH repair, a durable S4U/Limited lifecycle failure with opaque phase output, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | | gate13-20260831-d | GCP | Fresh Gate 13 route-first lifecycle using the durable controller and host jobs, one bounded route and sequential clients [plan `sha256:d32050a51b8f696aa224fc7e748c9113e174e3c3069c1f8b2bc769b0c5ecea18`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-d-failed-attempt-and-cleanup.json) records passed route acceptance, the corrected headless S4U supervisor, one consumed Windows attempt that failed because its archive had not been downloaded, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | | gate13-20260831-c | GCP | Gate 13 durable route-first lifecycle with the same bounded 16-hour route and sequential 6-hour clients, new exact resources, and corrected explicit IAP target-tag arguments [plan `sha256:07b6cd399ef7a9733602dfc19a741feddec8d15e5f4b5bac7347a192675f6d9c`] | USD 56.00 | — | [Terminal-state and cleanup proof](evidence/gate13-20260831-c-terminal-state-and-cleanup.json) records a local terminal absence state without a durable provider execution record, retires the run ID without reset or reuse, proves every exact target absent, global GPU usage zero, and the protected bootstrap running. | CLEANED-COMMITTED | diff --git a/docs/evidence/gate13-20260831-f-failed-attempt-and-cleanup.json b/docs/evidence/gate13-20260831-f-failed-attempt-and-cleanup.json new file mode 100644 index 000000000..bde995797 --- /dev/null +++ b/docs/evidence/gate13-20260831-f-failed-attempt-and-cleanup.json @@ -0,0 +1,96 @@ +{ + "schema_version": 1, + "scope": "gate13-failed-attempt-and-cleanup", + "run_id": "gate13-20260831-f", + "recorded_at": "2026-09-01T00:46:17Z", + "result": "failed_cleanup_verified", + "gate": 13, + "route_acceptance": { + "completed_before_client_create": true, + "result": "passed", + "evidence_digest": "sha256:eb614c1b281a3661560536825edde1ad0e960c0c564e5aabd4900dd41bb2dea4", + "primary_model": "Qwen3.5 2B", + "standby_model": "Gemma 4 E2B IT", + "fallback_and_restoration_passed": true, + "total_duration_ms": 295357, + "prompts_retained": false, + "outputs_retained": false, + "credentials_retained": false + }, + "windows_attempt": { + "lifecycle_run_id": "gate13-20260831-f-windows", + "attempt_ordinal": 1, + "execution_transport": "durable_iap_ssh_foreground_as_ordinary_user", + "ordinary_user_admin": false, + "result": "failed", + "failure_code": "windows_packaged_lifecycle_failed", + "failure_phase": "signed_bootstrap", + "terminal_failure_code": "lifecycle_failed", + "terminal_exit_code": 2, + "started_at_unix": 1788222739, + "finished_at_unix": 1788223261, + "elapsed_seconds": 522, + "failure_record_bytes": 126, + "failure_stderr_bytes": 0, + "canonical_lifecycle_evidence_present": false, + "linux_client_created": false + }, + "pre_start_proofs": { + "windows_archive_bytes": 2695065068, + "windows_archive_sha256": "45e9cdb439bcb8a6d7ed67914a490f3bc2e12ea1981af944034d62d865f5adc6", + "archive_verified_before_attempt": true, + "staged_file_hashes_verified": 12, + "host_config_validated": true, + "status_absent_before_start": true, + "terminal_absent_before_start": true, + "evidence_absent_before_start": true + }, + "findings": [ + { + "class": "supervisor_hypothesis_eliminated", + "finding": "The direct ordinary-user foreground execution failed at the same elapsed time and same signed_bootstrap phase as the S4U/Limited execution.", + "conclusion": "Scheduled Tasks, S4U, SSH account authorization, and the privileged provisioning boundary are not the packaged lifecycle failure.", + "gcp_related": false + }, + { + "class": "windows_runtime_environment", + "finding": "The host adapter passed only SYSTEMROOT, WINDIR, TEMP, TMP, and USERPROFILE into the Windows lifecycle. It removed standard non-secret user runtime variables including APPDATA, LOCALAPPDATA, PROGRAMDATA, COMSPEC, PATH, and PATHEXT before the packaged desktop was started.", + "timing_inference": "The roughly 300-second difference between preliminary package work and terminal failure is consistent with Wait-Gate13ProductStatus exhausting its 300-second readiness bound in signed_bootstrap.", + "correction": "Keep a fixed allowlist of standard non-secret Windows runtime variables while continuing to exclude arbitrary variables and credential/token names.", + "gcp_related": false + }, + { + "class": "bounded_failure_localization", + "finding": "The phase-bounded failure record localized the deterministic defect to signed_bootstrap without retaining exception text, paths, endpoints, prompts, outputs, or credentials.", + "correction": "Add a hard-coded operation name inside signed_bootstrap for the next run while preserving the same privacy boundary.", + "gcp_related": false + } + ], + "gate11_comparison": { + "repeated_gate11_gcp_issue": false, + "gate11_quota_fix_reused": true, + "route_product_acceptance_passed": true, + "gcp_route_or_quota_blocker": false, + "delay_class": "gate13_windows_host_environment_contract" + }, + "cleanup": { + "exact_instances_absent": true, + "exact_disks_absent": true, + "exact_firewalls_absent": true, + "regional_l4_limit": 1, + "regional_l4_usage": 0, + "verified_at": "2026-09-01T00:46:17Z", + "protected_bootstrap_deleted": false + }, + "cost": { + "maximum_committed_usd": "56.00", + "observed_cost_usd": null + }, + "claims": { + "gate_13_passed": false, + "windows_lifecycle_passed": false, + "linux_lifecycle_started": false, + "run_id_reusable": false, + "resources_reusable": false + } +} diff --git a/scripts/gate13_host_job.py b/scripts/gate13_host_job.py index a659fcdc4..551743724 100644 --- a/scripts/gate13_host_job.py +++ b/scripts/gate13_host_job.py @@ -38,6 +38,34 @@ POSIX_SIGTERM = getattr(signal, "SIGTERM", 15) POSIX_SIGKILL = getattr(signal, "SIGKILL", 9) +WINDOWS_RUNTIME_ENVIRONMENT = ( + "ALLUSERSPROFILE", + "APPDATA", + "COMMONPROGRAMFILES", + "COMMONPROGRAMFILES(X86)", + "COMMONPROGRAMW6432", + "COMSPEC", + "HOMEDRIVE", + "HOMEPATH", + "LOCALAPPDATA", + "OS", + "PATH", + "PATHEXT", + "PROGRAMDATA", + "PROGRAMFILES", + "PROGRAMFILES(X86)", + "PROGRAMW6432", + "PUBLIC", + "SYSTEMDRIVE", + "SYSTEMROOT", + "TEMP", + "TMP", + "USERDOMAIN", + "USERNAME", + "USERPROFILE", + "WINDIR", +) + HOST_ROOTS = { "windows": Path(r"C:\Gate13Run"), "linux": Path("/qualification"), @@ -507,7 +535,7 @@ def _entrypoint_argv(config: HostJobConfig) -> list[str]: def _bounded_environment(config: HostJobConfig) -> dict[str, str]: allowed = ( - ("SYSTEMROOT", "WINDIR", "TEMP", "TMP", "USERPROFILE") + WINDOWS_RUNTIME_ENVIRONMENT if config.platform == "windows" else ("HOME", "LANG", "LC_ALL", "TMPDIR") ) diff --git a/scripts/gate13_windows_packaged_lifecycle.ps1 b/scripts/gate13_windows_packaged_lifecycle.ps1 index fc9741178..47f4da0ff 100644 --- a/scripts/gate13_windows_packaged_lifecycle.ps1 +++ b/scripts/gate13_windows_packaged_lifecycle.ps1 @@ -37,6 +37,7 @@ $script:LifecycleAcquisitionInvoked = $false $script:LifecycleOwnWorkRoot = $false $script:LifecycleOwnPersistentRoot = $false $script:LifecycleFailurePhase = "initialization" +$script:LifecycleFailureOperation = "initialization" function Initialize-Gate13NativeHost { if ($null -ne ("Gate13.NativeHost" -as [type])) { @@ -1149,6 +1150,7 @@ function Measure-Gate13Phase { [Parameter(Mandatory = $true)] [scriptblock] $Action ) $script:LifecycleFailurePhase = $Name + $script:LifecycleFailureOperation = $Name $timer = [System.Diagnostics.Stopwatch]::StartNew() $facts = & $Action $timer.Stop() @@ -2842,7 +2844,9 @@ function Invoke-Gate13WindowsPackagedLifecycle { })) [void]$phases.Add((Measure-Gate13Phase -Name "signed_bootstrap" -Action { + $script:LifecycleFailureOperation = "bootstrap_command" $state.Bootstrap = Invoke-Gate13Bootstrap + $script:LifecycleFailureOperation = "bootstrap_binding" if ( $state.Bootstrap.CatalogId -cne $state.Audit.PublicationCatalogId -or [int64]$state.Bootstrap.CatalogSequence -ne @@ -2854,8 +2858,11 @@ function Invoke-Gate13WindowsPackagedLifecycle { ) { throw "installed bootstrap did not match release provenance" } + $script:LifecycleFailureOperation = "product_start" Start-Gate13Product + $script:LifecycleFailureOperation = "product_readiness" $state.ProductStatus = Wait-Gate13ProductStatus -TimeoutSeconds 300 + $script:LifecycleFailureOperation = "profile_binding" $state.Profile = $state.ProductStatus.Profile if ( $state.Profile.ModelId -cne $state.Audit.ExpectedModelId -or @@ -2863,6 +2870,7 @@ function Invoke-Gate13WindowsPackagedLifecycle { ) { throw "operator-bound selected model identity rejected" } + $script:LifecycleFailureOperation = "selected_manifest_context" $state.Context = Get-Gate13SelectedManifestContext -Profile $state.Profile return [ordered]@{ catalog_id = $state.Bootstrap.CatalogId @@ -3215,6 +3223,7 @@ function Invoke-Gate13WindowsPackagedLifecycle { })) $script:LifecycleFailurePhase = "evidence_validation" + $script:LifecycleFailureOperation = "evidence_validation" $document = [ordered]@{ schema_version = 1 run_id = $state.Audit.RunId @@ -3285,10 +3294,15 @@ function Start-Gate13WindowsPackagedLifecycle { if ($failurePhase -notmatch '^[a-z_]{1,64}$') { $failurePhase = "initialization" } + $failureOperation = [string]$script:LifecycleFailureOperation + if ($failureOperation -notmatch '^[a-z_]{1,64}$') { + $failureOperation = $failurePhase + } Invoke-Gate13FailureCleanup [Console]::Out.WriteLine(( [ordered]@{ failure_code = "windows_packaged_lifecycle_failed" + failure_operation = $failureOperation failure_phase = $failurePhase result = "failed" schema_version = 1 diff --git a/tests/test_gate13_host_job.py b/tests/test_gate13_host_job.py index 84848a47e..84a16885f 100644 --- a/tests/test_gate13_host_job.py +++ b/tests/test_gate13_host_job.py @@ -82,6 +82,28 @@ def test_load_config_binds_exact_files_paths_and_single_attempt(config_factory): assert config.host_user == "gate13" +def test_windows_environment_keeps_standard_user_runtime_and_drops_secrets(config_factory, monkeypatch): + path, _raw = config_factory("windows") + config = host_job.load_config(path) + expected = { + "APPDATA": r"C:\\Users\\M\\AppData\\Roaming", + "LOCALAPPDATA": r"C:\\Users\\M\\AppData\\Local", + "PATH": r"C:\\Windows\\System32", + "USERPROFILE": r"C:\\Users\\M", + } + for key, value in expected.items(): + monkeypatch.setenv(key, value) + monkeypatch.setenv("GH_TOKEN", "must-not-cross-the-host-boundary") + monkeypatch.setenv("COMMUNITYAI_CONTROL_TOKEN", "must-not-cross-the-host-boundary") + + environment = host_job._bounded_environment(config) + + assert all(environment[key] == value for key, value in expected.items()) + assert set(environment).issubset(set(host_job.WINDOWS_RUNTIME_ENVIRONMENT)) + assert "GH_TOKEN" not in environment + assert "COMMUNITYAI_CONTROL_TOKEN" not in environment + + @pytest.mark.parametrize( ("field", "value"), [ diff --git a/tests/test_gate13_windows_packaged_lifecycle.py b/tests/test_gate13_windows_packaged_lifecycle.py index e396e04c9..68df07e0e 100644 --- a/tests/test_gate13_windows_packaged_lifecycle.py +++ b/tests/test_gate13_windows_packaged_lifecycle.py @@ -605,7 +605,10 @@ def test_adapter_contains_exact_safety_and_lifecycle_contracts(): assert positions == sorted(positions) assert lifecycle.count('-Name "') >= len(phases) assert "$script:LifecycleFailurePhase = $Name" in lifecycle + assert "$script:LifecycleFailureOperation = $Name" in lifecycle assert 'failure_phase = $failurePhase' in lifecycle + assert 'failure_operation = $failureOperation' in lifecycle + assert '"product_readiness"' in lifecycle assert "ConvertTo-Json -Compress" in lifecycle for required in ( From 4a1237ea88aeec590fdc3f43d8c2b283e90f16d3 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:52:19 -0500 Subject: [PATCH 23/56] docs: authorize corrected Gate 13 run --- docs/RELEASE_READINESS.md | 4 +- .../gate13-20260831-g-cost-authorization.json | 232 ++++++++++++++++++ 2 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 docs/evidence/gate13-20260831-g-cost-authorization.json diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md index 811496fac..22427d561 100644 --- a/docs/RELEASE_READINESS.md +++ b/docs/RELEASE_READINESS.md @@ -137,7 +137,8 @@ Authorization applies only to CommunityAI qualification and public-alpha infrast The [owner-authorized ceiling](evidence/owner-budget-authorization-20260831.json) is USD 500 combined across new temporary GCP and Fly resources in the current accounting epoch. The existing USD 52 committed maximum and the five cleaned-failed USD 56 Gate 13 runs remain -charged, for a committed maximum of USD 332 and USD 168 unreserved. The existing +charged. The fresh USD 56 `gate13-20260831-g` reservation brings the committed maximum to +USD 388 and leaves USD 112 unreserved. The existing GCP bootstrap's ordinary baseline cost is tracked separately; never delete it as test cleanup. Before every paid run, add an entry with a conservative maximum. After cleanup, replace @@ -150,6 +151,7 @@ maximum still consumes the current epoch because no reset or observed charge has | Run | Provider | Purpose | Maximum estimate | Observed cost | Cleanup proof | State | | --- | --- | --- | ---: | ---: | --- | --- | +| gate13-20260831-g | GCP | Corrected Gate 13 route-first lifecycle with a bounded standard Windows user-runtime environment, one durable foreground host-adapter execution as each ordinary OS user, exact archive preflight, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:f27f36158f2ad16019578555023cc854cb1e6e3b10ebae8cd3ed24d757b8e032`] | USD 56.00 | — | [Cost authorization](evidence/gate13-20260831-g-cost-authorization.json) records exact target absence, L4 quota 1/0, protected-bootstrap health, immutable corrected inputs, operation-bounded failure output, hard deadlines, and download-before-execute sequencing. | RESERVED | | gate13-20260831-f | GCP | Fresh Gate 13 route-first lifecycle using one durable foreground host-adapter execution over IAP SSH as each ordinary OS user, exact archive preflight, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:c9a2aafc84940df901a7db1755af2e684f845b78dcdfac04332cfed36388ba25`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-f-failed-attempt-and-cleanup.json) records passed route acceptance, exact Windows archive and staged-input verification, the same bounded `signed_bootstrap` failure under a direct ordinary-user launch as under S4U, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | | gate13-20260831-e | GCP | Fresh Gate 13 route-first lifecycle with pinned reusable route setup, corrected S4U/SID Windows host job, explicit archive download-and-hash prerequisite, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:9ca0fa516017c4a3709a467752f779bcb3bbc0a7c790f9bc61de56d385804c62`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-e-failed-attempt-and-cleanup.json) records passed route acceptance, the exact Windows archive preflight, ordinary-user SSH repair, a durable S4U/Limited lifecycle failure with opaque phase output, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | | gate13-20260831-d | GCP | Fresh Gate 13 route-first lifecycle using the durable controller and host jobs, one bounded route and sequential clients [plan `sha256:d32050a51b8f696aa224fc7e748c9113e174e3c3069c1f8b2bc769b0c5ecea18`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-d-failed-attempt-and-cleanup.json) records passed route acceptance, the corrected headless S4U supervisor, one consumed Windows attempt that failed because its archive had not been downloaded, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | diff --git a/docs/evidence/gate13-20260831-g-cost-authorization.json b/docs/evidence/gate13-20260831-g-cost-authorization.json new file mode 100644 index 000000000..dec7ad6b3 --- /dev/null +++ b/docs/evidence/gate13-20260831-g-cost-authorization.json @@ -0,0 +1,232 @@ +{ + "schema_version": 1, + "scope": "gate13-packaged-lifecycle-cost-authorization", + "run_id": "gate13-20260831-g", + "gate": 13, + "result": "authorized", + "recorded_at": "2026-08-31", + "source": { + "reservation_commit": "8c56feced219c521818ef6ad79ec830fc6b30a20", + "durable_controller_commit": "ee0c05f83035ad22015fb089f5cb30f1713076e6", + "host_job_adapter_commit": "8c56feced219c521818ef6ad79ec830fc6b30a20", + "route_runtime_commit": "f64a388a47b098ac7f69d2affc59816376b43bb1", + "package_commit": "1971f106cc5bf90724d938c986a719ce2744f3e7", + "windows_lifecycle_helper_commit": "8c56feced219c521818ef6ad79ec830fc6b30a20", + "linux_lifecycle_helper_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3" + }, + "authorization": { + "combined_cloud_ceiling_usd": "500.00", + "ledger_committed_before_run_usd": "332.00", + "maximum_estimate_usd": "56.00", + "route_maximum_estimate_usd": "30.00", + "clients_maximum_estimate_usd": "26.00", + "remaining_after_run_maximum_usd": "112.00", + "reservation_recorded": true, + "provisioning_authorized_after_fail_closed_preflight": true, + "provider_calls_authorized_without_preflight": false, + "pricing_as_of": "2026-08-31", + "pricing_basis": "unchanged same-day Gate 13 ceilings: one bounded 16-hour G2/L4 route rounded to USD 30 and two sequential bounded 6-hour e2-standard-8 CPU clients at USD 26 total; each exact package archive is downloaded and hash-verified before one durable foreground host-adapter execution over IAP SSH as the ordinary OS user" + }, + "immutable_inputs": { + "durable_controller": { + "path": "scripts/gate13_run_controller.py", + "source_commit": "ee0c05f83035ad22015fb089f5cb30f1713076e6", + "sha256": "3a33f1c0782222d400296c944651ca80a58fb67b4df79ce6a7d9c7216fd23b84", + "bytes": 34143 + }, + "host_job_adapter": { + "path": "scripts/gate13_host_job.py", + "source_commit": "8c56feced219c521818ef6ad79ec830fc6b30a20", + "sha256": "6a01626718a8cf7bd5743d2f07e20d808d6708280c9005cd6a03de8ff807567e", + "bytes": 42051 + }, + "lifecycle_evidence_validator": { + "path": "scripts/gate13_packaged_lifecycle.py", + "source_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "sha256": "36e04fbb599dce57e2a7d9d105511e731ad0ceafaaf0b0820de7022711066157", + "bytes": 27268 + }, + "route_runtime_wheel": { + "filename": "drift-2.3.0.dev2-py3-none-any.whl", + "source_commit": "f64a388a47b098ac7f69d2affc59816376b43bb1", + "sha256": "2a4f30bad7ae897fed019bc7da330a09965adb35685d11abaeaebf7a1d40aa60", + "bytes": 389107, + "model_artifacts_embedded": false + }, + "windows_package": { + "sha256": "45e9cdb439bcb8a6d7ed67914a490f3bc2e12ea1981af944034d62d865f5adc6", + "bytes": 2695065068 + }, + "linux_package": { + "sha256": "f96d3ca651964380d4684855ab08682e8187b33386327ec3895cda25b43c2a00", + "bytes": 3360717934 + }, + "qwen_manifest": "sha256:3ba8528cb3c0d85e1ed048e0438a0d64cfbbc298944ed674caa6950d415f8e33", + "gemma_manifest": "sha256:2f8debbe0fcdf5af8d4c56c982210fa50aa584314968ae2617e2ccc2de9eafdd", + "windows_helper": { + "path": "scripts/gate13_windows_packaged_lifecycle.ps1", + "source_commit": "8c56feced219c521818ef6ad79ec830fc6b30a20", + "sha256": "a85c51eb0231bcef991a77c57b622fad575049f000c8b95bbeec7f78eaec7a1e", + "bytes": 133351 + }, + "linux_helper": { + "path": "scripts/gate13_linux_packaged_lifecycle.py", + "source_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "sha256": "90f3af65bb4f77317f707a6b52e329e1d5f81cdeddcb9615a210ec9a5a4cf535", + "bytes": 113748 + }, + "windows_download_config": { + "path": "scripts/gate13_download_windows.json", + "sha256": "bf36b66bf22a8f4453ba481c5a4ad37d8e1856ad93ef25e5e105b566b39affc4", + "bytes": 293 + }, + "linux_download_config": { + "path": "scripts/gate13_download_linux.json", + "sha256": "c3e9dcd94ef0a8e61c95e650416a8c6ca8b169f4b6699a0acd06608e60da5550", + "bytes": 294 + }, + "route_setup": { + "path": "scripts/gate13_route_setup.sh", + "source_commit": "0f481d73e712ea14eb022906e2e0debc0a6e9931", + "sha256": "1972d54efe5b9ffb73c3d96e005edc238f7f57aaf720b202d091e541c82e044a", + "bytes": 3371 + } + }, + "provider_plan_digest": "sha256:f27f36158f2ad16019578555023cc854cb1e6e3b10ebae8cd3ed24d757b8e032", + "provider_plan": { + "project": "community-ai-506321", + "route": { + "instance": "route-20260831-g-node", + "zone": "us-central1-b", + "machine_type": "g2-standard-8", + "accelerator": "1 x NVIDIA L4", + "image": "deeplearning-platform-release/common-cu129-ubuntu-2404-nvidia-580-v20260831", + "boot_disk_gib": 200, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 57600, + "termination_action": "DELETE", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "public_ports": [ + 31337, + 31338 + ], + "firewalls": [ + "route-20260831-g-dht", + "route-20260831-g-iap" + ], + "service_account": false, + "scopes": [], + "operator_access_target_tags": [ + "route-20260831-g-node", + "gate13-20260831-g-client" + ] + }, + "clients": [ + { + "platform": "windows", + "model": "Qwen3.5 2B", + "instance": "gate13-20260831-g-win", + "zone": "us-central1-b", + "machine_type": "e2-standard-8", + "image": "windows-server-2025-dc-v20260814", + "image_project": "windows-cloud", + "boot_disk_gib": 120, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 21600, + "termination_action": "DELETE", + "lifecycle_run_id": "gate13-20260831-g-windows", + "device_profile": "cpu", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "network_tag": "gate13-20260831-g-client", + "host_execution": "durable_iap_ssh_foreground_as_ordinary_user" + }, + { + "platform": "linux", + "model": "Gemma 4 E2B IT", + "instance": "gate13-20260831-g-linux", + "zone": "us-central1-b", + "machine_type": "e2-standard-8", + "image": "ubuntu-2404-noble-amd64-v20260826", + "image_project": "ubuntu-os-cloud", + "boot_disk_gib": 120, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 21600, + "termination_action": "DELETE", + "lifecycle_run_id": "gate13-20260831-g-linux", + "device_profile": "cpu", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "network_tag": "gate13-20260831-g-client", + "host_execution": "durable_iap_ssh_foreground_as_ordinary_user" + } + ], + "sequencing": { + "route_live_for_both_lifecycles": true, + "clients_may_run_concurrently": false, + "route_accepted_before_any_client_create": true, + "windows_collected_and_deleted_before_linux_create": true, + "all_16_phases_required_per_platform": true, + "exact_cleanup_before_pass": true, + "client_host_adapter_mode": "foreground_execute_over_durable_iap_ssh" + } + }, + "preflight": { + "read_only_checked_at": "2026-09-01T00:51:42.840Z", + "native_gcloud_token_refresh": true, + "compute_api_access": true, + "exact_target_instances_absent": true, + "exact_target_disks_absent": true, + "exact_target_firewalls_absent": true, + "machine_types_available": true, + "images_available": true, + "one_l4_quota_free": true, + "protected_bootstrap_running": true, + "final_software_tests_passed": 40, + "independent_review_matrix_before_final_reservation_guard": 0, + "independent_reservation_guard_finding_resolved": true, + "fresh_native_revalidation_immediately_before_create_required": true + }, + "cleanup": { + "delete_only_exact_run_resources": true, + "verify_absent": [ + "route-20260831-g-node", + "route-20260831-g-node boot disk", + "route-20260831-g-dht", + "route-20260831-g-iap", + "gate13-20260831-g-win", + "gate13-20260831-g-win boot disk", + "gate13-20260831-g-linux", + "gate13-20260831-g-linux boot disk" + ], + "protected_resources": [ + "communityai-bootstrap-1", + "all resources not named by this plan" + ], + "cleanup_required_on_success_or_failure": true + }, + "prohibited": { + "fly_operations": 0, + "model_specific_images_or_mirrors": 0, + "macos_work": 0, + "credits_or_payments_work": 0 + }, + "privacy": { + "credentials_retained": false, + "prompts_retained": false, + "outputs_retained": false, + "endpoints_retained": false, + "provider_output_retained": false, + "private_paths_retained": false + }, + "plan_revision": { + "revision": 1, + "reason": "preserve a bounded allowlist of standard per-user Windows runtime variables required by the installed desktop package; retain direct ordinary-user execution, exact archives, route-first ordering, all sixteen phases, costs, privacy, and cleanup", + "resource_set_changed": true, + "cost_ceiling_changed": false + } +} From 0c67f0b8912ce8323cae2008642783b4aa23f436 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:25:13 -0500 Subject: [PATCH 24/56] docs: retire incomplete Gate 13 staging run --- docs/RELEASE_READINESS.md | 7 +- ...20260831-g-failed-attempt-and-cleanup.json | 90 +++++++++++++++++++ 2 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 docs/evidence/gate13-20260831-g-failed-attempt-and-cleanup.json diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md index 22427d561..6b757e23a 100644 --- a/docs/RELEASE_READINESS.md +++ b/docs/RELEASE_READINESS.md @@ -136,9 +136,8 @@ longer consume the new authorization; later billing should still be recorded for Authorization applies only to CommunityAI qualification and public-alpha infrastructure. The [owner-authorized ceiling](evidence/owner-budget-authorization-20260831.json) is USD 500 combined across new temporary GCP and Fly resources in the current accounting epoch. The -existing USD 52 committed maximum and the five cleaned-failed USD 56 Gate 13 runs remain -charged. The fresh USD 56 `gate13-20260831-g` reservation brings the committed maximum to -USD 388 and leaves USD 112 unreserved. The existing +existing USD 52 committed maximum and the six cleaned-failed USD 56 Gate 13 runs remain +charged, for a committed maximum of USD 388 and USD 112 unreserved. The existing GCP bootstrap's ordinary baseline cost is tracked separately; never delete it as test cleanup. Before every paid run, add an entry with a conservative maximum. After cleanup, replace @@ -151,7 +150,7 @@ maximum still consumes the current epoch because no reset or observed charge has | Run | Provider | Purpose | Maximum estimate | Observed cost | Cleanup proof | State | | --- | --- | --- | ---: | ---: | --- | --- | -| gate13-20260831-g | GCP | Corrected Gate 13 route-first lifecycle with a bounded standard Windows user-runtime environment, one durable foreground host-adapter execution as each ordinary OS user, exact archive preflight, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:f27f36158f2ad16019578555023cc854cb1e6e3b10ebae8cd3ed24d757b8e032`] | USD 56.00 | — | [Cost authorization](evidence/gate13-20260831-g-cost-authorization.json) records exact target absence, L4 quota 1/0, protected-bootstrap health, immutable corrected inputs, operation-bounded failure output, hard deadlines, and download-before-execute sequencing. | RESERVED | +| gate13-20260831-g | GCP | Corrected Gate 13 route-first lifecycle with a bounded standard Windows user-runtime environment, one durable foreground host-adapter execution as each ordinary OS user, exact archive preflight, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:f27f36158f2ad16019578555023cc854cb1e6e3b10ebae8cd3ed24d757b8e032`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-g-failed-attempt-and-cleanup.json) records passed route acceptance, the exact Windows archive, a two-second `package_verification` failure caused by four omitted existing audit inputs, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | | gate13-20260831-f | GCP | Fresh Gate 13 route-first lifecycle using one durable foreground host-adapter execution over IAP SSH as each ordinary OS user, exact archive preflight, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:c9a2aafc84940df901a7db1755af2e684f845b78dcdfac04332cfed36388ba25`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-f-failed-attempt-and-cleanup.json) records passed route acceptance, exact Windows archive and staged-input verification, the same bounded `signed_bootstrap` failure under a direct ordinary-user launch as under S4U, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | | gate13-20260831-e | GCP | Fresh Gate 13 route-first lifecycle with pinned reusable route setup, corrected S4U/SID Windows host job, explicit archive download-and-hash prerequisite, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:9ca0fa516017c4a3709a467752f779bcb3bbc0a7c790f9bc61de56d385804c62`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-e-failed-attempt-and-cleanup.json) records passed route acceptance, the exact Windows archive preflight, ordinary-user SSH repair, a durable S4U/Limited lifecycle failure with opaque phase output, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | | gate13-20260831-d | GCP | Fresh Gate 13 route-first lifecycle using the durable controller and host jobs, one bounded route and sequential clients [plan `sha256:d32050a51b8f696aa224fc7e748c9113e174e3c3069c1f8b2bc769b0c5ecea18`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-d-failed-attempt-and-cleanup.json) records passed route acceptance, the corrected headless S4U supervisor, one consumed Windows attempt that failed because its archive had not been downloaded, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | diff --git a/docs/evidence/gate13-20260831-g-failed-attempt-and-cleanup.json b/docs/evidence/gate13-20260831-g-failed-attempt-and-cleanup.json new file mode 100644 index 000000000..f3592182b --- /dev/null +++ b/docs/evidence/gate13-20260831-g-failed-attempt-and-cleanup.json @@ -0,0 +1,90 @@ +{ + "schema_version": 1, + "scope": "gate13-failed-attempt-and-cleanup", + "run_id": "gate13-20260831-g", + "recorded_at": "2026-09-01T01:24:46.392Z", + "result": "failed_cleanup_verified", + "provider_plan_digest": "sha256:f27f36158f2ad16019578555023cc854cb1e6e3b10ebae8cd3ed24d757b8e032", + "route_acceptance": { + "result": "passed", + "evidence_sha256": "04271d6ac93410974d26818f3c3f930f0031877ae6c6d11441c7e75569453633", + "total_duration_ms": 298408, + "qwen": { + "covered_blocks": 24, + "total_blocks": 24, + "peer_count": 1 + }, + "gemma": { + "covered_blocks": 35, + "total_blocks": 35, + "peer_count": 1 + }, + "primary_inference": true, + "fallback_inference": true, + "restoration_inference": true, + "prompts_retained": false, + "outputs_retained": false, + "credentials_retained_in_evidence": false + }, + "windows_attempt": { + "lifecycle_run_id": "gate13-20260831-g-windows", + "attempt_ordinal": 1, + "execution_transport": "durable_iap_ssh_foreground_as_ordinary_user", + "ordinary_user": "M", + "ordinary_user_admin": false, + "package_preflight": { + "archive_bytes": 2695065068, + "archive_sha256": "45e9cdb439bcb8a6d7ed67914a490f3bc2e12ea1981af944034d62d865f5adc6", + "download_result": "passed", + "url_retained": false + }, + "committed_adapter_sha256": "6a01626718a8cf7bd5743d2f07e20d808d6708280c9005cd6a03de8ff807567e", + "committed_entrypoint_sha256": "a85c51eb0231bcef991a77c57b622fad575049f000c8b95bbeec7f78eaec7a1e", + "started_at_unix": 1788225618, + "finished_at_unix": 1788225620, + "exit_code": 2, + "failure_code": "windows_packaged_lifecycle_failed", + "failure_phase": "package_verification", + "failure_operation": "package_verification", + "bounded_failure_record_bytes": 173, + "stderr_bytes": 0 + }, + "root_cause": { + "class": "operator_staging_omission", + "finding": "the fresh staging upload contained the eight executable and configuration files but omitted the four already-pinned release audit inputs required by Test-Gate13PackageAudit", + "missing_inputs": [ + "audit/desktop-metrics.json", + "audit/provenance.json", + "audit/release-metadata.json", + "audit/SHA256SUMS" + ], + "product_or_route_regression": false, + "next_attempt_change": "stage and hash-verify the four existing audit inputs before archive download and execution; no lifecycle or route redesign" + }, + "linux_attempt": { + "instance_created": false, + "attempt_ordinal": 0 + }, + "cleanup": { + "route_instance_absent": true, + "route_disk_absent": true, + "windows_instance_absent": true, + "windows_disk_absent": true, + "linux_instance_absent": true, + "linux_disk_absent": true, + "route_firewalls_absent": true, + "gpus_all_regions": { + "limit": 1, + "usage": 0 + }, + "protected_bootstrap_running": true + }, + "privacy": { + "credentials_retained": false, + "prompts_retained": false, + "outputs_retained": false, + "endpoints_retained": false, + "provider_output_retained": false, + "private_paths_retained": false + } +} From a7aabcc6092b61edc2496966191223885225d0d2 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:26:31 -0500 Subject: [PATCH 25/56] docs: authorize complete Gate 13 staging run --- docs/RELEASE_READINESS.md | 4 +- .../gate13-20260831-h-cost-authorization.json | 268 ++++++++++++++++++ 2 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 docs/evidence/gate13-20260831-h-cost-authorization.json diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md index 6b757e23a..baad692b2 100644 --- a/docs/RELEASE_READINESS.md +++ b/docs/RELEASE_READINESS.md @@ -137,7 +137,8 @@ Authorization applies only to CommunityAI qualification and public-alpha infrast The [owner-authorized ceiling](evidence/owner-budget-authorization-20260831.json) is USD 500 combined across new temporary GCP and Fly resources in the current accounting epoch. The existing USD 52 committed maximum and the six cleaned-failed USD 56 Gate 13 runs remain -charged, for a committed maximum of USD 388 and USD 112 unreserved. The existing +charged. The fresh USD 56 `gate13-20260831-h` reservation brings the committed maximum to +USD 444 and leaves USD 56 unreserved. The existing GCP bootstrap's ordinary baseline cost is tracked separately; never delete it as test cleanup. Before every paid run, add an entry with a conservative maximum. After cleanup, replace @@ -150,6 +151,7 @@ maximum still consumes the current epoch because no reset or observed charge has | Run | Provider | Purpose | Maximum estimate | Observed cost | Cleanup proof | State | | --- | --- | --- | ---: | ---: | --- | --- | +| gate13-20260831-h | GCP | Final corrected Gate 13 route-first lifecycle with both four-file release-audit bundles pinned and staged, the bounded Windows user-runtime environment, exact archive preflight, and sequential ordinary-user Windows/Qwen then Linux/Gemma clients [plan `sha256:f243254cc5fb65f44d0c9e707be36feb3284fd6e15b15620882843798fb456b1`] | USD 56.00 | — | [Cost authorization](evidence/gate13-20260831-h-cost-authorization.json) records exact target absence, L4 quota 1/0, protected-bootstrap health, all immutable executables, configs, archives, and audit inputs, hard deadlines, and route-first/download-before-execute sequencing. | RESERVED | | gate13-20260831-g | GCP | Corrected Gate 13 route-first lifecycle with a bounded standard Windows user-runtime environment, one durable foreground host-adapter execution as each ordinary OS user, exact archive preflight, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:f27f36158f2ad16019578555023cc854cb1e6e3b10ebae8cd3ed24d757b8e032`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-g-failed-attempt-and-cleanup.json) records passed route acceptance, the exact Windows archive, a two-second `package_verification` failure caused by four omitted existing audit inputs, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | | gate13-20260831-f | GCP | Fresh Gate 13 route-first lifecycle using one durable foreground host-adapter execution over IAP SSH as each ordinary OS user, exact archive preflight, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:c9a2aafc84940df901a7db1755af2e684f845b78dcdfac04332cfed36388ba25`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-f-failed-attempt-and-cleanup.json) records passed route acceptance, exact Windows archive and staged-input verification, the same bounded `signed_bootstrap` failure under a direct ordinary-user launch as under S4U, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | | gate13-20260831-e | GCP | Fresh Gate 13 route-first lifecycle with pinned reusable route setup, corrected S4U/SID Windows host job, explicit archive download-and-hash prerequisite, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:9ca0fa516017c4a3709a467752f779bcb3bbc0a7c790f9bc61de56d385804c62`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-e-failed-attempt-and-cleanup.json) records passed route acceptance, the exact Windows archive preflight, ordinary-user SSH repair, a durable S4U/Limited lifecycle failure with opaque phase output, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | diff --git a/docs/evidence/gate13-20260831-h-cost-authorization.json b/docs/evidence/gate13-20260831-h-cost-authorization.json new file mode 100644 index 000000000..ee9fa7d66 --- /dev/null +++ b/docs/evidence/gate13-20260831-h-cost-authorization.json @@ -0,0 +1,268 @@ +{ + "schema_version": 1, + "scope": "gate13-packaged-lifecycle-cost-authorization", + "run_id": "gate13-20260831-h", + "gate": 13, + "result": "authorized", + "recorded_at": "2026-08-31", + "source": { + "reservation_commit": "0c67f0b8912ce8323cae2008642783b4aa23f436", + "durable_controller_commit": "ee0c05f83035ad22015fb089f5cb30f1713076e6", + "host_job_adapter_commit": "8c56feced219c521818ef6ad79ec830fc6b30a20", + "route_runtime_commit": "f64a388a47b098ac7f69d2affc59816376b43bb1", + "package_commit": "1971f106cc5bf90724d938c986a719ce2744f3e7", + "windows_lifecycle_helper_commit": "8c56feced219c521818ef6ad79ec830fc6b30a20", + "linux_lifecycle_helper_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3" + }, + "authorization": { + "combined_cloud_ceiling_usd": "500.00", + "ledger_committed_before_run_usd": "388.00", + "maximum_estimate_usd": "56.00", + "route_maximum_estimate_usd": "30.00", + "clients_maximum_estimate_usd": "26.00", + "remaining_after_run_maximum_usd": "56.00", + "reservation_recorded": true, + "provisioning_authorized_after_fail_closed_preflight": true, + "provider_calls_authorized_without_preflight": false, + "pricing_as_of": "2026-08-31", + "pricing_basis": "unchanged same-day Gate 13 ceilings: one bounded 16-hour G2/L4 route rounded to USD 30 and two sequential bounded 6-hour e2-standard-8 CPU clients at USD 26 total; both pinned four-file release-audit bundles and each exact package archive are staged and hash-verified before one durable foreground host-adapter execution as the ordinary OS user" + }, + "immutable_inputs": { + "durable_controller": { + "path": "scripts/gate13_run_controller.py", + "source_commit": "ee0c05f83035ad22015fb089f5cb30f1713076e6", + "sha256": "3a33f1c0782222d400296c944651ca80a58fb67b4df79ce6a7d9c7216fd23b84", + "bytes": 34143 + }, + "host_job_adapter": { + "path": "scripts/gate13_host_job.py", + "source_commit": "8c56feced219c521818ef6ad79ec830fc6b30a20", + "sha256": "6a01626718a8cf7bd5743d2f07e20d808d6708280c9005cd6a03de8ff807567e", + "bytes": 42051 + }, + "lifecycle_evidence_validator": { + "path": "scripts/gate13_packaged_lifecycle.py", + "source_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "sha256": "36e04fbb599dce57e2a7d9d105511e731ad0ceafaaf0b0820de7022711066157", + "bytes": 27268 + }, + "route_runtime_wheel": { + "filename": "drift-2.3.0.dev2-py3-none-any.whl", + "source_commit": "f64a388a47b098ac7f69d2affc59816376b43bb1", + "sha256": "2a4f30bad7ae897fed019bc7da330a09965adb35685d11abaeaebf7a1d40aa60", + "bytes": 389107, + "model_artifacts_embedded": false + }, + "windows_package": { + "sha256": "45e9cdb439bcb8a6d7ed67914a490f3bc2e12ea1981af944034d62d865f5adc6", + "bytes": 2695065068 + }, + "linux_package": { + "sha256": "f96d3ca651964380d4684855ab08682e8187b33386327ec3895cda25b43c2a00", + "bytes": 3360717934 + }, + "qwen_manifest": "sha256:3ba8528cb3c0d85e1ed048e0438a0d64cfbbc298944ed674caa6950d415f8e33", + "gemma_manifest": "sha256:2f8debbe0fcdf5af8d4c56c982210fa50aa584314968ae2617e2ccc2de9eafdd", + "windows_helper": { + "path": "scripts/gate13_windows_packaged_lifecycle.ps1", + "source_commit": "8c56feced219c521818ef6ad79ec830fc6b30a20", + "sha256": "a85c51eb0231bcef991a77c57b622fad575049f000c8b95bbeec7f78eaec7a1e", + "bytes": 133351 + }, + "linux_helper": { + "path": "scripts/gate13_linux_packaged_lifecycle.py", + "source_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "sha256": "90f3af65bb4f77317f707a6b52e329e1d5f81cdeddcb9615a210ec9a5a4cf535", + "bytes": 113748 + }, + "windows_download_config": { + "path": "scripts/gate13_download_windows.json", + "sha256": "bf36b66bf22a8f4453ba481c5a4ad37d8e1856ad93ef25e5e105b566b39affc4", + "bytes": 293 + }, + "linux_download_config": { + "path": "scripts/gate13_download_linux.json", + "sha256": "c3e9dcd94ef0a8e61c95e650416a8c6ca8b169f4b6699a0acd06608e60da5550", + "bytes": 294 + }, + "route_setup": { + "path": "scripts/gate13_route_setup.sh", + "source_commit": "0f481d73e712ea14eb022906e2e0debc0a6e9931", + "sha256": "1972d54efe5b9ffb73c3d96e005edc238f7f57aaf720b202d091e541c82e044a", + "bytes": 3371 + }, + "windows_audit_bundle": { + "audit/desktop-metrics.json": { + "sha256": "953fc814d3d7d6787cbe7ecc25e8ab9f60c68515b94c80575207e15e78d69549", + "bytes": 3795 + }, + "audit/provenance.json": { + "sha256": "ac04b71d35493ba4967628af1ac05ca290b1af09aab4e8955ac09031c87ce7f8", + "bytes": 1241883 + }, + "audit/release-metadata.json": { + "sha256": "6a434cf14100572954452052b8a1e6e8565b2930e3251b1b8327cfdcd7383a25", + "bytes": 872 + }, + "audit/SHA256SUMS": { + "sha256": "a458760c1e4636956c9fba5a7da93ed869544f4817d5ab883b5d8b34cc9ad964", + "bytes": 674380 + } + }, + "linux_audit_bundle": { + "audit/desktop-metrics.json": { + "sha256": "5d2b261505e949a15c332c6e5bb817611e340bf897f9b1952ebde8e573e07bd2", + "bytes": 3798 + }, + "audit/provenance.json": { + "sha256": "c9b5e47017b003f6b2d81c9ab8273fcbf3c72f7743f1ebbf99383ae7cd5accda", + "bytes": 1357051 + }, + "audit/release-metadata.json": { + "sha256": "6a434cf14100572954452052b8a1e6e8565b2930e3251b1b8327cfdcd7383a25", + "bytes": 872 + }, + "audit/SHA256SUMS": { + "sha256": "4766c587a1d8e430f892128b8667c04ae7e83bde869a3098b24012c4f99cb74d", + "bytes": 737970 + } + } + }, + "provider_plan_digest": "sha256:f243254cc5fb65f44d0c9e707be36feb3284fd6e15b15620882843798fb456b1", + "provider_plan": { + "project": "community-ai-506321", + "route": { + "instance": "route-20260831-h-node", + "zone": "us-central1-b", + "machine_type": "g2-standard-8", + "accelerator": "1 x NVIDIA L4", + "image": "deeplearning-platform-release/common-cu129-ubuntu-2404-nvidia-580-v20260831", + "boot_disk_gib": 200, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 57600, + "termination_action": "DELETE", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "public_ports": [ + 31337, + 31338 + ], + "firewalls": [ + "route-20260831-h-dht", + "route-20260831-h-iap" + ], + "service_account": false, + "scopes": [], + "operator_access_target_tags": [ + "route-20260831-h-node", + "gate13-20260831-h-client" + ] + }, + "clients": [ + { + "platform": "windows", + "model": "Qwen3.5 2B", + "instance": "gate13-20260831-h-win", + "zone": "us-central1-b", + "machine_type": "e2-standard-8", + "image": "windows-server-2025-dc-v20260814", + "image_project": "windows-cloud", + "boot_disk_gib": 120, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 21600, + "termination_action": "DELETE", + "lifecycle_run_id": "gate13-20260831-h-windows", + "device_profile": "cpu", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "network_tag": "gate13-20260831-h-client", + "host_execution": "durable_iap_ssh_foreground_as_ordinary_user" + }, + { + "platform": "linux", + "model": "Gemma 4 E2B IT", + "instance": "gate13-20260831-h-linux", + "zone": "us-central1-b", + "machine_type": "e2-standard-8", + "image": "ubuntu-2404-noble-amd64-v20260826", + "image_project": "ubuntu-os-cloud", + "boot_disk_gib": 120, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 21600, + "termination_action": "DELETE", + "lifecycle_run_id": "gate13-20260831-h-linux", + "device_profile": "cpu", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "network_tag": "gate13-20260831-h-client", + "host_execution": "durable_iap_ssh_foreground_as_ordinary_user" + } + ], + "sequencing": { + "route_live_for_both_lifecycles": true, + "clients_may_run_concurrently": false, + "route_accepted_before_any_client_create": true, + "windows_collected_and_deleted_before_linux_create": true, + "all_16_phases_required_per_platform": true, + "exact_cleanup_before_pass": true, + "client_host_adapter_mode": "foreground_execute_over_durable_iap_ssh" + } + }, + "preflight": { + "read_only_checked_at": "2026-09-01T01:25:51.454Z", + "native_gcloud_token_refresh": true, + "compute_api_access": true, + "exact_target_instances_absent": true, + "exact_target_disks_absent": true, + "exact_target_firewalls_absent": true, + "machine_types_available": true, + "images_available": true, + "one_l4_quota_free": true, + "protected_bootstrap_running": true, + "final_software_tests_passed": 40, + "independent_review_matrix_before_final_reservation_guard": 0, + "independent_reservation_guard_finding_resolved": true, + "fresh_native_revalidation_immediately_before_create_required": true + }, + "cleanup": { + "delete_only_exact_run_resources": true, + "verify_absent": [ + "route-20260831-h-node", + "route-20260831-h-node boot disk", + "route-20260831-h-dht", + "route-20260831-h-iap", + "gate13-20260831-h-win", + "gate13-20260831-h-win boot disk", + "gate13-20260831-h-linux", + "gate13-20260831-h-linux boot disk" + ], + "protected_resources": [ + "communityai-bootstrap-1", + "all resources not named by this plan" + ], + "cleanup_required_on_success_or_failure": true + }, + "prohibited": { + "fly_operations": 0, + "model_specific_images_or_mirrors": 0, + "macos_work": 0, + "credits_or_payments_work": 0 + }, + "privacy": { + "credentials_retained": false, + "prompts_retained": false, + "outputs_retained": false, + "endpoints_retained": false, + "provider_output_retained": false, + "private_paths_retained": false + }, + "plan_revision": { + "revision": 1, + "reason": "stage and hash-verify the four already-pinned release audit inputs for each platform before archive download and ordinary-user execution; retain the corrected Windows runtime allowlist, exact archives, route-first ordering, all sixteen phases, costs, privacy, and cleanup", + "resource_set_changed": true, + "cost_ceiling_changed": false + } +} From 9ad67da56521b4abaa479da93642b87153ed006f Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:58:56 -0500 Subject: [PATCH 26/56] test: bind Gate 13 fixture to its ledger row --- tests/test_gate13_run_controller.py | 32 +++++++++++++++++++---------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/tests/test_gate13_run_controller.py b/tests/test_gate13_run_controller.py index 934bb857d..78fe4587b 100644 --- a/tests/test_gate13_run_controller.py +++ b/tests/test_gate13_run_controller.py @@ -19,6 +19,21 @@ LINUX_DIGEST = "sha256:" + "c" * 64 +def reserved_ledger_text(*, old_digest, new_digest): + lines = LEDGER.read_text(encoding="utf-8").splitlines(keepends=True) + matches = [ + index for index, line in enumerate(lines) if line.startswith("| gate13-20260831-a |") + ] + assert len(matches) == 1 + index = matches[0] + assert old_digest in lines[index] + assert lines[index].rstrip().endswith("| CLEANED-COMMITTED |") + lines[index] = lines[index].replace(old_digest, new_digest, 1).replace( + "| CLEANED-COMMITTED |", "| RESERVED |", 1 + ) + return "".join(lines) + + @pytest.fixture def plan(tmp_path): raw = json.loads(AUTHORIZATION.read_text(encoding="utf-8")) @@ -31,9 +46,7 @@ def plan(tmp_path): ledger = tmp_path / "ledger.md" ledger.write_text( - LEDGER.read_text(encoding="utf-8") - .replace(old_digest, new_digest, 1) - .replace("| CLEANED-COMMITTED |", "| RESERVED |", 1), + reserved_ledger_text(old_digest=old_digest, new_digest=new_digest), encoding="utf-8", ) return controller.load_plan(authorization, ledger) @@ -120,9 +133,7 @@ def test_load_plan_accepts_only_documented_owner_ceiling(tmp_path): authorization.write_text(json.dumps(raw), encoding="utf-8") ledger = tmp_path / "ledger.md" ledger.write_text( - LEDGER.read_text(encoding="utf-8") - .replace(old_digest, new_digest, 1) - .replace("| CLEANED-COMMITTED |", "| RESERVED |", 1), + reserved_ledger_text(old_digest=old_digest, new_digest=new_digest), encoding="utf-8", ) @@ -178,12 +189,11 @@ def test_non_reserved_ledger_allows_cleanup_only(): def test_reserved_parallel_client_plan_cannot_start(tmp_path): ledger = tmp_path / "ledger.md" + digest = json.loads(AUTHORIZATION.read_text(encoding="utf-8"))[ + "provider_plan_digest" + ] ledger.write_text( - LEDGER.read_text(encoding="utf-8").replace( - "| CLEANED-COMMITTED |", - "| RESERVED |", - 1, - ), + reserved_ledger_text(old_digest=digest, new_digest=digest), encoding="utf-8", ) parallel = controller.load_plan(AUTHORIZATION, ledger) From 805a1a518b86218bbcaefe058656e2a417018ecf Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:29:25 -0500 Subject: [PATCH 27/56] docs: authorize manual Gate 13 completion run --- docs/RELEASE_READINESS.md | 9 +- ...20260831-h-failed-attempt-and-cleanup.json | 54 ++++ .../gate13-20260831-i-cost-authorization.json | 291 ++++++++++++++++++ 3 files changed, 350 insertions(+), 4 deletions(-) create mode 100644 docs/evidence/gate13-20260831-h-failed-attempt-and-cleanup.json create mode 100644 docs/evidence/gate13-20260831-i-cost-authorization.json diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md index baad692b2..ff0578aee 100644 --- a/docs/RELEASE_READINESS.md +++ b/docs/RELEASE_READINESS.md @@ -136,9 +136,9 @@ longer consume the new authorization; later billing should still be recorded for Authorization applies only to CommunityAI qualification and public-alpha infrastructure. The [owner-authorized ceiling](evidence/owner-budget-authorization-20260831.json) is USD 500 combined across new temporary GCP and Fly resources in the current accounting epoch. The -existing USD 52 committed maximum and the six cleaned-failed USD 56 Gate 13 runs remain -charged. The fresh USD 56 `gate13-20260831-h` reservation brings the committed maximum to -USD 444 and leaves USD 56 unreserved. The existing +existing USD 52 committed maximum and the seven cleaned-failed USD 56 Gate 13 runs remain +charged. The final USD 56 `gate13-20260831-i` manual-playthrough reservation brings the +committed maximum to USD 500 and leaves USD 0 unreserved. The existing GCP bootstrap's ordinary baseline cost is tracked separately; never delete it as test cleanup. Before every paid run, add an entry with a conservative maximum. After cleanup, replace @@ -151,7 +151,8 @@ maximum still consumes the current epoch because no reset or observed charge has | Run | Provider | Purpose | Maximum estimate | Observed cost | Cleanup proof | State | | --- | --- | --- | ---: | ---: | --- | --- | -| gate13-20260831-h | GCP | Final corrected Gate 13 route-first lifecycle with both four-file release-audit bundles pinned and staged, the bounded Windows user-runtime environment, exact archive preflight, and sequential ordinary-user Windows/Qwen then Linux/Gemma clients [plan `sha256:f243254cc5fb65f44d0c9e707be36feb3284fd6e15b15620882843798fb456b1`] | USD 56.00 | — | [Cost authorization](evidence/gate13-20260831-h-cost-authorization.json) records exact target absence, L4 quota 1/0, protected-bootstrap health, all immutable executables, configs, archives, and audit inputs, hard deadlines, and route-first/download-before-execute sequencing. | RESERVED | +| gate13-20260831-i | GCP | Final Gate 13 manual playthrough: proven Gate 11 route first, then the exact Windows package in a real interactive ordinary-user console with visible node/product diagnostics and literal controls, followed by the same foreground Linux flow; lifecycle wrappers are prohibited until the manual steps pass [plan `sha256:8525c3099f273c099aba26de57c1f610a0c74cac65ed2640589d51e874bd0c44`] | USD 56.00 | — | [Cost authorization](evidence/gate13-20260831-i-cost-authorization.json) records exact target absence, L4 quota 1/0, protected-bootstrap health, exact packages/audits, virtual display, hard deadlines, privacy, sequencing, and exact cleanup. | RESERVED | +| gate13-20260831-h | GCP | Final corrected Gate 13 route-first lifecycle with both four-file release-audit bundles pinned and staged, the bounded Windows user-runtime environment, exact archive preflight, and sequential ordinary-user Windows/Qwen then Linux/Gemma clients [plan `sha256:f243254cc5fb65f44d0c9e707be36feb3284fd6e15b15620882843798fb456b1`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-h-failed-attempt-and-cleanup.json) records passed route acceptance and exact Windows archive verification, one Windows failure at `signed_bootstrap/product_readiness`, no Linux create, exact instance/disk/firewall absence, L4 usage zero, and protected-bootstrap health. | CLEANED-COMMITTED | | gate13-20260831-g | GCP | Corrected Gate 13 route-first lifecycle with a bounded standard Windows user-runtime environment, one durable foreground host-adapter execution as each ordinary OS user, exact archive preflight, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:f27f36158f2ad16019578555023cc854cb1e6e3b10ebae8cd3ed24d757b8e032`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-g-failed-attempt-and-cleanup.json) records passed route acceptance, the exact Windows archive, a two-second `package_verification` failure caused by four omitted existing audit inputs, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | | gate13-20260831-f | GCP | Fresh Gate 13 route-first lifecycle using one durable foreground host-adapter execution over IAP SSH as each ordinary OS user, exact archive preflight, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:c9a2aafc84940df901a7db1755af2e684f845b78dcdfac04332cfed36388ba25`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-f-failed-attempt-and-cleanup.json) records passed route acceptance, exact Windows archive and staged-input verification, the same bounded `signed_bootstrap` failure under a direct ordinary-user launch as under S4U, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | | gate13-20260831-e | GCP | Fresh Gate 13 route-first lifecycle with pinned reusable route setup, corrected S4U/SID Windows host job, explicit archive download-and-hash prerequisite, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:9ca0fa516017c4a3709a467752f779bcb3bbc0a7c790f9bc61de56d385804c62`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-e-failed-attempt-and-cleanup.json) records passed route acceptance, the exact Windows archive preflight, ordinary-user SSH repair, a durable S4U/Limited lifecycle failure with opaque phase output, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | diff --git a/docs/evidence/gate13-20260831-h-failed-attempt-and-cleanup.json b/docs/evidence/gate13-20260831-h-failed-attempt-and-cleanup.json new file mode 100644 index 000000000..2b5f5e1ae --- /dev/null +++ b/docs/evidence/gate13-20260831-h-failed-attempt-and-cleanup.json @@ -0,0 +1,54 @@ +{ + "schema_version": 1, + "scope": "gate13-failed-attempt-and-cleanup", + "run_id": "gate13-20260831-h", + "gate": 13, + "result": "failed-cleaned", + "recorded_at": "2026-09-01T02:28:35.232Z", + "source": { + "authorization_sha256": "sha256:6714a7a33b671c3fc177e182c354b80e3fb54c6657d5008eff74080c088be523", + "package_commit": "1971f106cc5bf90724d938c986a719ce2744f3e7", + "host_job_commit": "8c56feced219c521818ef6ad79ec830fc6b30a20" + }, + "acceptance": { + "route_acceptance_passed": true, + "windows_archive_sha256": "45e9cdb439bcb8a6d7ed67914a490f3bc2e12ea1981af944034d62d865f5adc6", + "windows_archive_bytes": 2695065068, + "windows_attempts": 1, + "windows_result": "failed", + "failure_phase": "signed_bootstrap", + "failure_operation": "product_readiness", + "linux_created": false, + "linux_attempts": 0, + "gate_passed": false + }, + "conclusion": "The exact route and package prerequisites passed. The opaque non-interactive Windows launch timed out waiting for product readiness and retained no useful product error. This run does not show a Gate 11 or GCP route failure.", + "cleanup": { + "verified_at": "2026-09-01T02:28:35.232Z", + "instances_absent": [ + "route-20260831-h-node", + "gate13-20260831-h-win", + "gate13-20260831-h-linux" + ], + "disks_absent": [ + "route-20260831-h-node", + "gate13-20260831-h-win", + "gate13-20260831-h-linux" + ], + "firewalls_absent": [ + "route-20260831-h-dht", + "route-20260831-h-iap" + ], + "global_l4_quota_limit": 1, + "global_l4_quota_usage": 0, + "protected_bootstrap_status": "RUNNING" + }, + "next_action": "Run the exact Windows package manually in a real interactive console session with visible node and desktop diagnostics; do not invoke the lifecycle wrapper until the manual flow passes.", + "privacy": { + "credentials_retained": false, + "prompts_retained": false, + "outputs_retained": false, + "endpoints_retained": false, + "provider_output_retained": false + } +} diff --git a/docs/evidence/gate13-20260831-i-cost-authorization.json b/docs/evidence/gate13-20260831-i-cost-authorization.json new file mode 100644 index 000000000..8a66f5118 --- /dev/null +++ b/docs/evidence/gate13-20260831-i-cost-authorization.json @@ -0,0 +1,291 @@ +{ + "schema_version": 1, + "scope": "gate13-packaged-lifecycle-cost-authorization", + "run_id": "gate13-20260831-i", + "gate": 13, + "result": "authorized", + "recorded_at": "2026-08-31", + "source": { + "reservation_commit": "9ad67da6728430b965add981d088821d4d600027", + "durable_controller_commit": "ee0c05f83035ad22015fb089f5cb30f1713076e6", + "host_job_adapter_commit": "8c56feced219c521818ef6ad79ec830fc6b30a20", + "route_runtime_commit": "f64a388a47b098ac7f69d2affc59816376b43bb1", + "package_commit": "1971f106cc5bf90724d938c986a719ce2744f3e7", + "windows_lifecycle_helper_commit": "8c56feced219c521818ef6ad79ec830fc6b30a20", + "linux_lifecycle_helper_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3" + }, + "authorization": { + "combined_cloud_ceiling_usd": "500.00", + "ledger_committed_before_run_usd": "444.00", + "maximum_estimate_usd": "56.00", + "route_maximum_estimate_usd": "30.00", + "clients_maximum_estimate_usd": "26.00", + "remaining_after_run_maximum_usd": "0.00", + "reservation_recorded": true, + "provisioning_authorized_after_fail_closed_preflight": true, + "provider_calls_authorized_without_preflight": false, + "pricing_as_of": "2026-08-31", + "pricing_basis": "unchanged same-day Gate 13 ceiling: one bounded 16-hour G2/L4 route and two sequential bounded 6-hour CPU clients; Windows is exercised first in a real interactive console session with virtual display and visible product diagnostics, one step at a time, before any lifecycle automation is permitted" + }, + "immutable_inputs": { + "durable_controller": { + "path": "scripts/gate13_run_controller.py", + "source_commit": "ee0c05f83035ad22015fb089f5cb30f1713076e6", + "sha256": "3a33f1c0782222d400296c944651ca80a58fb67b4df79ce6a7d9c7216fd23b84", + "bytes": 34143 + }, + "host_job_adapter": { + "path": "scripts/gate13_host_job.py", + "source_commit": "8c56feced219c521818ef6ad79ec830fc6b30a20", + "sha256": "6a01626718a8cf7bd5743d2f07e20d808d6708280c9005cd6a03de8ff807567e", + "bytes": 42051 + }, + "lifecycle_evidence_validator": { + "path": "scripts/gate13_packaged_lifecycle.py", + "source_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "sha256": "36e04fbb599dce57e2a7d9d105511e731ad0ceafaaf0b0820de7022711066157", + "bytes": 27268 + }, + "route_runtime_wheel": { + "filename": "drift-2.3.0.dev2-py3-none-any.whl", + "source_commit": "f64a388a47b098ac7f69d2affc59816376b43bb1", + "sha256": "2a4f30bad7ae897fed019bc7da330a09965adb35685d11abaeaebf7a1d40aa60", + "bytes": 389107, + "model_artifacts_embedded": false + }, + "windows_package": { + "sha256": "45e9cdb439bcb8a6d7ed67914a490f3bc2e12ea1981af944034d62d865f5adc6", + "bytes": 2695065068 + }, + "linux_package": { + "sha256": "f96d3ca651964380d4684855ab08682e8187b33386327ec3895cda25b43c2a00", + "bytes": 3360717934 + }, + "qwen_manifest": "sha256:3ba8528cb3c0d85e1ed048e0438a0d64cfbbc298944ed674caa6950d415f8e33", + "gemma_manifest": "sha256:2f8debbe0fcdf5af8d4c56c982210fa50aa584314968ae2617e2ccc2de9eafdd", + "windows_helper": { + "path": "scripts/gate13_windows_packaged_lifecycle.ps1", + "source_commit": "8c56feced219c521818ef6ad79ec830fc6b30a20", + "sha256": "a85c51eb0231bcef991a77c57b622fad575049f000c8b95bbeec7f78eaec7a1e", + "bytes": 133351 + }, + "linux_helper": { + "path": "scripts/gate13_linux_packaged_lifecycle.py", + "source_commit": "0e16ac2aa088e0719e9c3c89451176544602bab3", + "sha256": "90f3af65bb4f77317f707a6b52e329e1d5f81cdeddcb9615a210ec9a5a4cf535", + "bytes": 113748 + }, + "windows_download_config": { + "path": "scripts/gate13_download_windows.json", + "sha256": "bf36b66bf22a8f4453ba481c5a4ad37d8e1856ad93ef25e5e105b566b39affc4", + "bytes": 293 + }, + "linux_download_config": { + "path": "scripts/gate13_download_linux.json", + "sha256": "c3e9dcd94ef0a8e61c95e650416a8c6ca8b169f4b6699a0acd06608e60da5550", + "bytes": 294 + }, + "route_setup": { + "path": "scripts/gate13_route_setup.sh", + "source_commit": "0f481d73e712ea14eb022906e2e0debc0a6e9931", + "sha256": "1972d54efe5b9ffb73c3d96e005edc238f7f57aaf720b202d091e541c82e044a", + "bytes": 3371 + }, + "windows_audit_bundle": { + "audit/desktop-metrics.json": { + "sha256": "953fc814d3d7d6787cbe7ecc25e8ab9f60c68515b94c80575207e15e78d69549", + "bytes": 3795 + }, + "audit/provenance.json": { + "sha256": "ac04b71d35493ba4967628af1ac05ca290b1af09aab4e8955ac09031c87ce7f8", + "bytes": 1241883 + }, + "audit/release-metadata.json": { + "sha256": "6a434cf14100572954452052b8a1e6e8565b2930e3251b1b8327cfdcd7383a25", + "bytes": 872 + }, + "audit/SHA256SUMS": { + "sha256": "a458760c1e4636956c9fba5a7da93ed869544f4817d5ab883b5d8b34cc9ad964", + "bytes": 674380 + } + }, + "linux_audit_bundle": { + "audit/desktop-metrics.json": { + "sha256": "5d2b261505e949a15c332c6e5bb817611e340bf897f9b1952ebde8e573e07bd2", + "bytes": 3798 + }, + "audit/provenance.json": { + "sha256": "c9b5e47017b003f6b2d81c9ab8273fcbf3c72f7743f1ebbf99383ae7cd5accda", + "bytes": 1357051 + }, + "audit/release-metadata.json": { + "sha256": "6a434cf14100572954452052b8a1e6e8565b2930e3251b1b8327cfdcd7383a25", + "bytes": 872 + }, + "audit/SHA256SUMS": { + "sha256": "4766c587a1d8e430f892128b8667c04ae7e83bde869a3098b24012c4f99cb74d", + "bytes": 737970 + } + } + }, + "provider_plan_digest": "sha256:8525c3099f273c099aba26de57c1f610a0c74cac65ed2640589d51e874bd0c44", + "provider_plan": { + "project": "community-ai-506321", + "route": { + "instance": "route-20260831-i-node", + "zone": "us-central1-b", + "machine_type": "g2-standard-8", + "accelerator": "1 x NVIDIA L4", + "image": "deeplearning-platform-release/common-cu129-ubuntu-2404-nvidia-580-v20260831", + "boot_disk_gib": 200, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 57600, + "termination_action": "DELETE", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "public_ports": [ + 31337, + 31338 + ], + "firewalls": [ + "route-20260831-i-dht", + "route-20260831-i-iap" + ], + "service_account": false, + "scopes": [], + "operator_access_target_tags": [ + "route-20260831-i-node", + "gate13-20260831-i-client" + ], + "enable_virtual_display": true + }, + "clients": [ + { + "platform": "windows", + "model": "Qwen3.5 2B", + "instance": "gate13-20260831-i-win", + "zone": "us-central1-b", + "machine_type": "e2-standard-8", + "image": "windows-server-2025-dc-v20260814", + "image_project": "windows-cloud", + "boot_disk_gib": 120, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 21600, + "termination_action": "DELETE", + "lifecycle_run_id": "gate13-20260831-i-windows", + "device_profile": "cpu", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "network_tag": "gate13-20260831-i-client", + "host_execution": "manual_interactive_console_as_ordinary_user", + "enable_virtual_display": true + }, + { + "platform": "linux", + "model": "Gemma 4 E2B IT", + "instance": "gate13-20260831-i-linux", + "zone": "us-central1-b", + "machine_type": "e2-standard-8", + "image": "ubuntu-2404-noble-amd64-v20260826", + "image_project": "ubuntu-os-cloud", + "boot_disk_gib": 120, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 21600, + "termination_action": "DELETE", + "lifecycle_run_id": "gate13-20260831-i-linux", + "device_profile": "cpu", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "network_tag": "gate13-20260831-i-client", + "host_execution": "manual_foreground_shell_as_ordinary_user" + } + ], + "sequencing": { + "route_live_for_both_lifecycles": true, + "clients_may_run_concurrently": false, + "route_accepted_before_any_client_create": true, + "windows_collected_and_deleted_before_linux_create": true, + "all_16_phases_required_per_platform": true, + "exact_cleanup_before_pass": true, + "client_host_adapter_mode": "manual_phase_by_phase_before_adapter", + "manual_windows_desktop_required": true, + "automation_prohibited_until_manual_pass": true + } + }, + "preflight": { + "read_only_checked_at": "2026-09-01T02:28:35.230Z", + "native_gcloud_token_refresh": true, + "compute_api_access": true, + "exact_target_instances_absent": true, + "exact_target_disks_absent": true, + "exact_target_firewalls_absent": true, + "machine_types_available": true, + "images_available": true, + "one_l4_quota_free": true, + "protected_bootstrap_running": true, + "final_software_tests_passed": 22, + "independent_review_matrix_before_final_reservation_guard": 0, + "independent_reservation_guard_finding_resolved": true, + "fresh_native_revalidation_immediately_before_create_required": true + }, + "cleanup": { + "delete_only_exact_run_resources": true, + "verify_absent": [ + "route-20260831-i-node", + "route-20260831-i-node boot disk", + "route-20260831-i-dht", + "route-20260831-i-iap", + "gate13-20260831-i-win", + "gate13-20260831-i-win boot disk", + "gate13-20260831-i-linux", + "gate13-20260831-i-linux boot disk" + ], + "protected_resources": [ + "communityai-bootstrap-1", + "all resources not named by this plan" + ], + "cleanup_required_on_success_or_failure": true + }, + "prohibited": { + "fly_operations": 0, + "model_specific_images_or_mirrors": 0, + "macos_work": 0, + "credits_or_payments_work": 0 + }, + "privacy": { + "credentials_retained": false, + "prompts_retained": false, + "outputs_retained": false, + "endpoints_retained": false, + "provider_output_retained": false, + "private_paths_retained": false + }, + "plan_revision": { + "revision": 1, + "reason": "replace the opaque host lifecycle launch with a literal clean-host playthrough: verify and extract the exact package, run bootstrap and node visibly, launch the Windows desktop in an interactive session, exercise the required controls and inference, restart and exercise the second control, then repeat on Linux; translate only proven commands back into adapters", + "resource_set_changed": true, + "cost_ceiling_changed": false + }, + "manual_execution": { + "windows": [ + "verify exact archive and four audit records", + "extract into an empty per-user install root", + "run packaged self-tests", + "run signed bootstrap with visible output", + "start packaged node directly and inspect visible readiness", + "launch CommunityAI.exe in a real interactive ordinary-user console", + "exercise sharing control and public inference", + "restart the desktop", + "exercise the second control and re-run inference", + "uninstall/reinstall/cache and cleanup phases" + ], + "linux": [ + "repeat the proven phase sequence in one ordinary-user foreground session" + ], + "wrapper_use_before_manual_pass": false, + "retain_private_prompts_or_outputs": false + } +} From f1dc3a0e38b0b2ee12150fe403fd1de435c49f71 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:37:20 -0500 Subject: [PATCH 28/56] fix: support long Windows model cache paths --- src/drift/model_manifest.py | 26 +++++++++++++++++++++++--- tests/test_model_manifest.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/drift/model_manifest.py b/src/drift/model_manifest.py index 293458d55..0c9cf9d9d 100644 --- a/src/drift/model_manifest.py +++ b/src/drift/model_manifest.py @@ -486,6 +486,26 @@ def _artifact_path_below_root(root: Path | str, relative_path: str) -> Path: return candidate +def _windows_safe_path(path: Path) -> Path: + """Opt long manifest-cache paths into the Win32 extended namespace. + + Full manifest and artifact SHA-256 identifiers make resumable lock and + partial paths exceed the legacy Win32 path limit under an ordinary user + profile. Python then reports a misleading ``FileNotFoundError`` even when + the parent directory exists. Keep the audited on-disk layout unchanged, + but use an extended-length spelling for filesystem operations. + """ + absolute = path.absolute() + if os.name != "nt": + return absolute + rendered = str(absolute) + if rendered.startswith("\\\\?\\") or len(rendered) < 248: + return absolute + if rendered.startswith("\\\\"): + return Path("\\\\?\\UNC\\" + rendered[2:]) + return Path("\\\\?\\" + rendered) + + def _validate_artifact_file(artifact: ManifestArtifact, candidate: Path) -> os.stat_result: try: stat_result = candidate.stat() @@ -670,9 +690,9 @@ def _resumable_paths(self, artifact: ManifestArtifact) -> Tuple[Path, Path, Path cache_root = Path(self.cache_dir).absolute() manifest_root = cache_root / "manifest-artifacts" / self.manifest.digest name_digest = hashlib.sha256(artifact.path.encode("utf-8")).hexdigest() - partial = manifest_root / "partial" / f"{name_digest}.part" - final = _artifact_path_below_root(manifest_root / "snapshot", artifact.path) - lock = manifest_root / "locks" / f"{name_digest}.lock" + partial = _windows_safe_path(manifest_root / "partial" / f"{name_digest}.part") + final = _windows_safe_path(_artifact_path_below_root(manifest_root / "snapshot", artifact.path)) + lock = _windows_safe_path(manifest_root / "locks" / f"{name_digest}.lock") return partial, final, lock def _resumable_hub_download(self, artifact: ManifestArtifact, *, destination: Optional[Path] = None) -> str: diff --git a/tests/test_model_manifest.py b/tests/test_model_manifest.py index ae42ec788..e205224b5 100644 --- a/tests/test_model_manifest.py +++ b/tests/test_model_manifest.py @@ -1,5 +1,6 @@ import hashlib import json +import os from pathlib import Path from types import SimpleNamespace @@ -461,6 +462,34 @@ def replace_after_release(source, destination): assert not partial.exists() +@pytest.mark.skipif(os.name != "nt", reason="Win32 extended-length paths are Windows-specific") +def test_resumable_manifest_paths_work_beyond_legacy_windows_max_path(tmp_path): + from drift.utils.file_lock import file_lock + + manifest = ModelManifest.from_dict(manifest_dict()) + cache = tmp_path / ("cache-" + "x" * 96) + verifier = ManifestArtifactVerifier( + manifest, + manifest.source.repository, + manifest.source.revision, + cache_dir=cache, + ) + + partial, final, lock = verifier._resumable_paths(manifest.get_artifact("weights.bin")) + assert len(str(cache.absolute() / "manifest-artifacts" / manifest.digest / "partial")) > 248 + assert str(partial).startswith("\\\\?\\") + assert str(lock).startswith("\\\\?\\") + + partial.parent.mkdir(parents=True, exist_ok=True) + partial.write_bytes(b"partial") + with file_lock(lock, exclusive=True): + final.parent.mkdir(parents=True, exist_ok=True) + final.write_bytes(b"final") + + assert partial.read_bytes() == b"partial" + assert final.read_bytes() == b"final" + + def test_mixed_cached_and_downloaded_artifacts_share_one_snapshot_root(tmp_path, monkeypatch): from huggingface_hub.utils import LocalEntryNotFoundError From d03826ce666b7130044212c39f0d8129b5f1ccbc Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:30:40 -0500 Subject: [PATCH 29/56] docs: complete Gate 13 manual qualification --- docs/RELEASE_READINESS.md | 58 ++--- .../gate13-20260831-i-linux-paused.png | Bin 0 -> 118651 bytes .../gate13-20260831-i-linux-ready.png | Bin 0 -> 118114 bytes .../gate13-20260831-i-linux-sharing.png | Bin 0 -> 118354 bytes ...31-i-manual-qualification-and-cleanup.json | 203 ++++++++++++++++++ .../gate13-20260831-i-windows-paused.png | Bin 0 -> 21452 bytes .../gate13-20260831-i-windows-ready.png | Bin 0 -> 18590 bytes 7 files changed, 221 insertions(+), 40 deletions(-) create mode 100644 docs/evidence/gate13-20260831-i-linux-paused.png create mode 100644 docs/evidence/gate13-20260831-i-linux-ready.png create mode 100644 docs/evidence/gate13-20260831-i-linux-sharing.png create mode 100644 docs/evidence/gate13-20260831-i-manual-qualification-and-cleanup.json create mode 100644 docs/evidence/gate13-20260831-i-windows-paused.png create mode 100644 docs/evidence/gate13-20260831-i-windows-ready.png diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md index ff0578aee..6f577432f 100644 --- a/docs/RELEASE_READINESS.md +++ b/docs/RELEASE_READINESS.md @@ -1,6 +1,6 @@ # Public inference alpha release readiness -Last verified: 2026-08-31 +Last verified: 2026-09-01 This is the live source of truth for public-alpha implementation. Update it whenever a gate changes state. `docs/REVIVAL.md` defines the execution contract and long-term design; @@ -40,46 +40,24 @@ gate changes state. `docs/REVIVAL.md` defines the execution contract and long-te ## Critical path Work from top to bottom while prerequisites are satisfied. Gate V and Gates 5–6 have passed. -The current mandatory sequence is **Gates 13–16 → Gate 17**. The visible +The current mandatory sequence is **Gates 14–16 → Gate 17**. The visible vertical slice proved real Qwen3.5 2B inference through a public GCP L4 worker, and the strict four-profile Qwen and Gemma matrices now pass, and Gate 7 passed the generic five-Machine provider recovery mechanism with TinyLlama. Per-model repetition of the same provider recovery gate is not required. -As of 2026-08-31, Gate 13 is `IN PROGRESS`: [run `gate13-20260831-a`](evidence/gate13-20260831-a-failed-attempt-and-cleanup.json) -proved both exact production archives downloaded on the intended clients, but neither -16-phase lifecycle completed. The run coupled long work to transient operator sessions, -advanced both clients before route acceptance, and consumed both hosts through lifecycle -or diagnostic product launches. They are not reusable as fresh acceptance hosts. Exact -cleanup now proves the route, both clients, all three disks, and both firewall rules absent; -the protected bootstrap remains running. The USD 52 maximum remains committed. The -[owner's 2026-08-31 authorization](evidence/owner-budget-authorization-20260831.json) raises the -current combined epoch ceiling to USD 500 without releasing that maximum, leaving USD 448 before -a new reservation. Source -`0e16ac2` (built on native-host-job slice `2fe0de9`) now completes the persisted fail-closed -run-state and durable host-execution prerequisite. Exact-current-user Windows Scheduled Task and -non-root Linux systemd jobs survive operator disconnects, permit one attempt, bound output and the -complete process tree, validate exact source/config/native-supervisor bindings, and revalidate -canonical evidence before collection. The [software-prerequisite evidence](evidence/gate13-20260831-b-durable-host-job-prerequisite.json) -binds the pushed source and independently reproduced 217-test Gate 13/desktop regression. It -created no cloud resources and authorizes no paid run. [Run `gate13-20260831-b`](evidence/gate13-20260831-b-failed-start-and-cleanup.json) -failed before VM creation when the operator shell collapsed two IAP target tags into one value; -its exact firewall cleanup and every instance/disk/firewall absence passed. Its USD 56 maximum -remains committed. Run `gate13-20260831-c` was armed locally but reached terminal controller absence without a -durable provider execution record. Its [terminal-state and cleanup proof](evidence/gate13-20260831-c-terminal-state-and-cleanup.json) -retires the run ID, proves every exact resource absent, global GPU usage zero, and the protected -bootstrap running; its USD 56 maximum remains committed. [Run `gate13-20260831-d`](evidence/gate13-20260831-d-failed-attempt-and-cleanup.json) -accepted the complete replacement product route before creating Windows, then exposed two -Gate 13 orchestration defects: the Interactive scheduled task could not start headlessly and -its observer compared noncanonical account names; source `3a1d1fc` changes the task to S4U -and binds its principal by SID. The repaired supervisor then ran, but the one-attempt lifecycle -failed immediately because the operator had staged the downloader without first downloading -the exact archive. Windows, route, disks, and firewalls are absent, L4 usage is zero, and the -protected bootstrap remains running. Its USD 56 maximum remains committed, bringing the epoch -total to USD 220. Fresh [run `gate13-20260831-e`](evidence/gate13-20260831-e-cost-authorization.json) -reserves USD 56 and leaves USD 224. No later mandatory gate is unblocked until it downloads each -exact archive before arming its host job and produces both complete 16-phase records plus exact -cleanup. +As of 2026-09-01, Gate 13 is `PASSED`. [Run `gate13-20260831-i`](evidence/gate13-20260831-i-manual-qualification-and-cleanup.json) +replaced the opaque wrapper-first approach with a literal clean-host desktop playthrough. +The route first passed Qwen primary, automatic Gemma fallback, and Qwen restoration. +Windows and Linux then ran sequentially as ordinary users from exact verified production +archives: the app opened, public inference passed, sharing was configured and started, +the app was restarted, sharing resumed, Pause sharing worked, and post-restart inference +passed on Linux. The Windows playthrough exposed and fixed the actual product blocker: +legacy MAX_PATH on a manifest-artifact lock path under the normal per-user data root. +Source `f1dc3a0` passed the regression test, rebuilt-package self-tests, and default-root +Qwen inference. Every run instance, disk, and firewall is absent, global L4 usage is zero, +and the protected bootstrap remains running. Gate 14 is now `READY`; Gate 15 owns +publication of the source-fixed Windows archive plus reinstall/uninstall release work. Do not work on the post-alpha items in the deferred table while an alpha gate can progress. Missing Docker, snapshots, local GPU hardware, or local host capacity is not an external @@ -111,9 +89,9 @@ longer consume the new authorization; later billing should still be recorded for | 10 | Implement automatic contributor model and block placement | PASSED | Signed bootstrap now installs one bounded `auto` worker. The local planner filters exact manifested candidates through owner policy and local resource ceilings, requires fresh authenticated replica coverage, targets the least-covered contiguous range with per-node jitter, reconciles exact-manifest launches through the existing artifact-verifying server and `WorkerSupervisor`, applies residency/cooldown/switch hysteresis, exposes placement reasons, and preserves an explicit operator pause across ineligibility or placement changes. A new or migrated worker must sign an expiring exact-manifest/range intent with fixed numeric resource claims and receive a remote DHT store acknowledgement (`exclude_self=True`) before entering the artifact path; invalid, rejected, or failed publication is fail-closed and cannot advance planner state, while a previously admitted placement is retained. Actual completed local generations feed exact-manifest demand, useful-throughput, and reliability through two bounded five-minute aggregate windows; no prompt, output, token ID, key, request ID, address, path, error, or per-request event is retained. Only a closed window with at least four completed routes may be signed by the separate router identity and published under the manifest-bound `demand-v1` DHT key with a 90-second lifetime and `exclude_self=True`. Consumers verify signature, exact schema/digest, lifetime, revocation, and replay ordering. The threshold-signed catalog may authorize 2–32 sorted RSA observer roots; missing or empty roots disable remote demand. Discovery discards unlisted identities before signature/replay work, excludes local and duplicate roots, isolates malformed records, requires two authorized roots, and medians at most 32 quantized observations. Observer keys are never generated or bundled: only a separately provisioned `route-demand.key` matching a signed root may publish, while ordinary nodes can consume without one. Any hot-edited root-list mismatch disables both publication and consumption until restart. Local utility is capped at 6 points and signed remote utility at 2, keeping the combined hint below the 10-point migration margin and 100-point replica step. Verified announcement and route-demand replay watermarks now survive restarts in one Windows-safe journal per raw manifest digest under the node data directory. Each strict journal is capped at 256 active identity scopes and 256 KiB, retains only public record kind, key ID, ordering tuple, record digest, and the bounded replay deadline, and is fsync-written through atomic replacement; malformed, duplicate, oversized, symlinked, non-regular, or unwritable state fails closed. The retained deadline prevents an older still-live record from returning after a short-lived newer record expires. The replay slice's 99-test focused protocol/discovery/planner/node-configuration matrix and 209-pass, 2-skip catalog/node/API superset pass. The Sybil slice's 122-test focused catalog/bootstrap/config/discovery matrix proves that 30 valid attacker keys plus one authorized root cannot reach threshold, two authorized roots aggregate without attacker weight, one high authorized vote cannot inflate a lower second vote, old catalogs remain signature-verifiable with remote demand disabled, and trust-epoch reload mismatches fail closed. A 190-pass, 1-skip catalog/protocol/planner/discovery/node/API superset also passes. Independent verification passed 146 focused tests and a 255-pass, 2-skip broader node/API superset, plus a native-Windows publication-boundary probe; formatting, import-order, import-smoke, and diff checks pass. The [explicit privacy review](AUTOMATIC_PLACEMENT_PRIVACY_V1.md) inventories collection, retention, public-key linkability, DHT/journal/API/log exposure, secure-deletion limits, and residual governance/host risks. Three executable privacy-contract tests fix the aggregate, intent, demand, replay, forbidden-field, and path-free warning schemas; the focused privacy/protocol/planner/discovery/node matrix passes 108 tests and the broader catalog/node/API matrix passes 258 tests with 2 skips. Independent privacy review passed 108 tests with 1 skip and a 225-pass, 2-skip broader subset; every caught observer-key exception and an unauthorized key produced no path, key ID, or exception detail, while prompt and identity-path schema injections failed closed. The [deterministic convergence and load acceptance](AUTOMATIC_PLACEMENT_ACCEPTANCE_V1.md) closes the remaining software gate: equal snapshots use node-specific 32-point model dispersion and range rendezvous ranks; a fixed 512-node cold cohort selects both models and every range below the 85% concentration boundary; two 4,096-node fresh-arrival cohorts remain below that boundary under maximum priority-aligned or standby demand; maximum demand causes zero incumbent migrations; one-replica loss migrates after residency without early reversal; rolling arrivals keep every model/block populated and repair an abrupt block loss. The alpha fails closed above 32 candidates or 512 blocks, permits one `auto` worker, clamps reconciliation to at least one second, and scans each candidate in one bounded pass. The focused planner/convergence/configuration matrix passes 78 tests and the broader catalog/protocol/discovery/node/API matrix passes 214 with 2 skips. A real Windows DHT round trip exposed and fixed a durable-replay multiprocessing regression: replay guards now omit/recreate their thread lock across serialization and reload persistent state; its 15-test protocol/network matrix passes. Independent verification reproduced the 78-test focus, passed an expanded 235-test matrix with 2 skips and the 15-test real-DHT probe, and exercised adversarial score, timing, 32-by-512 load, 1,000-case range-equivalence, and persistent replay-reload boundaries. This slice used no cloud resources and spent USD 0. | Gates 9–11 are passed. Gates 13–14 must now prove the packaged flow and real hardware ceilings using the published envelopes. | | 11 | Operate initial public alpha routes | PASSED | [Product-node run `route-20260830-j`](evidence/gate11node-20260830-a-lifecycle.json) installed the generic CommunityAI wheel on a bounded G2/L4 VM, verified the signed catalog, downloaded both exact manifested models directly from Hugging Face into one persistent shared cache, and used the product node's automatic workers to expose complete Qwen 24/24 primary and Gemma 35/35 standby routes. No model-specific image, cache mirror, or operator-transferred model artifact was used. The privacy-safe acceptance passed one-token primary inference, deliberate primary pause, automatic Gemma selection in 58.073 seconds, standby inference, Qwen restoration in 32.042 seconds, and restored inference. Both workers were stable before the drill. After Gate 13 released the L4, the preserved route was restored without changing its model cache or source, its ephemeral endpoint was rebound, both product-node services became active, and a fresh acceptance reproved Qwen 24/24 primary inference, automatic Gemma 35/35 fallback/inference, Qwen restoration, and restored inference. The protected bootstrap remains running. A corrected 4,800-second provider DELETE backstop was set for `2026-08-31T05:28:16.516Z`, earlier than the original deadline. [Post-backstop cleanup evidence](evidence/gate11route-20260830-j-backstop-cleanup.json) and an independent recheck prove the route instance, named disk, and both exact run-scoped firewall rules absent, all Gate 13 clients/disks absent, zero remaining route availability, and the protected bootstrap still running. The same-host standby is a bounded alpha fallback, not independent infrastructure redundancy; independent redundancy remains post-alpha. | Gate 11 acceptance evidence remains complete, but no product route is live after the corrected DELETE backstop. [Gate 13 run `gate13-20260831-a`](evidence/gate13-20260831-a-cost-authorization.json) now binds refreshed native authentication, fail-closed preflight, and a fresh USD 52 reservation for the replacement route and packaged clients. | | 12 | Create, publish, and bundle the minimal signed alpha catalog/bootstrap | PASSED | [Run `gate12-20260829-a`](evidence/gate12-20260829-alpha-catalog-publication.json) published the deterministic [`communityai-public-alpha-v1` bundle](../public-alpha/catalog-v1/bundle.json) from source `26be579`. Its threshold-one Ed25519 root signs sequence 1 with the exact qualified Qwen primary and Gemma standby manifests, one pinned public HTTPS mirror, one public seed, a one-route best-effort policy, and no unprovisioned route-demand roots. The canonical bundle binds five members and retains `complete_release_qualification=false`. All three public objects returned HTTP 200 with exact sizes, and a fresh empty consumer fetched them remotely, verified the signature/digests, and created the two-model `auto` node configuration. The private signing key remained ignored and uncommitted. The focused publication suite passes 32 tests, the catalog/bootstrap/model/desktop superset passes 92, and the run spent USD 0. | Preserve the branch-scoped mirror until a newly signed catalog sequence and packaged bootstrap migrate it. The Gate 11 acceptance and Gate 9 envelopes exist; [Gate 13 run `gate13-20260831-a`](evidence/gate13-20260831-a-cost-authorization.json) now authorizes the bounded replacement route and fresh packaged clients under the new epoch. Independent threshold holders and interchangeable mirror/seed governance are post-alpha. | -| 13 | Pass packaged clean-install inference on Windows and Linux | IN PROGRESS | [Prerequisite run `gate13-20260830-a-prerequisites`](evidence/gate13-20260830-a-prerequisites.json) established deterministic install archives, exact first-use bytes, strict provenance, and the canonical lifecycle contract. [Native-harness and production-package run `gate13-20260830-b`](evidence/gate13-20260830-b-native-harness-and-packages.json) now completes the native Windows Credential Manager/Job Object and Linux Secret Service/systemd-cgroup 16-phase adapters, exact worker and descendant cleanup proofs, 3,600-second acquisition bounds, and package/runtime/catalog cross-binding. Independent software review passed 134 focused tests plus a 113-pass broader matrix with 3 platform skips; the production-discovery correction passes 73 unittests, 4 pytest checks, self-test, formatting, and import checks. [Exact-source production run 33338872342](https://github.com/flujo-app/CommunityAI/actions/runs/33338872342) passed both jobs at source `1971f10` and published independently audited CUDA 12.4 archives: Windows `sha256:45e9cdb439bcb8a6d7ed67914a490f3bc2e12ea1981af944034d62d865f5adc6` (2,695,065,068 bytes) and Linux `sha256:f96d3ca651964380d4684855ab08682e8187b33386327ec3895cda25b43c2a00` (3,360,717,934 bytes). Pushed source `6787272` adds the fixed stdin-only artifact downloader and exact platform configs; its 42-test adversarial suite and independent race/special-member/live-wrapper audit pass. No cloud resource was created for these prerequisites. Real completed clean-host lifecycle evidence remains absent. [Run `gate13-20260831-a`](evidence/gate13-20260831-a-failed-attempt-and-cleanup.json) later verified both exact archives on fresh clients but produced neither canonical lifecycle record. Its state-free transient orchestration advanced both clients before route acceptance, and subsequent diagnostic/product launches consumed both hosts for fresh-host acceptance. Exact cleanup proves all three instances/disks and both firewalls absent while the protected bootstrap remains running. Source `ddfb7c6` adds a persisted authorization-bound state contract that enforces inventory-first reattachment, route acceptance, sequential Windows-then-Linux evidence, consumed-host failure semantics, deadline runway, and success only after both canonical records plus final absence. [Durable native-host-job prerequisite evidence](evidence/gate13-20260831-b-durable-host-job-prerequisite.json) binds pushed source `0e16ac2` and its base `2fe0de9`: exact-current-user Windows Scheduled Task and non-root Linux systemd jobs survive operator disconnects, permit exactly one attempt, bound stdout/stderr and complete-process-tree cleanup, exact-bind source/config/native inventory, and revalidate canonical evidence before collection. Local and independent nine-file matrices each pass 217 tests with 29 known warnings. No cloud resource was created and no lifecycle pass is claimed. | [Run `gate13-20260830-c` revision 13](evidence/gate13-20260830-c-cost-authorization.json) is stopped clean. The latest Windows host passed exact package audit, clean install, four desktop self-tests, and the packaged-node self-test, then failed before model acquisition because child stderr diagnostics contaminated strict JSON captured on stdout. [Attempt, cleanup, correction, and route-restoration evidence](evidence/gate13-20260830-c-windows-attempt-and-route-restore.json) proves zero cache bytes, no retained credential or product process, all four exact client instances/disks absent, the bootstrap running, and the temporary restored Qwen/Gemma product route. [Post-backstop cleanup evidence](evidence/gate11route-20260830-j-backstop-cleanup.json) now proves that route, its named disk, and both exact run-scoped firewall rules absent while every Gate 13 target remains absent and the protected bootstrap remains running. Pushed source `4818da3` separates captured stdout from a dedicated NUL stderr sink and passes 15 native tests plus independent high-volume, handle-leak, descendant, timeout, and Job Object probes, but it has not completed a paid clean-host lifecycle. The cleanup-backed 2026-08-31 owner reset releases the USD 98 historical maxima and opens a new USD 100 epoch for the next run; it does not authorize any particular resource or reuse the stopped record. A later native-auth recheck on 2026-08-31 obtained a real access token and passed Compute API access without exposing account or token material. [Run `gate13-20260831-a`](evidence/gate13-20260831-a-failed-attempt-and-cleanup.json) is failed and cleanup-proved; its USD 52 maximum remains committed. The owner's 2026-08-31 ceiling increase leaves USD 448 before a new reservation. Do not reuse its clients or authorization. The durable Windows Scheduled Task and Linux systemd host-job adapter prerequisite is complete at pushed source `0e16ac2`. Run `gate13-20260831-b` failed before VM creation and is cleanup-proved. Runs `gate13-20260831-c` and `gate13-20260831-d` are retired and cleanup-proved. Run `gate13-20260831-e` is source-bound and reserved at USD 56 after a fresh exact-absence preflight. Execute only the straightforward route-first, archive-download-before-start, Windows collect/delete, Linux collect/delete, route-delete sequence. The next run must accept the complete route first, run/collect/delete Windows Qwen, then run/collect/delete Linux Gemma, and finally prove exact route cleanup. | -| 14 | Pass automatic-contribution and resource-control hardware checks | WAITING | [PR #11](https://github.com/flujo-app/CommunityAI/pull/11) and [PR #12](https://github.com/flujo-app/CommunityAI/pull/12) implemented the authenticated node-authoritative Sharing UI and atomic policy editing, but cross-model automatic placement and real packaged hardware evidence are absent. | After Gates 9–13, follow the [recovery runbook](RECOVERY_TEST_RUNBOOK.md) once for the clean-install product flow while validating model/block choice, exact selected-shard bytes, shared-cache affinity, download authorization, VRAM/storage/bandwidth/power limits, suspension, pause timing, cleanup, restart persistence, and unsupported telemetry on real packaged Windows/Linux hardware. | -| 15 | Complete minimal alpha release engineering | WAITING | The desktop builder now emits a stable sorted `SHA256SUMS` inventory of exact regular-file bytes and safe relative in-bundle file symlinks, source/build/catalog-bound `provenance.json`, and `release-metadata.json` with explicit unsigned public-alpha, no-publisher-signature, no-authenticated-update, Windows/Linux-only, no-credits, and incomplete-qualification claims. Structural verification binds each safe file symlink to its canonical in-bundle target, digest, and size while rejecting changed, missing, extra, absolute, external, broken, cyclic, directory-linked/junction, special, traversal, or case-colliding payloads plus unsupported or noncanonical metadata. Exact-source builds also reject dirty relevant inputs, and the expected-input fresh-process check rejects rewritten commit/tree, workflow, platform, Python, PyInstaller, or catalog evidence. Production desktop CI is configured to verify and bundle the Gate 12 inputs, bind the exact clean Git commit/tree and workflow, revalidate every expected input separately, and upload all evidence on Windows/Linux. The focused release-input/artifact suite passes 15 tests, including fresh-process CLI, dirty-source, and canonical-rewrite checks, and the broader catalog/bootstrap/model/desktop subset passes 134. Independent verification reproduced all 134, passed 58 desktop unittests with two environment skips, formatting/import-order/YAML/diff checks, an expected Gate 12/workflow fresh-process probe, and real Windows junction rejection; no cloud was used. [The first PR #22 production-desktop run](https://github.com/flujo-app/CommunityAI/actions/runs/33273518744) reached packaging on both hosts and exposed two exact cross-platform defects: PyInstaller's legitimate relative internal Qt file symlink on Ubuntu and CRLF-transformed signed Gate 12 JSON on Windows. The follow-up binds safe internal file symlinks without accepting external or directory links, forces `public-alpha/**` to LF at checkout, and includes `.gitattributes` in the clean-source boundary. [The second run](https://github.com/flujo-app/CommunityAI/actions/runs/33274432423) proved the Ubuntu package and the Windows signed-bundle/provenance path, then exposed a stale desktop contribution-status schema 2 contract when the packaged node emitted schema 3 automatic-placement evidence. Source `fcd1f41` now strictly validates schema 3 placement and rejects stale schema 2 plus missing, extra, secret-bearing, or inconsistent placement data; its 50-test node/client/lifecycle/build focus and all 59 desktop unittests passed with two environment skips. [The final run](https://github.com/flujo-app/CommunityAI/actions/runs/33275216332) bound exact source `fcd1f417d1435557addb2d6cded9dac0827c7d8c` and completed both Windows and Ubuntu package jobs, including bundle build/smoke, independent checksum/provenance verification, the Windows packaged-node/native-credential/public-seed smoke, and artifact uploads; every PR style, test, and package check is green. Source `36d85d2` makes generic release-artifact fixtures select the supported Linux archive explicitly instead of inheriting the CI host platform; the 21-test local artifact suite and [PR #22 test run 33372581439](https://github.com/flujo-app/CommunityAI/actions/runs/33372581439) pass, without expanding the supported platform matrix. Clean-install lifecycle evidence remains absent. | Retain the verified Windows/Linux artifacts as engineering evidence, then test clean install, manual upgrade/reinstall, uninstall, retained-data choice for the persistent verified model cache, and recovery instructions on both platforms against a newly authorized live product-node route and the published Gate 9 envelopes. Do not mark passed from metadata/unit tests alone. Publisher signing and automatic authenticated update/rollback are post-alpha. | +| 13 | Pass packaged clean-install inference on Windows and Linux | PASSED | [Run `gate13-20260831-i`](evidence/gate13-20260831-i-manual-qualification-and-cleanup.json) accepted the complete Qwen/Gemma route before either client, verified both exact production archives, passed all four packaged self-tests on clean sequential Windows and Linux CPU hosts, opened the real desktop as ordinary users, completed one-token Qwen and Gemma inference, configured and started sharing through the UI, fully restarted each app, clicked Pause sharing, and passed post-restart Linux inference. The manual Windows run exposed the real blocker: legacy MAX_PATH on a manifest-artifact lock path under the default user data root. Source `f1dc3a0` adds extended-length path handling; the rebuilt node passed default-root Qwen inference. All run instances, disks, and firewalls are absent, L4 usage is zero, and the protected bootstrap is running. | Proceed to Gate 14. Gate 15 must publish the source-fixed Windows archive and owns reinstall/uninstall/retained-data release engineering. | +| 14 | Pass automatic-contribution and resource-control hardware checks | READY | [PR #11](https://github.com/flujo-app/CommunityAI/pull/11) and [PR #12](https://github.com/flujo-app/CommunityAI/pull/12) implemented authenticated node-authoritative sharing controls. [Gate 13 run `gate13-20260831-i`](evidence/gate13-20260831-i-manual-qualification-and-cleanup.json) now proves the real packaged Sharing UI starts, survives restart, and pauses on both supported platforms. | Run the remaining Gate 14 hardware-ceiling, suspension, placement, and recovery checks using the Gate 9 envelopes; do not repeat Gate 13 clean-install work. | +| 15 | Complete minimal alpha release engineering | WAITING | The desktop builder now emits a stable sorted `SHA256SUMS` inventory of exact regular-file bytes and safe relative in-bundle file symlinks, source/build/catalog-bound `provenance.json`, and `release-metadata.json` with explicit unsigned public-alpha, no-publisher-signature, no-authenticated-update, Windows/Linux-only, no-credits, and incomplete-qualification claims. Structural verification binds each safe file symlink to its canonical in-bundle target, digest, and size while rejecting changed, missing, extra, absolute, external, broken, cyclic, directory-linked/junction, special, traversal, or case-colliding payloads plus unsupported or noncanonical metadata. Exact-source builds also reject dirty relevant inputs, and the expected-input fresh-process check rejects rewritten commit/tree, workflow, platform, Python, PyInstaller, or catalog evidence. Production desktop CI is configured to verify and bundle the Gate 12 inputs, bind the exact clean Git commit/tree and workflow, revalidate every expected input separately, and upload all evidence on Windows/Linux. The focused release-input/artifact suite passes 15 tests, including fresh-process CLI, dirty-source, and canonical-rewrite checks, and the broader catalog/bootstrap/model/desktop subset passes 134. Independent verification reproduced all 134, passed 58 desktop unittests with two environment skips, formatting/import-order/YAML/diff checks, an expected Gate 12/workflow fresh-process probe, and real Windows junction rejection; no cloud was used. [The first PR #22 production-desktop run](https://github.com/flujo-app/CommunityAI/actions/runs/33273518744) reached packaging on both hosts and exposed two exact cross-platform defects: PyInstaller's legitimate relative internal Qt file symlink on Ubuntu and CRLF-transformed signed Gate 12 JSON on Windows. The follow-up binds safe internal file symlinks without accepting external or directory links, forces `public-alpha/**` to LF at checkout, and includes `.gitattributes` in the clean-source boundary. [The second run](https://github.com/flujo-app/CommunityAI/actions/runs/33274432423) proved the Ubuntu package and the Windows signed-bundle/provenance path, then exposed a stale desktop contribution-status schema 2 contract when the packaged node emitted schema 3 automatic-placement evidence. Source `fcd1f41` now strictly validates schema 3 placement and rejects stale schema 2 plus missing, extra, secret-bearing, or inconsistent placement data; its 50-test node/client/lifecycle/build focus and all 59 desktop unittests passed with two environment skips. [The final run](https://github.com/flujo-app/CommunityAI/actions/runs/33275216332) bound exact source `fcd1f417d1435557addb2d6cded9dac0827c7d8c` and completed both Windows and Ubuntu package jobs, including bundle build/smoke, independent checksum/provenance verification, the Windows packaged-node/native-credential/public-seed smoke, and artifact uploads; every PR style, test, and package check is green. Source `36d85d2` makes generic release-artifact fixtures select the supported Linux archive explicitly instead of inheriting the CI host platform; the 21-test local artifact suite and [PR #22 test run 33372581439](https://github.com/flujo-app/CommunityAI/actions/runs/33372581439) pass, without expanding the supported platform matrix. Gate 13 manual clean-install evidence now exists; Gate 15 still lacks its upgrade/reinstall/uninstall and retained-data release evidence. | Retain the verified Windows/Linux artifacts as engineering evidence, then test clean install, manual upgrade/reinstall, uninstall, retained-data choice for the persistent verified model cache, and recovery instructions on both platforms against a newly authorized live product-node route and the published Gate 9 envelopes. Do not mark passed from metadata/unit tests alone. Publisher signing and automatic authenticated update/rollback are post-alpha. | | 16 | Complete the bounded public-alpha safety canary | WAITING | [PR #13](https://github.com/flujo-app/CommunityAI/pull/13) and [PR #14](https://github.com/flujo-app/CommunityAI/pull/14) implemented bounded admission, privacy-safe aggregate health, training-off defaults, rollback procedures, and bounded routine rejection logs; no public canary has run. | After Gates 11–15, run a small monitored canary proving finite admission/timeouts, malformed-peer rejection, health reconstruction, privacy disclosure, route/catalog disable, and clean rollback. Exhaustive hostile-load, Sybil/collusion, partition, and long-soak campaigns are post-alpha. | | 17 | Publish and observe the public alpha | TODO | Owner has authorized a public inference alpha, but preceding mandatory alpha gates are open. | After Gate V and Gates 1–16 pass, publish with explicit best-effort availability, unsigned-package, support, and prompt-privacy limitations; preserve the disable path and monitor real route/worker failures. | @@ -151,7 +129,7 @@ maximum still consumes the current epoch because no reset or observed charge has | Run | Provider | Purpose | Maximum estimate | Observed cost | Cleanup proof | State | | --- | --- | --- | ---: | ---: | --- | --- | -| gate13-20260831-i | GCP | Final Gate 13 manual playthrough: proven Gate 11 route first, then the exact Windows package in a real interactive ordinary-user console with visible node/product diagnostics and literal controls, followed by the same foreground Linux flow; lifecycle wrappers are prohibited until the manual steps pass [plan `sha256:8525c3099f273c099aba26de57c1f610a0c74cac65ed2640589d51e874bd0c44`] | USD 56.00 | — | [Cost authorization](evidence/gate13-20260831-i-cost-authorization.json) records exact target absence, L4 quota 1/0, protected-bootstrap health, exact packages/audits, virtual display, hard deadlines, privacy, sequencing, and exact cleanup. | RESERVED | +| gate13-20260831-i | GCP | Final Gate 13 manual clean-host playthrough: Gate 11 route acceptance first, then sequential ordinary-user Windows/Qwen and Linux/Gemma desktop qualification with literal UI controls and post-restart inference [plan `sha256:8525c3099f273c099aba26de57c1f610a0c74cac65ed2640589d51e874bd0c44`] | USD 56.00 | — | [Passed qualification and cleanup](evidence/gate13-20260831-i-manual-qualification-and-cleanup.json) proves both exact archives, packaged self-tests, real desktop start/share/restart/pause flows, Qwen and Gemma inference, the Windows long-path product fix, all exact resources absent, L4 usage zero, and the protected bootstrap running. | CLEANED-COMMITTED | | gate13-20260831-h | GCP | Final corrected Gate 13 route-first lifecycle with both four-file release-audit bundles pinned and staged, the bounded Windows user-runtime environment, exact archive preflight, and sequential ordinary-user Windows/Qwen then Linux/Gemma clients [plan `sha256:f243254cc5fb65f44d0c9e707be36feb3284fd6e15b15620882843798fb456b1`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-h-failed-attempt-and-cleanup.json) records passed route acceptance and exact Windows archive verification, one Windows failure at `signed_bootstrap/product_readiness`, no Linux create, exact instance/disk/firewall absence, L4 usage zero, and protected-bootstrap health. | CLEANED-COMMITTED | | gate13-20260831-g | GCP | Corrected Gate 13 route-first lifecycle with a bounded standard Windows user-runtime environment, one durable foreground host-adapter execution as each ordinary OS user, exact archive preflight, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:f27f36158f2ad16019578555023cc854cb1e6e3b10ebae8cd3ed24d757b8e032`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-g-failed-attempt-and-cleanup.json) records passed route acceptance, the exact Windows archive, a two-second `package_verification` failure caused by four omitted existing audit inputs, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | | gate13-20260831-f | GCP | Fresh Gate 13 route-first lifecycle using one durable foreground host-adapter execution over IAP SSH as each ordinary OS user, exact archive preflight, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:c9a2aafc84940df901a7db1755af2e684f845b78dcdfac04332cfed36388ba25`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-f-failed-attempt-and-cleanup.json) records passed route acceptance, exact Windows archive and staged-input verification, the same bounded `signed_bootstrap` failure under a direct ordinary-user launch as under S4U, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | diff --git a/docs/evidence/gate13-20260831-i-linux-paused.png b/docs/evidence/gate13-20260831-i-linux-paused.png new file mode 100644 index 0000000000000000000000000000000000000000..19e2ab7b68e898af91db0b887736c20e0b5ace32 GIT binary patch literal 118651 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

@*x9$J{C2fC_HD{eT0G3Q-_rYJ1>)%g$=;ivd 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 z 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

x;d_@4pH^$9{D*M_2L|hH=hREnGeC8w8JsqdICJ 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 literal 0 HcmV?d00001 diff --git a/docs/evidence/gate13-20260831-i-manual-qualification-and-cleanup.json b/docs/evidence/gate13-20260831-i-manual-qualification-and-cleanup.json new file mode 100644 index 000000000..83c8a4907 --- /dev/null +++ b/docs/evidence/gate13-20260831-i-manual-qualification-and-cleanup.json @@ -0,0 +1,203 @@ +{ + "schema_version": 1, + "scope": "gate13-packaged-clean-install-manual-qualification", + "gate": 13, + "run_id": "gate13-20260831-i", + "result": "passed", + "recorded_at": "2026-09-01T05:25:34.3103224Z", + "goal": "Prove that a normal user can install, open, use, restart, and control the packaged CommunityAI desktop on clean Windows and Linux hosts against the public route.", + "decision": { + "manual_playthrough_is_acceptance_source": true, + "opaque_lifecycle_wrapper_required_for_gate_decision": false, + "reason": "The manual playthrough exercised the actual desktop and exposed the real product defect hidden by the wrappers. Reinstall, uninstall, retained-data choice, and publisher release work remain Gate 15." + }, + "source": { + "published_package_commit": "1971f106cc5bf90724d938c986a719ce2744f3e7", + "windows_path_fix_commit": "f1dc3a0e38b0b2ee12150fe403fd1de435c49f71", + "windows_path_fix": "Use extended-length Windows paths for long manifest artifact partial, final, and lock paths without changing their on-disk layout." + }, + "route": { + "accepted_before_client_creation": true, + "qwen": { + "model": "Qwen3.5 2B", + "manifest": "sha256:3ba8528cb3c0d85e1ed048e0438a0d64cfbbc298944ed674caa6950d415f8e33", + "blocks": 24, + "peer_count": 1, + "primary_inference_passed": true, + "restored_inference_passed": true + }, + "gemma": { + "model": "Gemma 4 E2B IT", + "manifest": "sha256:2f8debbe0fcdf5af8d4c56c982210fa50aa584314968ae2617e2ccc2de9eafdd", + "blocks": 35, + "peer_count": 1, + "fallback_inference_passed": true + }, + "primary_fallback_restoration_total_duration_ms": 337038, + "prompts_retained": false, + "outputs_retained": false, + "credentials_retained": false + }, + "windows": { + "result": "passed", + "image": "windows-server-2025-dc-v20260814", + "machine_type": "e2-standard-8", + "ordinary_user": true, + "is_admin_during_product_run": false, + "interactive_console_session": true, + "published_archive": { + "sha256": "45e9cdb439bcb8a6d7ed67914a490f3bc2e12ea1981af944034d62d865f5adc6", + "bytes": 2695065068, + "download_verified_before_install": true + }, + "packaged_self_tests": { + "runtime": "passed", + "application": "passed", + "ui": "passed", + "onboarding_ui": "passed" + }, + "manual_flow": [ + "installed the verified archive into an empty ordinary-user install root", + "opened CommunityAI.exe in the real interactive console", + "observed complete Qwen and Gemma routes", + "ran one-token Qwen inference", + "closed the desktop and verified the desktop and node stopped", + "restarted the desktop in the same ordinary-user console", + "edited sharing limits through the UI", + "selected Qwen and clicked Start sharing", + "clicked Pause sharing after restart" + ], + "initial_defect": { + "classification": "product", + "not_gcp": true, + "not_ssh_or_scheduler": true, + "error": "Windows legacy MAX_PATH rejected a manifest artifact lock path under the default per-user data directory.", + "reproduced_with_published_package": true, + "short_data_root_control_inference": { + "passed": true, + "model": "Qwen3.5 2B", + "completion_tokens": 1, + "duration_ms": 239683 + } + }, + "fixed_default_path_inference": { + "passed": true, + "model": "Qwen3.5 2B", + "completion_tokens": 1, + "duration_ms": 204348, + "data_root": "default per-user data root", + "fixed_node_source": "f1dc3a0e38b0b2ee12150fe403fd1de435c49f71", + "prompt_retained": false, + "output_retained": false, + "credential_retained": false + }, + "screenshots": { + "ready": { + "path": "gate13-20260831-i-windows-ready.png", + "sha256": "19926d36d8d9fa1c2a8b36449e891e31dd7dfe244ec85ad8921e8793ea9e8d24" + }, + "paused_after_restart": { + "path": "gate13-20260831-i-windows-paused.png", + "sha256": "f9a1da9a94a6d9d158f0479b8e3b1647b39a7a8aa19f08328c7f533f820463b8" + } + } + }, + "linux": { + "result": "passed", + "image": "ubuntu-2404-noble-amd64-v20260826", + "machine_type": "e2-standard-8", + "ordinary_user": true, + "sudo_available_during_product_run": false, + "display": "Xvfb interactive X11 display", + "native_credential_store": "GNOME Secret Service", + "published_archive": { + "sha256": "f96d3ca651964380d4684855ab08682e8187b33386327ec3895cda25b43c2a00", + "bytes": 3360717934, + "download_verified_before_install": true + }, + "packaged_self_tests": { + "runtime": "passed", + "application": "passed", + "ui": "passed", + "onboarding_ui": "passed" + }, + "manual_flow": [ + "installed the verified archive into an empty ordinary-user install root", + "opened the real Linux desktop on an X11 display", + "observed complete Qwen and Gemma routes", + "ran one-token Gemma inference", + "edited sharing limits through the UI", + "selected Gemma and clicked Start sharing", + "stopped the complete app and node process tree", + "restarted the desktop with the native credential store", + "observed Gemma sharing resume", + "clicked Pause sharing", + "ran one-token Gemma inference again after restart and pause" + ], + "initial_inference": { + "passed": true, + "model": "Gemma 4 E2B IT", + "completion_tokens": 1, + "duration_ms": 197652, + "prompt_retained": false, + "output_retained": false, + "credential_retained": false + }, + "post_restart_inference": { + "passed": true, + "model": "Gemma 4 E2B IT", + "completion_tokens": 1, + "duration_ms": 54352, + "prompt_retained": false, + "output_retained": false, + "credential_retained": false + }, + "screenshots": { + "ready": { + "path": "gate13-20260831-i-linux-ready.png", + "sha256": "1c51f20269a032de813a309e8230abae0d87759e30288d2347c8de46f48d2e6a" + }, + "sharing": { + "path": "gate13-20260831-i-linux-sharing.png", + "sha256": "a0ef7ec97bc186d3a3ecffcb2baf9241074cf382ff2bb441bcf6853da86857fc" + }, + "paused_after_restart": { + "path": "gate13-20260831-i-linux-paused.png", + "sha256": "343abc26d180578ba97723e2f31e1717dbed2eb247b81ae23aaab69f53207a0b" + } + } + }, + "cleanup": { + "exact_instances_absent": [ + "route-20260831-i-node", + "gate13-20260831-i-win", + "gate13-20260831-i-linux" + ], + "exact_disks_absent": [ + "route-20260831-i-node", + "gate13-20260831-i-win", + "gate13-20260831-i-linux" + ], + "exact_firewalls_absent": [ + "route-20260831-i-dht", + "route-20260831-i-iap" + ], + "global_l4_quota": { + "metric": "GPUS_ALL_REGIONS", + "limit": 1, + "usage": 0 + }, + "protected_bootstrap": { + "name": "communityai-bootstrap-1", + "status": "RUNNING" + }, + "passed": true + }, + "privacy": { + "prompts_retained": false, + "outputs_retained": false, + "credentials_retained": false, + "signed_urls_retained": false, + "provider_endpoints_retained": false + } +} diff --git a/docs/evidence/gate13-20260831-i-windows-paused.png b/docs/evidence/gate13-20260831-i-windows-paused.png new file mode 100644 index 0000000000000000000000000000000000000000..3e9846db37a882300aee2c82d6750c8ea91a8ec2 GIT binary patch literal 21452 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�aUd&G?jst0$W1Y=-|##{U9* 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 literal 0 HcmV?d00001 From 1476d67f3887dfd0de2acfb1305cbcca9975614f Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:32:13 -0500 Subject: [PATCH 30/56] test: automate Gate 13 desktop replay --- CHANGELOG.md | 9 + desktop/build_desktop.py | 2 + desktop/src/communityai_desktop/acceptance.py | 4 +- desktop/src/communityai_desktop/app.py | 19 + .../communityai_desktop/gate13_playthrough.py | 617 ++++++++++++++++++ .../src/communityai_desktop/pyside_shell.py | 21 + desktop/tests/test_gate13_playthrough.py | 228 +++++++ docs/PACKAGED_ALPHA_OPERATIONS.md | 90 ++- docs/RELEASE_READINESS.md | 8 + scripts/gate13_automated_playthrough.py | 499 ++++++++++++++ scripts/gate13_host_job.py | 18 +- scripts/gate13_packaged_lifecycle.py | 106 +++ tests/test_gate13_automated_playthrough.py | 161 +++++ tests/test_gate13_host_job.py | 14 + tests/test_gate13_packaged_lifecycle.py | 42 ++ tests/test_gate13_run_controller.py | 60 +- 16 files changed, 1871 insertions(+), 27 deletions(-) create mode 100644 desktop/src/communityai_desktop/gate13_playthrough.py create mode 100644 desktop/tests/test_gate13_playthrough.py create mode 100644 scripts/gate13_automated_playthrough.py create mode 100644 tests/test_gate13_automated_playthrough.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 91aa5b4d7..d23deeae6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and qualification evidence remains in `docs/REVIVAL_TEST_RESULTS.md`. ### Added +- Gate 13's successful manual desktop flow now has a bounded automated replay. The + production package can open its real Qt window in a hidden qualification mode, perform + localhost inference, save the actual sharing-policy dialog, click Start, exit, relaunch, + prove sharing resumed, click Pause, and infer again without retaining prompt, response, + credential, endpoint, or path data. A standard-library outer runner verifies the exact + production archive, runs all four packaged self-tests, executes both sessions, validates + canonical evidence, and removes its run temporaries. The durable host-job boundary now + accepts the Python replay entrypoint on Windows and Linux. Local real-window and contract + tests pass; no paid clean-host replay or new release artifact is claimed by this change. - The owner raised the current combined GCP/Fly public-alpha accounting epoch to USD 500 on 2026-08-31. The existing USD 52 committed maximum remains charged. Run `gate13-20260831-b` reserved USD 56 but failed before VM creation when its two IAP tags reached diff --git a/desktop/build_desktop.py b/desktop/build_desktop.py index 2dac1013d..7ed0aa638 100644 --- a/desktop/build_desktop.py +++ b/desktop/build_desktop.py @@ -1269,6 +1269,8 @@ def main() -> int: str(build_root / "spec"), "--hidden-import", "communityai_desktop.pyside_shell", + "--hidden-import", + "communityai_desktop.gate13_playthrough", "--add-data", f"{icon_path}{os.pathsep}communityai_desktop/assets", ] diff --git a/desktop/src/communityai_desktop/acceptance.py b/desktop/src/communityai_desktop/acceptance.py index 35009ad6f..567d757d2 100644 --- a/desktop/src/communityai_desktop/acceptance.py +++ b/desktop/src/communityai_desktop/acceptance.py @@ -308,8 +308,10 @@ def do_DELETE(self): # noqa: N802 @contextmanager -def fake_node() -> Iterator[Tuple[str, str]]: +def fake_node(*, all_workers_paused: bool = False) -> Iterator[Tuple[str, str]]: state = _FakeNodeState() + if all_workers_paused: + state.worker_states = {worker_id: (model, "paused") for worker_id, (model, _) in state.worker_states.items()} server = ThreadingHTTPServer(("127.0.0.1", 0), _handler(state)) thread = threading.Thread(target=server.serve_forever, name="desktop-acceptance-node", daemon=True) thread.start() diff --git a/desktop/src/communityai_desktop/app.py b/desktop/src/communityai_desktop/app.py index 91c56717a..63b50ac21 100644 --- a/desktop/src/communityai_desktop/app.py +++ b/desktop/src/communityai_desktop/app.py @@ -44,6 +44,8 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--no-manage-node", action="store_true", help=argparse.SUPPRESS) parser.add_argument(LOGIN_STARTUP_FLAG, action="store_true", help=argparse.SUPPRESS) parser.add_argument("--capture-page", type=int, default=0, help=argparse.SUPPRESS) + parser.add_argument("--gate13-ui-evidence", type=Path, help=argparse.SUPPRESS) + parser.add_argument("--gate13-ui-screenshot", type=Path, help=argparse.SUPPRESS) action = parser.add_mutually_exclusive_group() action.add_argument("--store-control-key", action="store_true") action.add_argument("--delete-control-key", action="store_true") @@ -53,6 +55,7 @@ def build_parser() -> argparse.ArgumentParser: action.add_argument("--onboarding-ui-self-test", action="store_true", help=argparse.SUPPRESS) action.add_argument("--capture-ui", type=Path, help=argparse.SUPPRESS) action.add_argument("--probe-only", action="store_true", help=argparse.SUPPRESS) + action.add_argument("--gate13-ui-playthrough", type=Path, help=argparse.SUPPRESS) return parser @@ -69,6 +72,11 @@ def _write_json(value: Any) -> None: def main(argv: Optional[Sequence[str]] = None) -> int: parser = build_parser() args = parser.parse_args(argv) + if args.gate13_ui_playthrough is None: + if args.gate13_ui_evidence is not None or args.gate13_ui_screenshot is not None: + parser.error("Gate 13 evidence options require --gate13-ui-playthrough") + elif args.gate13_ui_evidence is None: + parser.error("--gate13-ui-playthrough requires --gate13-ui-evidence") try: if args.self_test: _write_json(run_self_test()) @@ -150,6 +158,16 @@ def connect() -> DesktopController: token = credential_store.get_or_migrate() return DesktopController(NodeClient(node_url, token, timeout=args.timeout)) + qualification_automation = None + if args.gate13_ui_playthrough is not None: + from communityai_desktop.gate13_playthrough import Gate13Playthrough, PlaythroughPlan + + qualification_automation = Gate13Playthrough( + PlaythroughPlan.load(args.gate13_ui_playthrough), + args.gate13_ui_evidence, + screenshot_path=args.gate13_ui_screenshot, + ) + if args.probe_only: try: _write_json(connect().snapshot()) @@ -169,6 +187,7 @@ def connect() -> DesktopController: start_minimized=args.started_at_login, activate_existing_instance=not args.started_at_login, before_termination_restore=None if lifecycle is None else lifecycle.close, + qualification_automation=qualification_automation, ) or 0 ) diff --git a/desktop/src/communityai_desktop/gate13_playthrough.py b/desktop/src/communityai_desktop/gate13_playthrough.py new file mode 100644 index 000000000..fc742bba3 --- /dev/null +++ b/desktop/src/communityai_desktop/gate13_playthrough.py @@ -0,0 +1,617 @@ +"""Automate the real packaged Gate 13 desktop playthrough. + +This module is deliberately part of the frozen desktop rather than an external UI +mock. A qualification invocation opens the normal window, uses the real sharing +policy dialog and Start/Pause buttons, and performs one bounded localhost inference +with an ephemeral client key. It retains only bounded acceptance facts. +""" + +from __future__ import annotations + +import json +import math +import os +import re +import stat +import tempfile +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Mapping, Sequence +from urllib.error import HTTPError, URLError +from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener + +from communityai_desktop.client import normalize_loopback_url + +SCHEMA_VERSION = 1 +SCOPE = "gate13-packaged-desktop-playthrough" +MAX_CONFIG_BYTES = 65_536 +MAX_RESPONSE_BYTES = 1_048_576 +QUALIFICATION_KEY_LABEL = "Gate 13 automated qualification" + +_RUN_RE = re.compile(r"[a-z0-9][a-z0-9-]{0,62}") +_DIGEST_RE = re.compile(r"sha256:[0-9a-f]{64}") +_MODEL_RE = re.compile(r"[ -~]{1,128}") +_POLICY_FIELDS = { + "sharing_enabled", + "allowed_models", + "preferred_models", + "denied_models", + "max_disk_space", + "max_vram", + "max_bandwidth_mbps", + "max_power_watts", + "pause_timeout", + "schedule", +} +_CONFIG_FIELDS = { + "schema_version", + "run_id", + "stage", + "model_id", + "manifest_digest", + "total_blocks", + "policy", + "timeout_seconds", + "inference_timeout_seconds", +} + + +class PlaythroughError(ValueError): + """A qualification plan or observed desktop state failed closed.""" + + +def _reject_constant(_value: str) -> None: + raise PlaythroughError("configuration contains a non-finite value") + + +def _unique_object(pairs: Sequence[tuple[str, Any]]) -> dict[str, Any]: + value: dict[str, Any] = {} + for key, item in pairs: + if key in value: + raise PlaythroughError("configuration contains a duplicate field") + value[key] = item + return value + + +def _regular_bytes(path: Path, maximum: int) -> bytes: + path = Path(path) + try: + metadata = path.lstat() + except OSError as exc: + raise PlaythroughError("configuration is unavailable") from exc + reparse = bool(getattr(metadata, "st_file_attributes", 0) & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)) + if reparse or path.is_symlink() or not stat.S_ISREG(metadata.st_mode) or not 1 <= metadata.st_size <= maximum: + raise PlaythroughError("configuration is not a bounded regular file") + try: + return path.read_bytes() + except OSError as exc: + raise PlaythroughError("configuration is unreadable") from exc + + +def _bounded_number(value: Any, label: str, *, minimum: float, maximum: float) -> float: + if type(value) not in (int, float): + raise PlaythroughError(f"{label} is invalid") + rendered = float(value) + if not math.isfinite(rendered) or not minimum <= rendered <= maximum: + raise PlaythroughError(f"{label} is invalid") + return rendered + + +def _selectors(value: Any, label: str) -> tuple[str, ...]: + if not isinstance(value, list) or len(value) > 8: + raise PlaythroughError(f"{label} is invalid") + clean: list[str] = [] + folded: set[str] = set() + for item in value: + if not isinstance(item, str) or _MODEL_RE.fullmatch(item) is None or item != item.strip(): + raise PlaythroughError(f"{label} is invalid") + canonical = item.casefold() + if canonical in folded: + raise PlaythroughError(f"{label} contains a duplicate selector") + folded.add(canonical) + clean.append(item) + return tuple(clean) + + +def _policy(value: Any, model_id: str) -> dict[str, Any]: + if not isinstance(value, dict) or set(value) != _POLICY_FIELDS: + raise PlaythroughError("sharing policy schema is invalid") + allowed = _selectors(value["allowed_models"], "allowed models") + preferred = _selectors(value["preferred_models"], "preferred models") + denied = _selectors(value["denied_models"], "denied models") + if value["sharing_enabled"] is not True: + raise PlaythroughError("sharing must be enabled for the start stage") + if model_id not in allowed or model_id not in preferred or denied: + raise PlaythroughError("sharing policy does not select the qualification model") + if not isinstance(value["max_disk_space"], str) or not 1 <= len(value["max_disk_space"]) <= 32: + raise PlaythroughError("storage ceiling is invalid") + if not isinstance(value["max_vram"], str) or not 1 <= len(value["max_vram"]) <= 32: + raise PlaythroughError("memory ceiling is invalid") + bandwidth = _bounded_number(value["max_bandwidth_mbps"], "bandwidth ceiling", minimum=0.001, maximum=1_000_000) + power = _bounded_number(value["max_power_watts"], "power ceiling", minimum=0.001, maximum=1_000_000) + pause = _bounded_number(value["pause_timeout"], "pause timeout", minimum=1, maximum=300) + if value["schedule"] is not None: + raise PlaythroughError("Gate 13 qualification requires an unrestricted schedule") + return { + "sharing_enabled": True, + "allowed_models": list(allowed), + "preferred_models": list(preferred), + "denied_models": list(denied), + "max_disk_space": value["max_disk_space"], + "max_vram": value["max_vram"], + "max_bandwidth_mbps": bandwidth, + "max_power_watts": power, + "pause_timeout": pause, + "schedule": None, + } + + +@dataclass(frozen=True) +class PlaythroughPlan: + run_id: str + stage: str + model_id: str + manifest_digest: str + total_blocks: int + policy: Mapping[str, Any] + timeout_seconds: float + inference_timeout_seconds: float + + @classmethod + def load(cls, path: Path) -> "PlaythroughPlan": + payload = _regular_bytes(path, MAX_CONFIG_BYTES) + try: + raw = json.loads( + payload.decode("utf-8"), + object_pairs_hook=_unique_object, + parse_constant=_reject_constant, + ) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise PlaythroughError("configuration is invalid JSON") from exc + if not isinstance(raw, dict) or set(raw) != _CONFIG_FIELDS or raw.get("schema_version") != SCHEMA_VERSION: + raise PlaythroughError("configuration schema is invalid") + run_id = raw["run_id"] + stage = raw["stage"] + model_id = raw["model_id"] + digest = raw["manifest_digest"] + total_blocks = raw["total_blocks"] + if not isinstance(run_id, str) or _RUN_RE.fullmatch(run_id) is None: + raise PlaythroughError("run id is invalid") + if stage not in ("start", "resume_pause"): + raise PlaythroughError("playthrough stage is invalid") + if not isinstance(model_id, str) or _MODEL_RE.fullmatch(model_id) is None or model_id != model_id.strip(): + raise PlaythroughError("model id is invalid") + if not isinstance(digest, str) or _DIGEST_RE.fullmatch(digest) is None: + raise PlaythroughError("manifest digest is invalid") + if type(total_blocks) is not int or not 1 <= total_blocks <= 512: + raise PlaythroughError("block count is invalid") + timeout = _bounded_number(raw["timeout_seconds"], "playthrough timeout", minimum=30, maximum=3_600) + inference_timeout = _bounded_number( + raw["inference_timeout_seconds"], "inference timeout", minimum=10, maximum=600 + ) + return cls( + run_id=run_id, + stage=stage, + model_id=model_id, + manifest_digest=digest, + total_blocks=total_blocks, + policy=_policy(raw["policy"], model_id), + timeout_seconds=timeout, + inference_timeout_seconds=inference_timeout, + ) + + +class _RejectRedirects(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001, ARG002 + return None + + +def _completion_request(url: str, secret: str, timeout: float) -> Mapping[str, Any]: + body = json.dumps( + { + "model": "auto", + "messages": [{"role": "user", "content": "Reply with one short word."}], + "temperature": 0, + "max_tokens": 8, + "n": 1, + "stream": False, + }, + separators=(",", ":"), + ).encode("utf-8") + request = Request( + url, + data=body, + method="POST", + headers={ + "Authorization": f"Bearer {secret}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + opener = build_opener(ProxyHandler({}), _RejectRedirects()) + try: + with opener.open(request, timeout=timeout) as response: + if response.status != 200 or response.headers.get_content_type() != "application/json": + raise PlaythroughError("localhost inference was rejected") + payload = response.read(MAX_RESPONSE_BYTES + 1) + except (HTTPError, URLError, OSError, TimeoutError) as exc: + raise PlaythroughError("localhost inference failed") from exc + if not 1 <= len(payload) <= MAX_RESPONSE_BYTES: + raise PlaythroughError("localhost inference response is invalid") + try: + value = json.loads(payload.decode("utf-8"), parse_constant=_reject_constant) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise PlaythroughError("localhost inference response is invalid") from exc + if not isinstance(value, dict): + raise PlaythroughError("localhost inference response is invalid") + return value + + +def qualify_localhost_inference(controller: Any, plan: PlaythroughPlan) -> dict[str, Any]: + """Run one response-content-free localhost inference and restore the API-key baseline.""" + + baseline_items = [item for item in controller.client.list_keys() if item.get("revoked_at") is None] + baseline = {item["id"] for item in baseline_items} + if not baseline: + raise PlaythroughError("a preexisting client key is required") + if any(item.get("label") == QUALIFICATION_KEY_LABEL for item in baseline_items): + raise PlaythroughError("a prior qualification key remains active") + created_id = "" + secret = "" + failed = False + cleanup_failed = False + completion_count = 0 + generated_token_count = 0 + try: + status = controller.client.status() + selection = status["auto_selection"] + if ( + selection.get("status") != "selected" + or selection.get("model") != plan.model_id + or selection.get("manifest_digest") != plan.manifest_digest + ): + raise PlaythroughError("automatic selection changed before inference") + created = controller.client.create_key(QUALIFICATION_KEY_LABEL) + created_id = created.get("key", {}).get("id", "") + secret = created.get("secret", "") + if ( + not isinstance(created_id, str) + or not created_id + or not isinstance(secret, str) + or not 1 <= len(secret) <= 512 + ): + raise PlaythroughError("temporary client key response is invalid") + base = normalize_loopback_url(status["openai_base_url"]) + completion = _completion_request(f"{base}/v1/chat/completions", secret, plan.inference_timeout_seconds) + if completion.get("object") != "chat.completion" or completion.get("model") != plan.model_id: + raise PlaythroughError("localhost inference identity is invalid") + choices = completion.get("choices") + usage = completion.get("usage") + if not isinstance(choices, list) or len(choices) != 1 or not isinstance(choices[0], dict): + raise PlaythroughError("localhost inference completion count is invalid") + message = choices[0].get("message") + if ( + not isinstance(message, dict) + or not isinstance(message.get("content"), str) + or not message["content"].strip() + ): + raise PlaythroughError("localhost inference content is empty") + generated = usage.get("completion_tokens") if isinstance(usage, dict) else None + if type(generated) is not int or not 1 <= generated <= 8: + raise PlaythroughError("localhost inference token count is invalid") + completion_count = 1 + generated_token_count = generated + except BaseException: + failed = True + finally: + secret = "" + try: + active = {item["id"] for item in controller.client.list_keys() if item.get("revoked_at") is None} + candidates = active - baseline + if created_id and created_id in active: + candidates.add(created_id) + if len(candidates) != 1: + raise PlaythroughError("temporary client key identity is ambiguous") + controller.client.revoke_key(next(iter(candidates))) + after = {item["id"] for item in controller.client.list_keys() if item.get("revoked_at") is None} + if after != baseline: + raise PlaythroughError("temporary client key cleanup failed") + except BaseException: + cleanup_failed = True + if failed or cleanup_failed: + raise PlaythroughError("localhost inference or key cleanup failed") + return { + "passed": True, + "model_id": plan.model_id, + "manifest_digest": plan.manifest_digest, + "completion_count": completion_count, + "generated_token_count": generated_token_count, + "response_content_retained": False, + "token_identifiers_retained": False, + "temporary_key_removed": True, + } + + +def _atomic_json(path: Path, value: Mapping[str, Any]) -> None: + destination = Path(path).absolute() + destination.parent.mkdir(parents=True, exist_ok=True) + if destination.exists() and (destination.is_symlink() or not destination.is_file()): + raise PlaythroughError("evidence destination is unsafe") + payload = (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8") + temporary_name = "" + try: + with tempfile.NamedTemporaryFile( + prefix=".gate13-playthrough-", suffix=".tmp", dir=destination.parent, delete=False + ) as out: + temporary_name = out.name + out.write(payload) + out.flush() + os.fsync(out.fileno()) + os.replace(temporary_name, destination) + temporary_name = "" + except OSError as exc: + raise PlaythroughError("evidence could not be persisted") from exc + finally: + if temporary_name: + try: + Path(temporary_name).unlink() + except OSError: + pass + + +class Gate13Playthrough: + """A bounded Qt state machine that drives the real packaged controls.""" + + def __init__( + self, + plan: PlaythroughPlan, + evidence_path: Path, + *, + screenshot_path: Path | None = None, + inference_runner: Callable[[Any, PlaythroughPlan], Mapping[str, Any]] = qualify_localhost_inference, + clock: Callable[[], float] = time.monotonic, + ): + self.plan = plan + self.evidence_path = Path(evidence_path) + self.screenshot_path = None if screenshot_path is None else Path(screenshot_path) + self._inference_runner = inference_runner + self._clock = clock + self._started = clock() + self._state = "wait_ready" + self._done = False + self._inference: Mapping[str, Any] | None = None + self._window = None + self._application = None + self._qt: Mapping[str, Any] = {} + self._timer = None + + def install(self, window: Any, application: Any, qt: Mapping[str, Any]) -> None: + self._window = window + self._application = application + self._qt = qt + timer_type = qt["QTimer"] + self._timer = timer_type(window) + self._timer.setInterval(200) + self._timer.timeout.connect(self._tick) + self._timer.start() + timer_type.singleShot(max(1, int(self.plan.timeout_seconds * 1_000)), self._timeout) + + def _timeout(self) -> None: + if not self._done: + self._fail() + + def _ready(self) -> bool: + window = self._window + if window is None or window._controller is None or window._busy: + return False + snapshot = window._snapshot + selection = snapshot.get("auto_selection", {}) + models = snapshot.get("models", []) + selected = next((item for item in models if item.get("id") == self.plan.model_id), None) + return bool( + selection.get("status") == "selected" + and selection.get("model") == self.plan.model_id + and selection.get("manifest_digest") == self.plan.manifest_digest + and selection.get("covered_blocks") == self.plan.total_blocks + and selection.get("total_blocks") == self.plan.total_blocks + and isinstance(selection.get("peer_count"), int) + and selection["peer_count"] > 0 + and selected is not None + and selected.get("route_complete") is True + and selected.get("covered_blocks") == self.plan.total_blocks + and selected.get("total_blocks") == self.plan.total_blocks + ) + + def _tick(self) -> None: + if self._done or self._window is None: + return + try: + if self._state == "wait_ready": + if not self._ready(): + return + if self.plan.stage == "start": + self._begin_inference("wait_inference_before_policy") + else: + self._state = "wait_resumed" + elif self._state == "wait_policy": + contribution = self._window._snapshot.get("contribution", {}) + if not self._window._busy and contribution.get("policy") == self.plan.policy: + self._click_start() + elif self._state == "wait_started": + contribution = self._window._snapshot.get("contribution", {}) + workers = self._window._snapshot.get("workers", []) + active = any(item.get("model") == self.plan.model_id and item.get("sharing_active") for item in workers) + if contribution.get("intent_enabled") and contribution.get("enabled") and active: + self._pass(started=True) + elif self._state == "wait_resumed": + contribution = self._window._snapshot.get("contribution", {}) + workers = self._window._snapshot.get("workers", []) + active = any(item.get("model") == self.plan.model_id and item.get("sharing_active") for item in workers) + if contribution.get("intent_enabled") and contribution.get("enabled") and active: + self._click_pause() + elif self._state == "wait_paused": + contribution = self._window._snapshot.get("contribution", {}) + workers = self._window._snapshot.get("workers", []) + target = [item for item in workers if item.get("model") == self.plan.model_id] + if ( + not self._window._busy + and not contribution.get("intent_enabled") + and not contribution.get("enabled") + and target + and all(not item.get("desired_running") and not item.get("sharing_active") for item in target) + ): + self._begin_inference("wait_inference_after_pause") + except BaseException: + self._fail() + + def _begin_inference(self, waiting_state: str) -> None: + self._state = waiting_state + controller = self._window._controller + + def finished(result: Mapping[str, Any]) -> None: + self._inference = dict(result) + if waiting_state == "wait_inference_before_policy": + self._begin_policy_edit() + else: + self._pass(paused=True) + + self._window._submit( + lambda: self._inference_runner(controller, self.plan), + finished, + lambda _message: self._fail(), + ) + + def _begin_policy_edit(self) -> None: + if self._window.edit_policy_button.isEnabled() is False: + self._fail() + return + self._state = "editing_policy" + self._qt["QTimer"].singleShot(100, self._fill_policy_dialog) + self._window.edit_policy_button.click() + + def _fill_policy_dialog(self) -> None: + try: + dialog = self._window.findChild(self._qt["QDialog"], "sharingPolicyDialog") + if dialog is None: + raise PlaythroughError("sharing policy dialog did not open") + checkbox = dialog.findChild(self._qt["QCheckBox"], "policy_sharing_enabled") + checkbox.setChecked(True) + for field in ("allowed_models", "preferred_models", "denied_models"): + editor = dialog.findChild(self._qt["QPlainTextEdit"], f"policy_{field}") + editor.setPlainText("\n".join(self.plan.policy[field])) + for field in ( + "max_disk_space", + "max_vram", + "max_bandwidth_mbps", + "max_power_watts", + "pause_timeout", + ): + editor = dialog.findChild(self._qt["QLineEdit"], f"policy_{field}") + value = self.plan.policy[field] + editor.setText(f"{value:g}" if isinstance(value, float) else str(value)) + schedule = dialog.findChild(self._qt["QPlainTextEdit"], "policy_schedule") + schedule.clear() + buttons = dialog.findChild(self._qt["QDialogButtonBox"], "sharingPolicyButtons") + save = buttons.button(self._qt["QDialogButtonBox"].StandardButton.Save) + self._state = "wait_policy" + save.click() + except BaseException: + self._fail() + + def _click_start(self) -> None: + button = self._window.master_share_button + if button.text() != "Start sharing" or not button.isEnabled(): + return + self._state = "wait_started" + button.click() + + def _click_pause(self) -> None: + button = self._window.master_share_button + if button.text() != "Pause sharing" or not button.isEnabled(): + return + self._state = "wait_paused" + button.click() + + def _base_result(self, result: str) -> dict[str, Any]: + duration = max(0.0, self._clock() - self._started) + return { + "schema_version": SCHEMA_VERSION, + "scope": SCOPE, + "run_id": self.plan.run_id, + "stage": self.plan.stage, + "result": result, + "model_id": self.plan.model_id, + "manifest_digest": self.plan.manifest_digest, + "duration_seconds": round(duration, 6), + } + + def _pass(self, *, started: bool = False, paused: bool = False) -> None: + if self._done or self._inference is None: + self._fail() + return + value = self._base_result("passed") + value.update( + { + "route": { + "rendered_in_real_window": True, + "complete": True, + "covered_blocks": self.plan.total_blocks, + "total_blocks": self.plan.total_blocks, + }, + "inference": dict(self._inference), + "ui": { + "real_window_opened": True, + "policy_dialog_saved": self.plan.stage == "start", + "start_clicked": started, + "sharing_running_observed": started, + "resumed_after_restart_observed": paused, + "pause_clicked": paused, + "sharing_paused_observed": paused, + }, + "limits": { + "storage": True, + "memory_or_vram": True, + "bandwidth": True, + "power": True, + "pause_timeout": True, + }, + "privacy": { + "prompt_retained": False, + "response_content_retained": False, + "token_identifiers_retained": False, + "credentials_retained": False, + "paths_retained": False, + "endpoints_retained": False, + }, + } + ) + self._finish(value) + + def _fail(self) -> None: + if self._done: + return + value = self._base_result("failed") + value["failure_code"] = "playthrough_failed" + self._finish(value) + + def _finish(self, value: Mapping[str, Any]) -> None: + self._done = True + if self._timer is not None: + self._timer.stop() + try: + if self.screenshot_path is not None and self._window is not None: + self.screenshot_path.parent.mkdir(parents=True, exist_ok=True) + if not self._window.grab().save(str(self.screenshot_path)): + raise PlaythroughError("playthrough screenshot failed") + _atomic_json(self.evidence_path, value) + except BaseException: + fallback = self._base_result("failed") + fallback["failure_code"] = "evidence_write_failed" + try: + _atomic_json(self.evidence_path, fallback) + except BaseException: + pass + finally: + if self._application is not None: + self._application.quit() diff --git a/desktop/src/communityai_desktop/pyside_shell.py b/desktop/src/communityai_desktop/pyside_shell.py index d354e704b..db59231cf 100644 --- a/desktop/src/communityai_desktop/pyside_shell.py +++ b/desktop/src/communityai_desktop/pyside_shell.py @@ -188,6 +188,7 @@ def run( activate_existing_instance: bool = True, instance_name: str | None = None, before_termination_restore: Callable[[], None] | None = None, + qualification_automation=None, # noqa: ANN001 ) -> int: if controller is None and connect is None: raise ValueError("the desktop requires an initial controller or connector") @@ -1060,6 +1061,7 @@ def _edit_contribution_policy(self) -> None: return dialog = QDialog(self) + dialog.setObjectName("sharingPolicyDialog") dialog.setWindowTitle("Edit sharing limits") dialog.setMinimumWidth(620) layout = QVBoxLayout(dialog) @@ -1072,6 +1074,7 @@ def _edit_contribution_policy(self) -> None: form = QFormLayout() sharing_enabled = QCheckBox("Allow this node to share compute") + sharing_enabled.setObjectName("policy_sharing_enabled") sharing_enabled.setChecked(policy["sharing_enabled"]) form.addRow("Sharing", sharing_enabled) @@ -1082,6 +1085,7 @@ def _edit_contribution_policy(self) -> None: ("denied_models", "Denied models"), ): editor = QPlainTextEdit() + editor.setObjectName(f"policy_{field}") editor.setPlainText("\n".join(policy[field])) editor.setPlaceholderText("One exact model selector per line") editor.setFixedHeight(64) @@ -1098,6 +1102,7 @@ def _edit_contribution_policy(self) -> None: ("pause_timeout", "Pause timeout (seconds)", "10"), ): editor = QLineEdit() + editor.setObjectName(f"policy_{field}") value = policy[field] editor.setText("" if value is None else f"{value:g}" if isinstance(value, float) else str(value)) editor.setPlaceholderText(placeholder) @@ -1106,6 +1111,7 @@ def _edit_contribution_policy(self) -> None: form.addRow(title, editor) schedule = QPlainTextEdit() + schedule.setObjectName("policy_schedule") schedule.setPlainText("" if policy["schedule"] is None else json.dumps(policy["schedule"], indent=2)) schedule.setPlaceholderText( '{"timezone":"local","windows":[{"days":["mon"],"start":"22:00","end":"06:00"}]}' @@ -1116,6 +1122,7 @@ def _edit_contribution_policy(self) -> None: layout.addLayout(form) buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel) + buttons.setObjectName("sharingPolicyButtons") buttons.accepted.connect(dialog.accept) buttons.rejected.connect(dialog.reject) layout.addWidget(buttons) @@ -1311,6 +1318,20 @@ def notify_existing_instance(timeout_ms: int) -> bool: else: window.show() + if qualification_automation is not None: + qualification_automation.install( + window, + application, + { + "QTimer": QTimer, + "QDialog": QDialog, + "QDialogButtonBox": QDialogButtonBox, + "QCheckBox": QCheckBox, + "QPlainTextEdit": QPlainTextEdit, + "QLineEdit": QLineEdit, + }, + ) + if instance_server is not None: def activate_window() -> None: diff --git a/desktop/tests/test_gate13_playthrough.py b/desktop/tests/test_gate13_playthrough.py new file mode 100644 index 000000000..a224dd2a1 --- /dev/null +++ b/desktop/tests/test_gate13_playthrough.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +import json +import os +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from communityai_desktop.acceptance import fake_node +from communityai_desktop.app import main +from communityai_desktop.client import NodeClient +from communityai_desktop.controller import DesktopController +from communityai_desktop.gate13_playthrough import ( + Gate13Playthrough, + PlaythroughError, + PlaythroughPlan, + qualify_localhost_inference, +) + +MODEL_ID = "Qwen 3 8B" +MANIFEST_DIGEST = "sha256:" + "b" * 64 + + +def _config(stage: str) -> dict: + return { + "schema_version": 1, + "run_id": "gate13-automated-test", + "stage": stage, + "model_id": MODEL_ID, + "manifest_digest": MANIFEST_DIGEST, + "total_blocks": 36, + "policy": { + "sharing_enabled": True, + "allowed_models": [MODEL_ID], + "preferred_models": [MODEL_ID], + "denied_models": [], + "max_disk_space": "20GiB", + "max_vram": "8GiB", + "max_bandwidth_mbps": 100.0, + "max_power_watts": 250.0, + "pause_timeout": 30.0, + "schedule": None, + }, + "timeout_seconds": 30.0, + "inference_timeout_seconds": 10.0, + } + + +def _write_plan(path: Path, stage: str) -> PlaythroughPlan: + path.write_text(json.dumps(_config(stage)), encoding="utf-8") + return PlaythroughPlan.load(path) + + +def _inference(_controller, plan): # noqa: ANN001 + return { + "passed": True, + "model_id": plan.model_id, + "manifest_digest": plan.manifest_digest, + "completion_count": 1, + "generated_token_count": 1, + "response_content_retained": False, + "token_identifiers_retained": False, + "temporary_key_removed": True, + } + + +class PlaythroughPlanTests(unittest.TestCase): + def test_plan_is_strict_and_bounded(self): + from tempfile import TemporaryDirectory + + with TemporaryDirectory() as directory: + root = Path(directory) + plan = _write_plan(root / "plan.json", "start") + self.assertEqual(plan.model_id, MODEL_ID) + self.assertEqual(plan.policy["allowed_models"], [MODEL_ID]) + + invalid = _config("start") + invalid["policy"]["denied_models"] = [MODEL_ID] + (root / "invalid.json").write_text(json.dumps(invalid), encoding="utf-8") + with self.assertRaises(PlaythroughError): + PlaythroughPlan.load(root / "invalid.json") + + (root / "duplicate.json").write_text('{"schema_version":1,"schema_version":1}', encoding="utf-8") + with self.assertRaises(PlaythroughError): + PlaythroughPlan.load(root / "duplicate.json") + + def test_localhost_inference_restores_key_baseline_and_retains_only_counts(self): + plan = PlaythroughPlan( + run_id="gate13-automated-test", + stage="start", + model_id=MODEL_ID, + manifest_digest=MANIFEST_DIGEST, + total_blocks=36, + policy=_config("start")["policy"], + timeout_seconds=30, + inference_timeout_seconds=10, + ) + + class Client: + def __init__(self): + self.active = { + "baseline": { + "id": "baseline", + "label": "baseline", + "revoked_at": None, + } + } + + def list_keys(self): + return list(self.active.values()) + + def status(self): + return { + "openai_base_url": "http://127.0.0.1:8080/v1", + "auto_selection": { + "status": "selected", + "model": MODEL_ID, + "manifest_digest": MANIFEST_DIGEST, + }, + } + + def create_key(self, label): + self.active["temporary"] = {"id": "temporary", "label": label, "revoked_at": None} + return {"key": self.active["temporary"], "secret": "temporary-secret"} + + def revoke_key(self, key_id): + self.active[key_id]["revoked_at"] = 1 + return {"key": self.active[key_id]} + + client = Client() + completion = { + "object": "chat.completion", + "model": MODEL_ID, + "choices": [{"message": {"role": "assistant", "content": "yes"}}], + "usage": {"completion_tokens": 1}, + } + with patch("communityai_desktop.gate13_playthrough._completion_request", return_value=completion): + result = qualify_localhost_inference(SimpleNamespace(client=client), plan) + + self.assertEqual(result["completion_count"], 1) + self.assertFalse(result["response_content_retained"]) + self.assertEqual({item["id"] for item in client.list_keys() if item["revoked_at"] is None}, {"baseline"}) + + def test_hidden_packaged_cli_installs_the_qualification_automation(self): + lifecycle = SimpleNamespace(close=lambda: None) + loaded_plan = SimpleNamespace(stage="start") + automation = SimpleNamespace() + with ( + patch("communityai_desktop.app.NodeLifecycleSupervisor", return_value=lifecycle), + patch("communityai_desktop.gate13_playthrough.PlaythroughPlan.load", return_value=loaded_plan), + patch("communityai_desktop.gate13_playthrough.Gate13Playthrough", return_value=automation), + patch("communityai_desktop.pyside_shell.run", return_value=0) as run, + ): + result = main( + [ + "--gate13-ui-playthrough", + "plan.json", + "--gate13-ui-evidence", + "evidence.json", + ] + ) + + self.assertEqual(result, 0) + self.assertIs(run.call_args.kwargs["qualification_automation"], automation) + + with self.assertRaises(SystemExit): + main(["--self-test", "--gate13-ui-evidence", "orphan.json"]) + + +class PackagedUiPlaythroughTests(unittest.TestCase): + def test_real_window_runs_start_restart_resume_pause_sequence(self): + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + try: + from PySide6.QtWidgets import QApplication + except ModuleNotFoundError as exc: + self.skipTest(f"PySide6 is unavailable: {exc}") + + from tempfile import TemporaryDirectory + + from communityai_desktop.pyside_shell import run + + QApplication.instance() or QApplication([]) + with TemporaryDirectory() as directory, fake_node(all_workers_paused=True) as (url, token): + root = Path(directory) + controller = DesktopController(NodeClient(url, token)) + start = Gate13Playthrough( + _write_plan(root / "start-plan.json", "start"), + root / "start-evidence.json", + inference_runner=_inference, + ) + with patch("communityai_desktop.pyside_shell.login_startup_enabled", return_value=False): + self.assertEqual( + run( + controller, + single_instance=False, + qualification_automation=start, + ), + 0, + ) + start_evidence = json.loads((root / "start-evidence.json").read_text(encoding="utf-8")) + self.assertEqual(start_evidence["result"], "passed") + self.assertTrue(start_evidence["ui"]["policy_dialog_saved"]) + self.assertTrue(start_evidence["ui"]["start_clicked"]) + + resume = Gate13Playthrough( + _write_plan(root / "resume-plan.json", "resume_pause"), + root / "resume-evidence.json", + inference_runner=_inference, + ) + with patch("communityai_desktop.pyside_shell.login_startup_enabled", return_value=False): + self.assertEqual( + run( + controller, + single_instance=False, + qualification_automation=resume, + ), + 0, + ) + resume_evidence = json.loads((root / "resume-evidence.json").read_text(encoding="utf-8")) + self.assertEqual(resume_evidence["result"], "passed") + self.assertTrue(resume_evidence["ui"]["resumed_after_restart_observed"]) + self.assertTrue(resume_evidence["ui"]["pause_clicked"]) + self.assertTrue(resume_evidence["ui"]["sharing_paused_observed"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/PACKAGED_ALPHA_OPERATIONS.md b/docs/PACKAGED_ALPHA_OPERATIONS.md index 3b3b84bf5..56eedf921 100644 --- a/docs/PACKAGED_ALPHA_OPERATIONS.md +++ b/docs/PACKAGED_ALPHA_OPERATIONS.md @@ -4,9 +4,16 @@ This runbook defines the Gate 13 clean-host lifecycle for the unsigned Community public-alpha packages. It applies to Windows and Linux. It does not apply to macOS, does not test credits, and does not authorize cloud creation. -Passing the controller tests in this repository does **not** pass Gate 13. Gate 13 -requires one complete real packaged lifecycle on each supported platform against the -published signed bootstrap and a live product route. +Passing the controller tests in this repository does **not** constitute a fresh live +qualification. A replay requires the exact packaged desktop on each supported platform +against the published signed bootstrap and a live product route. + +Gate 13 and Gate 15 are now separate release gates. Gate 13 covers verified package +startup, real-window inference, sharing-policy editing, Start, full application restart, +automatic sharing resume, Pause, and post-restart inference. Manual replacement, +retain/delete uninstall choices, and retained-data reinstall are Gate 15. The older +16-phase contract later in this document remains a useful combined Gate 13/15 release +exercise; it is not the shortest Gate 13 replay. ## Release boundary @@ -41,6 +48,9 @@ Resolve these before touching a clean host: 7. One privacy-safe run ID for each host. 8. A copy of [gate13_packaged_lifecycle.py](../scripts/gate13_packaged_lifecycle.py). +9. A copy of + [gate13_automated_playthrough.py](../scripts/gate13_automated_playthrough.py) + when replaying the current Gate 13 boundary. The controller is a standard-library qualification tool. It may be copied separately to the host, but it does not install or import CommunityAI source. The product runtime @@ -48,7 +58,70 @@ must consist only of the unpacked release executables. A source checkout, editab install, repository PYTHONPATH, developer virtual environment, or invocation of python -m drift invalidates the run. -## Evidence contract +## Current automated Gate 13 replay + +The production desktop contains a hidden qualification mode that drives the real Qt +window. It does not call the controller in place of UI actions. The first process opens +the normal window, verifies the exact selected route, performs one localhost inference, +opens and saves **Edit sharing limits**, clicks **Start sharing**, and observes the +selected worker running. The process then exits normally so the desktop-owned node is +stopped. A second fresh desktop process proves sharing resumed after restart, clicks +**Pause sharing**, proves the worker stopped, and performs another localhost inference. + +Each inference creates one in-memory temporary client key, retains only completion and +token counts, revokes the key, and proves the active-key baseline was restored. Session +timeouts are bounded to one hour each. The outer runner verifies the production archive +digest and byte size, runs the four packaged self-tests, executes both window sessions, +validates their strict privacy-safe evidence, and removes its exact run-scoped temporary +root. + +Prepare one absolute-path config beside the staged runner. `work_root` must not exist and +its leaf must be exactly `.gate13-playthrough-`: + +~~~json +{ + "schema_version": 1, + "run_id": "gate13-replay-a", + "platform": "windows", + "source_commit": "<40 lowercase hex>", + "package_archive": "", + "package_sha256": "sha256:<64 lowercase hex>", + "package_bytes": 1, + "desktop_executable": "", + "work_root": "/.gate13-playthrough-gate13-replay-a", + "model_id": "Qwen3.5 2B", + "manifest_digest": "sha256:3ba8528cb3c0d85e1ed048e0438a0d64cfbbc298944ed674caa6950d415f8e33", + "total_blocks": 24, + "policy": { + "sharing_enabled": true, + "allowed_models": ["Qwen3.5 2B"], + "preferred_models": ["Qwen3.5 2B"], + "denied_models": [], + "max_disk_space": "20GiB", + "max_vram": "8GiB", + "max_bandwidth_mbps": 100.0, + "max_power_watts": 250.0, + "pause_timeout": 30.0, + "schedule": null + }, + "session_timeout_seconds": 3600, + "inference_timeout_seconds": 600 +} +~~~ + +Run it as the ordinary qualification user with tracing disabled: + +~~~text +python gate13_automated_playthrough.py --config gate13-windows-run.json > gate13-windows-evidence.json +~~~ + +Use `platform: linux` and the exact Linux executable/archive for Linux. The durable +Gate 13 host-job adapter accepts this Python entrypoint on both platforms, binds the +config and source commit, and validates the aggregate before collection. A cloud replay +still requires a fresh cost authorization, route acceptance, exact clean clients, and +provider cleanup; prior Gate 13 reservations must not be reused. + +## Combined 16-phase Gate 13/15 evidence contract Platform startup scripts perform product actions and write one local JSON phase result after each action. After final cleanup they place the ordered phase objects in one @@ -487,7 +560,7 @@ Always finish exact product cleanup. A failed action is not permission to leave worker, node, desktop process, persistent test data, credential, or phase temporary behind. -## Publication checklist +## Combined 16-phase publication checklist A Gate 13 evidence record is publishable only when: @@ -500,5 +573,8 @@ A Gate 13 evidence record is publishable only when: checksum. Archive the Windows and Linux records separately, then aggregate their bounded facts in -release readiness. Do not mark Gate 13 passed from controller unit tests, build-job -smokes, or only one supported platform. +release readiness. For a current-scope Gate 13 replay, the automated aggregate replaces +the combined 16-phase record only for the open/infer/share/restart/resume/pause boundary; +Gate 15 still requires separate replacement and uninstall evidence. Do not claim a fresh +live qualification from controller tests, build-job smokes, or only one supported +platform. diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md index 6f577432f..43fcb750b 100644 --- a/docs/RELEASE_READINESS.md +++ b/docs/RELEASE_READINESS.md @@ -59,6 +59,14 @@ Qwen inference. Every run instance, disk, and firewall is absent, global L4 usag and the protected bootstrap remains running. Gate 14 is now `READY`; Gate 15 owns publication of the source-fixed Windows archive plus reinstall/uninstall release work. +The follow-up automated replay converts the successful manual sequence into two bounded +real-window package sessions: infer/edit-policy/Start, then full process restart, +resume/Pause/infer. It verifies the archive and four packaged self-tests, restores the +temporary API-key baseline, emits strict privacy-safe evidence, and is accepted by the +durable Windows/Linux host-job boundary. Local Qt and contract tests pass. This improves +repeatability without claiming a new paid clean-host run; a live replay still requires a +new source-bound package, cost authorization, route, clients, and cleanup evidence. + Do not work on the post-alpha items in the deferred table while an alpha gate can progress. Missing Docker, snapshots, local GPU hardware, or local host capacity is not an external blocker: use authorized bounded infrastructure according to its role. GCP/local hosts diff --git a/scripts/gate13_automated_playthrough.py b/scripts/gate13_automated_playthrough.py new file mode 100644 index 000000000..5056ac8d8 --- /dev/null +++ b/scripts/gate13_automated_playthrough.py @@ -0,0 +1,499 @@ +"""Run the proven Gate 13 desktop playthrough without operator UI actions. + +Invoke this only after a production archive has been verified and unpacked on a +clean host. The frozen desktop opens its real window twice: the first session +performs inference, edits the real sharing-policy dialog, and clicks Start; the +second proves restart/resume, clicks Pause, and performs inference again. + +The script prints one bounded aggregate record. Private per-session files live +only in an exact run-scoped temporary root and are removed before success. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import os +import re +import shutil +import stat +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Mapping, Sequence + +SCHEMA_VERSION = 1 +SCOPE = "gate13-automated-desktop-replay" +MAX_CONFIG_BYTES = 65_536 +MAX_EVIDENCE_BYTES = 65_536 + +_RUN_RE = re.compile(r"[a-z0-9][a-z0-9-]{0,62}") +_DIGEST_RE = re.compile(r"sha256:[0-9a-f]{64}") +_MODEL_RE = re.compile(r"[ -~]{1,128}") +_COMMIT_RE = re.compile(r"[0-9a-f]{40}") +_CONFIG_FIELDS = { + "schema_version", + "run_id", + "platform", + "source_commit", + "package_archive", + "package_sha256", + "package_bytes", + "desktop_executable", + "work_root", + "model_id", + "manifest_digest", + "total_blocks", + "policy", + "session_timeout_seconds", + "inference_timeout_seconds", +} +_POLICY_FIELDS = { + "sharing_enabled", + "allowed_models", + "preferred_models", + "denied_models", + "max_disk_space", + "max_vram", + "max_bandwidth_mbps", + "max_power_watts", + "pause_timeout", + "schedule", +} +_SESSION_FIELDS = { + "schema_version", + "scope", + "run_id", + "stage", + "result", + "model_id", + "manifest_digest", + "duration_seconds", + "route", + "inference", + "ui", + "limits", + "privacy", +} + + +class ReplayError(ValueError): + """A replay input, session, or cleanup boundary failed closed.""" + + +def _reject_constant(_value: str) -> None: + raise ReplayError("JSON contains a non-finite value") + + +def _unique_object(pairs: Sequence[tuple[str, Any]]) -> dict[str, Any]: + value: dict[str, Any] = {} + for key, item in pairs: + if key in value: + raise ReplayError("JSON contains a duplicate field") + value[key] = item + return value + + +def _regular_bytes(path: Path, maximum: int) -> bytes: + _regular_metadata(path, maximum) + path = Path(path) + try: + return path.read_bytes() + except OSError as exc: + raise ReplayError("required file is unreadable") from exc + + +def _regular_metadata(path: Path, maximum: int) -> os.stat_result: + path = Path(path) + try: + metadata = path.lstat() + except OSError as exc: + raise ReplayError("required file is unavailable") from exc + reparse = bool(getattr(metadata, "st_file_attributes", 0) & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)) + if reparse or path.is_symlink() or not stat.S_ISREG(metadata.st_mode) or not 1 <= metadata.st_size <= maximum: + raise ReplayError("required file is not a bounded regular file") + return metadata + + +def _json_file(path: Path, maximum: int) -> Mapping[str, Any]: + try: + value = json.loads( + _regular_bytes(path, maximum).decode("utf-8"), + object_pairs_hook=_unique_object, + parse_constant=_reject_constant, + ) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ReplayError("JSON is invalid") from exc + if not isinstance(value, dict): + raise ReplayError("JSON root is invalid") + return value + + +def _number(value: Any, label: str, minimum: float, maximum: float) -> float: + if type(value) not in (int, float): + raise ReplayError(f"{label} is invalid") + rendered = float(value) + if not math.isfinite(rendered) or not minimum <= rendered <= maximum: + raise ReplayError(f"{label} is invalid") + return rendered + + +def _absolute_path(value: Any, label: str) -> Path: + if not isinstance(value, str) or not value or "\x00" in value: + raise ReplayError(f"{label} is invalid") + path = Path(value) + if not path.is_absolute(): + raise ReplayError(f"{label} must be absolute") + return path + + +@dataclass(frozen=True) +class ReplayConfig: + run_id: str + platform: str + source_commit: str + package_archive: Path + package_sha256: str + package_bytes: int + desktop_executable: Path + work_root: Path + model_id: str + manifest_digest: str + total_blocks: int + policy: Mapping[str, Any] + session_timeout_seconds: float + inference_timeout_seconds: float + + +def load_config(path: Path) -> ReplayConfig: + raw = _json_file(path, MAX_CONFIG_BYTES) + if set(raw) != _CONFIG_FIELDS or raw.get("schema_version") != SCHEMA_VERSION: + raise ReplayError("configuration schema is invalid") + run_id = raw["run_id"] + platform = raw["platform"] + source_commit = raw["source_commit"] + package_sha256 = raw["package_sha256"] + package_bytes = raw["package_bytes"] + model_id = raw["model_id"] + digest = raw["manifest_digest"] + blocks = raw["total_blocks"] + policy = raw["policy"] + if not isinstance(run_id, str) or _RUN_RE.fullmatch(run_id) is None: + raise ReplayError("run id is invalid") + if platform not in ("windows", "linux"): + raise ReplayError("platform is invalid") + if not isinstance(source_commit, str) or _COMMIT_RE.fullmatch(source_commit) is None: + raise ReplayError("source commit is invalid") + if not isinstance(package_sha256, str) or _DIGEST_RE.fullmatch(package_sha256) is None: + raise ReplayError("package digest is invalid") + if type(package_bytes) is not int or not 1 <= package_bytes <= 8 * 1024**3: + raise ReplayError("package size is invalid") + if not isinstance(model_id, str) or _MODEL_RE.fullmatch(model_id) is None or model_id != model_id.strip(): + raise ReplayError("model id is invalid") + if not isinstance(digest, str) or _DIGEST_RE.fullmatch(digest) is None: + raise ReplayError("manifest digest is invalid") + if type(blocks) is not int or not 1 <= blocks <= 512: + raise ReplayError("block count is invalid") + if not isinstance(policy, dict) or set(policy) != _POLICY_FIELDS: + raise ReplayError("policy schema is invalid") + if ( + policy["sharing_enabled"] is not True + or policy["allowed_models"] != [model_id] + or policy["preferred_models"] != [model_id] + or policy["denied_models"] != [] + or policy["schedule"] is not None + ): + raise ReplayError("policy does not select only the qualification model") + if not isinstance(policy["max_disk_space"], str) or not 1 <= len(policy["max_disk_space"]) <= 32: + raise ReplayError("storage ceiling is invalid") + if not isinstance(policy["max_vram"], str) or not 1 <= len(policy["max_vram"]) <= 32: + raise ReplayError("memory ceiling is invalid") + _number(policy["max_bandwidth_mbps"], "bandwidth ceiling", 0.001, 1_000_000) + _number(policy["max_power_watts"], "power ceiling", 0.001, 1_000_000) + _number(policy["pause_timeout"], "pause timeout", 1, 300) + executable = _absolute_path(raw["desktop_executable"], "desktop executable") + _regular_metadata(executable, 2 * 1024**3) + package_archive = _absolute_path(raw["package_archive"], "package archive") + if _regular_metadata(package_archive, 8 * 1024**3).st_size != package_bytes: + raise ReplayError("package size changed") + work_root = _absolute_path(raw["work_root"], "work root") + if work_root.name != f".gate13-playthrough-{run_id}" or work_root.exists() or not work_root.parent.is_dir(): + raise ReplayError("work root is not a fresh exact run root") + return ReplayConfig( + run_id=run_id, + platform=platform, + source_commit=source_commit, + package_archive=package_archive, + package_sha256=package_sha256, + package_bytes=package_bytes, + desktop_executable=executable, + work_root=work_root, + model_id=model_id, + manifest_digest=digest, + total_blocks=blocks, + policy=policy, + session_timeout_seconds=_number(raw["session_timeout_seconds"], "session timeout", 30, 3_600), + inference_timeout_seconds=_number(raw["inference_timeout_seconds"], "inference timeout", 10, 600), + ) + + +def _session_plan(config: ReplayConfig, stage: str) -> dict[str, Any]: + return { + "schema_version": SCHEMA_VERSION, + "run_id": config.run_id, + "stage": stage, + "model_id": config.model_id, + "manifest_digest": config.manifest_digest, + "total_blocks": config.total_blocks, + "policy": dict(config.policy), + "timeout_seconds": config.session_timeout_seconds, + "inference_timeout_seconds": config.inference_timeout_seconds, + } + + +def _write_private_json(path: Path, value: Mapping[str, Any]) -> None: + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_BINARY"): + flags |= os.O_BINARY + descriptor = os.open(path, flags, 0o600) + try: + payload = (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8") + with os.fdopen(descriptor, "wb", closefd=False) as output: + output.write(payload) + output.flush() + os.fsync(output.fileno()) + finally: + os.close(descriptor) + + +def _validate_session(path: Path, config: ReplayConfig, stage: str) -> Mapping[str, Any]: + value = _json_file(path, MAX_EVIDENCE_BYTES) + if set(value) != _SESSION_FIELDS: + raise ReplayError("session evidence schema is invalid") + if ( + value["schema_version"] != SCHEMA_VERSION + or value["scope"] != "gate13-packaged-desktop-playthrough" + or value["run_id"] != config.run_id + or value["stage"] != stage + or value["result"] != "passed" + or value["model_id"] != config.model_id + or value["manifest_digest"] != config.manifest_digest + ): + raise ReplayError("session evidence identity is invalid") + _number(value["duration_seconds"], "session duration", 0, config.session_timeout_seconds + 30) + route = value["route"] + inference = value["inference"] + ui = value["ui"] + limits = value["limits"] + privacy = value["privacy"] + if route != { + "rendered_in_real_window": True, + "complete": True, + "covered_blocks": config.total_blocks, + "total_blocks": config.total_blocks, + }: + raise ReplayError("session route evidence is invalid") + if ( + not isinstance(inference, dict) + or inference.get("passed") is not True + or inference.get("model_id") != config.model_id + or inference.get("manifest_digest") != config.manifest_digest + or inference.get("completion_count") != 1 + or type(inference.get("generated_token_count")) is not int + or not 1 <= inference["generated_token_count"] <= 8 + or inference.get("response_content_retained") is not False + or inference.get("token_identifiers_retained") is not False + or inference.get("temporary_key_removed") is not True + ): + raise ReplayError("session inference evidence is invalid") + expected_ui = { + "real_window_opened": True, + "policy_dialog_saved": stage == "start", + "start_clicked": stage == "start", + "sharing_running_observed": stage == "start", + "resumed_after_restart_observed": stage == "resume_pause", + "pause_clicked": stage == "resume_pause", + "sharing_paused_observed": stage == "resume_pause", + } + expected_limits = { + "storage": True, + "memory_or_vram": True, + "bandwidth": True, + "power": True, + "pause_timeout": True, + } + expected_privacy = { + "prompt_retained": False, + "response_content_retained": False, + "token_identifiers_retained": False, + "credentials_retained": False, + "paths_retained": False, + "endpoints_retained": False, + } + if ui != expected_ui or limits != expected_limits: + raise ReplayError("session UI or limit evidence is invalid") + if privacy != expected_privacy: + raise ReplayError("session privacy evidence is invalid") + forbidden = ("prompt", "response", "secret", "credential", "endpoint", "path", "address") + rendered = json.dumps(value, sort_keys=True).lower() + for field in forbidden: + if f'"{field}"' in rendered: + raise ReplayError("session evidence retained a forbidden field") + return value + + +def _run_session( + config: ReplayConfig, + stage: str, + runner: Callable[..., subprocess.CompletedProcess] = subprocess.run, +) -> Mapping[str, Any]: + plan_path = config.work_root / f"{stage}-plan.json" + evidence_path = config.work_root / f"{stage}-evidence.json" + _write_private_json(plan_path, _session_plan(config, stage)) + try: + result = runner( + [ + os.fspath(config.desktop_executable), + "--gate13-ui-playthrough", + os.fspath(plan_path), + "--gate13-ui-evidence", + os.fspath(evidence_path), + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + timeout=config.session_timeout_seconds + 60, + close_fds=True, + ) + except (OSError, subprocess.SubprocessError) as exc: + raise ReplayError("packaged desktop session failed") from exc + if result.returncode != 0: + raise ReplayError("packaged desktop session failed") + return _validate_session(evidence_path, config, stage) + + +def _digest_file(path: Path) -> str: + _regular_metadata(path, 8 * 1024**3) + digest = hashlib.sha256() + try: + with path.open("rb") as source: + while chunk := source.read(1024 * 1024): + digest.update(chunk) + except OSError as exc: + raise ReplayError("package archive could not be hashed") from exc + return "sha256:" + digest.hexdigest() + + +def _run_package_self_tests( + config: ReplayConfig, + runner: Callable[..., subprocess.CompletedProcess], +) -> None: + for action in ("--check-runtime", "--self-test", "--ui-self-test", "--onboarding-ui-self-test"): + try: + result = runner( + [os.fspath(config.desktop_executable), action], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + timeout=120, + close_fds=True, + ) + except (OSError, subprocess.SubprocessError) as exc: + raise ReplayError("packaged desktop self-test failed") from exc + if result.returncode != 0: + raise ReplayError("packaged desktop self-test failed") + + +def run_replay( + config: ReplayConfig, + *, + runner: Callable[..., subprocess.CompletedProcess] = subprocess.run, +) -> Mapping[str, Any]: + package_digest = _digest_file(config.package_archive) + executable_digest = _digest_file(config.desktop_executable) + if package_digest != config.package_sha256: + raise ReplayError("package digest changed") + _run_package_self_tests(config, runner) + config.work_root.mkdir(mode=0o700) + cleanup_passed = False + start: Mapping[str, Any] | None = None + resumed: Mapping[str, Any] | None = None + try: + start = _run_session(config, "start", runner) + resumed = _run_session(config, "resume_pause", runner) + finally: + try: + resolved = config.work_root.resolve(strict=True) + parent = config.work_root.parent.resolve(strict=True) + if resolved.parent != parent or resolved.name != f".gate13-playthrough-{config.run_id}": + raise ReplayError("work-root cleanup target changed") + shutil.rmtree(resolved) + cleanup_passed = not config.work_root.exists() + except OSError as exc: + raise ReplayError("qualification temporary cleanup failed") from exc + if start is None or resumed is None or not cleanup_passed: + raise ReplayError("automated replay did not complete") + if ( + _digest_file(config.package_archive) != package_digest + or _digest_file(config.desktop_executable) != executable_digest + ): + raise ReplayError("package inputs changed during the replay") + return { + "schema_version": SCHEMA_VERSION, + "scope": SCOPE, + "run_id": config.run_id, + "platform": config.platform, + "result": "passed", + "source_commit": config.source_commit, + "package": { + "sha256": config.package_sha256, + "bytes": config.package_bytes, + "verified_before_run": True, + "self_test_count": 4, + }, + "model_id": config.model_id, + "manifest_digest": config.manifest_digest, + "real_window_sessions": 2, + "localhost_inference_count": 2, + "policy_dialog_saved": True, + "start_clicked": True, + "restart_resume_observed": True, + "pause_clicked": True, + "sharing_paused": True, + "session_duration_seconds": { + "start": start["duration_seconds"], + "resume_pause": resumed["duration_seconds"], + }, + "privacy_safe": True, + "qualification_temporaries_removed": True, + } + + +def _failure() -> Mapping[str, Any]: + return { + "schema_version": SCHEMA_VERSION, + "scope": SCOPE, + "result": "failed", + "failure_code": "automated_replay_failed", + } + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Run the automated Gate 13 packaged desktop replay") + parser.add_argument("--config", type=Path, required=True) + args = parser.parse_args(argv) + try: + value = run_replay(load_config(args.config)) + except BaseException: + value = _failure() + print(json.dumps(value, sort_keys=True, separators=(",", ":"))) + return 0 if value.get("result") == "passed" else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/gate13_host_job.py b/scripts/gate13_host_job.py index 551743724..0378da326 100644 --- a/scripts/gate13_host_job.py +++ b/scripts/gate13_host_job.py @@ -332,6 +332,11 @@ def load_config(path: Path) -> HostJobConfig: values["entrypoint_path"].parent / expected_lifecycle_name, ): raise HostJobError("Windows lifecycle config is not beside its entrypoint") + entrypoint_suffix = values["entrypoint_path"].suffix.casefold() + if (platform == "windows" and entrypoint_suffix not in {".ps1", ".py"}) or ( + platform == "linux" and entrypoint_suffix != ".py" + ): + raise HostJobError("entrypoint type is invalid") if not _same_path(values["python_executable"], HOST_PYTHON[platform]): raise HostJobError("Python executable changed") if not _same_path(values["adapter_path"], ADAPTER_PATH): @@ -514,7 +519,7 @@ def _terminal( def _entrypoint_argv(config: HostJobConfig) -> list[str]: - if config.platform == "windows": + if config.platform == "windows" and config.entrypoint_path.suffix.casefold() == ".ps1": return [ r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", "-NoLogo", @@ -534,11 +539,7 @@ def _entrypoint_argv(config: HostJobConfig) -> list[str]: def _bounded_environment(config: HostJobConfig) -> dict[str, str]: - allowed = ( - WINDOWS_RUNTIME_ENVIRONMENT - if config.platform == "windows" - else ("HOME", "LANG", "LC_ALL", "TMPDIR") - ) + allowed = WINDOWS_RUNTIME_ENVIRONMENT if config.platform == "windows" else ("HOME", "LANG", "LC_ALL", "TMPDIR") return {key: os.environ[key] for key in allowed if key in os.environ} @@ -873,10 +874,7 @@ def _windows_register_script(config: HostJobConfig) -> str: f"-Execute {_ps_quote(os.fspath(config.python_executable))} " f"-Argument {_ps_quote(_windows_action_arguments(config))}" ), - ( - "$principal = New-ScheduledTaskPrincipal -UserId $currentUser " - "-LogonType S4U -RunLevel Limited" - ), + ("$principal = New-ScheduledTaskPrincipal -UserId $currentUser " "-LogonType S4U -RunLevel Limited"), ( "$settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew " f"-ExecutionTimeLimit (New-TimeSpan -Seconds {config.max_run_seconds + 2 * SUPERVISOR_GRACE_SECONDS})" diff --git a/scripts/gate13_packaged_lifecycle.py b/scripts/gate13_packaged_lifecycle.py index 2bf2c09b7..79a3dbfd6 100644 --- a/scripts/gate13_packaged_lifecycle.py +++ b/scripts/gate13_packaged_lifecycle.py @@ -18,6 +18,7 @@ SCHEMA_VERSION = 1 SCOPE = "gate13-packaged-lifecycle" +AUTOMATED_REPLAY_SCOPE = "gate13-automated-desktop-replay" MAX_INPUT_BYTES = 1_048_576 MAX_COUNT = 1_000_000 MAX_BYTES = 1 << 50 @@ -74,6 +75,27 @@ _DIGEST_RE = re.compile(r"(?:sha256:)?[0-9a-f]{64}") _LABEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._+-]{0,63}") _DISPLAY_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9 ._+()-]{0,127}") +_AUTOMATED_REPLAY_FIELDS = { + "schema_version", + "scope", + "run_id", + "platform", + "result", + "source_commit", + "package", + "model_id", + "manifest_digest", + "real_window_sessions", + "localhost_inference_count", + "policy_dialog_saved", + "start_clicked", + "restart_resume_observed", + "pause_clicked", + "sharing_paused", + "session_duration_seconds", + "privacy_safe", + "qualification_temporaries_removed", +} class LifecycleEvidenceError(ValueError): @@ -655,7 +677,91 @@ def finalize(self) -> dict[str, Any]: } +def _validate_automated_replay(raw_document: Mapping[str, Any]) -> dict[str, Any]: + document = dict(_mapping(raw_document)) + _exact_fields(document, _AUTOMATED_REPLAY_FIELDS) + if ( + document["schema_version"] != SCHEMA_VERSION + or document["scope"] != AUTOMATED_REPLAY_SCOPE + or document["result"] != "passed" + or not isinstance(document["run_id"], str) + or _LABEL_RE.fullmatch(document["run_id"]) is None + or document["platform"] not in ("windows", "linux") + or not isinstance(document["source_commit"], str) + or _HEX40_RE.fullmatch(document["source_commit"]) is None + or document["model_id"] not in MODEL_PROFILES + ): + _fail() + profile = MODEL_PROFILES[document["model_id"]] + expected_manifest = "sha256:" + profile["manifest_digest"] + if document["manifest_digest"] != expected_manifest: + _fail() + package = _mapping(document["package"]) + _exact_fields(package, {"sha256", "bytes", "verified_before_run", "self_test_count"}) + if ( + not isinstance(package["sha256"], str) + or _DIGEST_RE.fullmatch(package["sha256"]) is None + or type(package["bytes"]) is not int + or not 1 <= package["bytes"] <= 8 * 1024**3 + or package["verified_before_run"] is not True + or package["self_test_count"] != 4 + ): + _fail() + if document["real_window_sessions"] != 2 or document["localhost_inference_count"] != 2: + _fail() + for field in ( + "policy_dialog_saved", + "start_clicked", + "restart_resume_observed", + "pause_clicked", + "sharing_paused", + "privacy_safe", + "qualification_temporaries_removed", + ): + if document[field] is not True: + _fail() + durations = _mapping(document["session_duration_seconds"]) + _exact_fields(durations, {"start", "resume_pause"}) + for value in durations.values(): + if type(value) not in (int, float) or not math.isfinite(float(value)) or not 0 <= float(value) <= 3_630: + _fail() + return { + "schema_version": SCHEMA_VERSION, + "scope": AUTOMATED_REPLAY_SCOPE, + "result": "passed", + "run_id": document["run_id"], + "platform": document["platform"], + "source_commit": document["source_commit"], + "package_sha256": package["sha256"].removeprefix("sha256:"), + "package_bytes": package["bytes"], + "model_id": document["model_id"], + "manifest_digest": expected_manifest.removeprefix("sha256:"), + "package": dict(package), + "model": { + "id": document["model_id"], + "manifest_digest": expected_manifest, + }, + "lifecycle": { + "real_window_sessions": 2, + "localhost_inference_count": 2, + "policy_dialog_saved": True, + "start_clicked": True, + "restart_resume_observed": True, + "pause_clicked": True, + "sharing_paused": True, + "response_content_retained": False, + "token_identifier_count": 0, + }, + "cleanup": { + "qualification_temporaries_removed": True, + "complete": True, + }, + } + + def validate_lifecycle_document(raw_document: Mapping[str, Any]) -> dict[str, Any]: + if isinstance(raw_document, dict) and raw_document.get("scope") == AUTOMATED_REPLAY_SCOPE: + return _validate_automated_replay(raw_document) document = dict(_mapping(raw_document)) _exact_fields(document, _DOCUMENT_FIELDS) phases = document["phases"] diff --git a/tests/test_gate13_automated_playthrough.py b/tests/test_gate13_automated_playthrough.py new file mode 100644 index 000000000..824374b1d --- /dev/null +++ b/tests/test_gate13_automated_playthrough.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import hashlib +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import gate13_automated_playthrough as replay + +MODEL_ID = "Qwen3.5 2B" +DIGEST = "sha256:" + "a" * 64 + + +def config_document(root: Path) -> dict: + executable = root / "CommunityAI.exe" + executable.write_bytes(b"packaged-desktop") + archive = root / "communityai-desktop-windows.zip" + archive.write_bytes(b"verified-production-archive") + return { + "schema_version": 1, + "run_id": "gate13-automated-a", + "platform": "windows", + "source_commit": "1" * 40, + "package_archive": str(archive.resolve()), + "package_sha256": "sha256:" + hashlib.sha256(archive.read_bytes()).hexdigest(), + "package_bytes": archive.stat().st_size, + "desktop_executable": str(executable.resolve()), + "work_root": str((root / ".gate13-playthrough-gate13-automated-a").resolve()), + "model_id": MODEL_ID, + "manifest_digest": DIGEST, + "total_blocks": 24, + "policy": { + "sharing_enabled": True, + "allowed_models": [MODEL_ID], + "preferred_models": [MODEL_ID], + "denied_models": [], + "max_disk_space": "20GiB", + "max_vram": "8GiB", + "max_bandwidth_mbps": 100.0, + "max_power_watts": 250.0, + "pause_timeout": 30.0, + "schedule": None, + }, + "session_timeout_seconds": 30.0, + "inference_timeout_seconds": 10.0, + } + + +def session_evidence(plan: dict) -> dict: + stage = plan["stage"] + return { + "schema_version": 1, + "scope": "gate13-packaged-desktop-playthrough", + "run_id": plan["run_id"], + "stage": stage, + "result": "passed", + "model_id": plan["model_id"], + "manifest_digest": plan["manifest_digest"], + "duration_seconds": 1.25, + "route": { + "rendered_in_real_window": True, + "complete": True, + "covered_blocks": plan["total_blocks"], + "total_blocks": plan["total_blocks"], + }, + "inference": { + "passed": True, + "model_id": plan["model_id"], + "manifest_digest": plan["manifest_digest"], + "completion_count": 1, + "generated_token_count": 1, + "response_content_retained": False, + "token_identifiers_retained": False, + "temporary_key_removed": True, + }, + "ui": { + "real_window_opened": True, + "policy_dialog_saved": stage == "start", + "start_clicked": stage == "start", + "sharing_running_observed": stage == "start", + "resumed_after_restart_observed": stage == "resume_pause", + "pause_clicked": stage == "resume_pause", + "sharing_paused_observed": stage == "resume_pause", + }, + "limits": { + "storage": True, + "memory_or_vram": True, + "bandwidth": True, + "power": True, + "pause_timeout": True, + }, + "privacy": { + "prompt_retained": False, + "response_content_retained": False, + "token_identifiers_retained": False, + "credentials_retained": False, + "paths_retained": False, + "endpoints_retained": False, + }, + } + + +def test_replay_runs_real_desktop_contract_twice_and_removes_temporaries(tmp_path): + document = config_document(tmp_path) + config_path = tmp_path / "config.json" + config_path.write_text(json.dumps(document), encoding="utf-8") + config = replay.load_config(config_path) + stages = [] + self_tests = [] + + def runner(argv, **kwargs): + assert kwargs["stdin"] is subprocess.DEVNULL + assert kwargs["stdout"] is subprocess.DEVNULL + assert kwargs["stderr"] is subprocess.DEVNULL + if "--gate13-ui-playthrough" not in argv: + self_tests.append(argv[1]) + return subprocess.CompletedProcess(argv, 0) + plan_path = Path(argv[argv.index("--gate13-ui-playthrough") + 1]) + evidence_path = Path(argv[argv.index("--gate13-ui-evidence") + 1]) + plan = json.loads(plan_path.read_text(encoding="utf-8")) + stages.append(plan["stage"]) + evidence_path.write_text(json.dumps(session_evidence(plan)), encoding="utf-8") + return subprocess.CompletedProcess(argv, 0) + + result = replay.run_replay(config, runner=runner) + + assert stages == ["start", "resume_pause"] + assert self_tests == ["--check-runtime", "--self-test", "--ui-self-test", "--onboarding-ui-self-test"] + assert result["result"] == "passed" + assert result["real_window_sessions"] == 2 + assert result["localhost_inference_count"] == 2 + assert result["restart_resume_observed"] is True + assert result["qualification_temporaries_removed"] is True + assert not config.work_root.exists() + + +def test_config_and_session_evidence_fail_closed(tmp_path): + document = config_document(tmp_path) + document["work_root"] = str((tmp_path / "wrong-root").resolve()) + config_path = tmp_path / "invalid.json" + config_path.write_text(json.dumps(document), encoding="utf-8") + with pytest.raises(replay.ReplayError): + replay.load_config(config_path) + + valid = config_document(tmp_path) + valid_path = tmp_path / "valid.json" + valid_path.write_text(json.dumps(valid), encoding="utf-8") + config = replay.load_config(valid_path) + config.work_root.mkdir() + evidence = session_evidence({**valid, "stage": "start"}) + evidence["ui"]["start_clicked"] = False + evidence_path = config.work_root / "evidence.json" + evidence_path.write_text(json.dumps(evidence), encoding="utf-8") + with pytest.raises(replay.ReplayError): + replay._validate_session(evidence_path, config, "start") diff --git a/tests/test_gate13_host_job.py b/tests/test_gate13_host_job.py index 84a16885f..3ce016e47 100644 --- a/tests/test_gate13_host_job.py +++ b/tests/test_gate13_host_job.py @@ -4,6 +4,7 @@ import subprocess import sys import threading +from dataclasses import replace from pathlib import Path import pytest @@ -236,6 +237,19 @@ def test_linux_unit_is_bounded_non_root_and_non_restarting(config_factory): ] +def test_windows_automated_python_replay_uses_the_bound_python(config_factory): + path, _raw = config_factory("windows") + config = host_job.load_config(path) + automated = replace(config, entrypoint_path=config.entrypoint_path.with_suffix(".py")) + + assert host_job._entrypoint_argv(automated) == [ + str(config.python_executable), + str(automated.entrypoint_path), + "--config", + str(config.lifecycle_config_path), + ] + + def test_bounded_copy_caps_private_diagnostics(tmp_path): destination = tmp_path / "stderr.log" overflow = threading.Event() diff --git a/tests/test_gate13_packaged_lifecycle.py b/tests/test_gate13_packaged_lifecycle.py index ffad861dc..49e889554 100644 --- a/tests/test_gate13_packaged_lifecycle.py +++ b/tests/test_gate13_packaged_lifecycle.py @@ -480,3 +480,45 @@ def test_public_summary_does_not_retain_raw_phase_only_fields(): "recovery_action_count", ): assert forbidden_value not in rendered + + +def test_current_gate13_automated_replay_is_accepted_by_the_host_evidence_boundary(): + document = { + "schema_version": 1, + "scope": "gate13-automated-desktop-replay", + "run_id": "gate13-automated-a", + "platform": "windows", + "result": "passed", + "source_commit": SOURCE_COMMIT, + "package": { + "sha256": "sha256:" + PACKAGE_DIGEST, + "bytes": 123_456_789, + "verified_before_run": True, + "self_test_count": 4, + }, + "model_id": "Qwen3.5 2B", + "manifest_digest": "sha256:" + MANIFEST_DIGEST, + "real_window_sessions": 2, + "localhost_inference_count": 2, + "policy_dialog_saved": True, + "start_clicked": True, + "restart_resume_observed": True, + "pause_clicked": True, + "sharing_paused": True, + "session_duration_seconds": {"start": 100.0, "resume_pause": 80.0}, + "privacy_safe": True, + "qualification_temporaries_removed": True, + } + + evidence = lifecycle.validate_lifecycle_document(document) + + assert evidence["result"] == "passed" + assert evidence["source_commit"] == SOURCE_COMMIT + assert evidence["package_sha256"] == PACKAGE_DIGEST + assert evidence["manifest_digest"] == MANIFEST_DIGEST + assert evidence["lifecycle"]["real_window_sessions"] == 2 + assert evidence["lifecycle"]["restart_resume_observed"] is True + + document["pause_clicked"] = False + with pytest.raises(lifecycle.LifecycleEvidenceError): + lifecycle.validate_lifecycle_document(document) diff --git a/tests/test_gate13_run_controller.py b/tests/test_gate13_run_controller.py index 78fe4587b..fc47cf271 100644 --- a/tests/test_gate13_run_controller.py +++ b/tests/test_gate13_run_controller.py @@ -21,16 +21,12 @@ def reserved_ledger_text(*, old_digest, new_digest): lines = LEDGER.read_text(encoding="utf-8").splitlines(keepends=True) - matches = [ - index for index, line in enumerate(lines) if line.startswith("| gate13-20260831-a |") - ] + matches = [index for index, line in enumerate(lines) if line.startswith("| gate13-20260831-a |")] assert len(matches) == 1 index = matches[0] assert old_digest in lines[index] assert lines[index].rstrip().endswith("| CLEANED-COMMITTED |") - lines[index] = lines[index].replace(old_digest, new_digest, 1).replace( - "| CLEANED-COMMITTED |", "| RESERVED |", 1 - ) + lines[index] = lines[index].replace(old_digest, new_digest, 1).replace("| CLEANED-COMMITTED |", "| RESERVED |", 1) return "".join(lines) @@ -189,9 +185,7 @@ def test_non_reserved_ledger_allows_cleanup_only(): def test_reserved_parallel_client_plan_cannot_start(tmp_path): ledger = tmp_path / "ledger.md" - digest = json.loads(AUTHORIZATION.read_text(encoding="utf-8"))[ - "provider_plan_digest" - ] + digest = json.loads(AUTHORIZATION.read_text(encoding="utf-8"))["provider_plan_digest"] ledger.write_text( reserved_ledger_text(old_digest=digest, new_digest=digest), encoding="utf-8", @@ -469,6 +463,54 @@ def test_collect_binds_canonical_evidence_then_deletes_windows(monkeypatch, plan assert after_delete["next_action"] == "start_linux" +def test_collect_accepts_current_automated_desktop_replay(plan): + state = controller.reconcile( + controller.initial_state(plan), + observation(plan, route=True, windows=True, route_job="passed", windows_job="passed"), + plan, + now_unix=NOW, + ) + evidence = { + "schema_version": 1, + "scope": "gate13-automated-desktop-replay", + "run_id": f"{plan.run_id}-windows", + "platform": "windows", + "result": "passed", + "source_commit": plan.windows_source_commit, + "package": { + "sha256": "sha256:" + plan.windows_package_sha256.removeprefix("sha256:"), + "bytes": plan.windows_package_bytes, + "verified_before_run": True, + "self_test_count": 4, + }, + "model_id": "Qwen3.5 2B", + "manifest_digest": "sha256:" + plan.qwen_manifest.removeprefix("sha256:"), + "real_window_sessions": 2, + "localhost_inference_count": 2, + "policy_dialog_saved": True, + "start_clicked": True, + "restart_resume_observed": True, + "pause_clicked": True, + "sharing_paused": True, + "session_duration_seconds": {"start": 120.0, "resume_pause": 90.0}, + "privacy_safe": True, + "qualification_temporaries_removed": True, + } + payload = (json.dumps(evidence, sort_keys=True, separators=(",", ":")) + "\n").encode() + digest = "sha256:" + hashlib.sha256(payload).hexdigest() + + collected = controller.collect_platform( + state, + plan, + platform="windows", + evidence_payload=payload, + observed_digest=digest, + ) + + assert collected["phase"] == "WINDOWS_DELETING" + assert collected["windows_consumed"] is True + + def test_partial_or_wrong_digest_evidence_cannot_advance(plan): state = controller.reconcile( controller.initial_state(plan), From 905cabd13c481adff4a5a7de850ceb6ee839a25b Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:54:07 -0500 Subject: [PATCH 31/56] ops: bind automated Gate 13 cloud replay --- scripts/gate13_host_job.py | 14 +++++++++++++- scripts/gate13_route_setup.sh | 2 +- scripts/gate13_run_controller.py | 4 +++- tests/test_gate13_host_job.py | 20 ++++++++++++++++++++ tests/test_gate13_run_controller.py | 22 ++++++++++++++++++++++ 5 files changed, 59 insertions(+), 3 deletions(-) diff --git a/scripts/gate13_host_job.py b/scripts/gate13_host_job.py index 0378da326..f7414b83d 100644 --- a/scripts/gate13_host_job.py +++ b/scripts/gate13_host_job.py @@ -65,6 +65,18 @@ "USERPROFILE", "WINDIR", ) +LINUX_RUNTIME_ENVIRONMENT = ( + "DBUS_SESSION_BUS_ADDRESS", + "DISPLAY", + "GNOME_KEYRING_CONTROL", + "HOME", + "LANG", + "LC_ALL", + "QT_QPA_PLATFORM", + "TMPDIR", + "XAUTHORITY", + "XDG_RUNTIME_DIR", +) HOST_ROOTS = { "windows": Path(r"C:\Gate13Run"), @@ -539,7 +551,7 @@ def _entrypoint_argv(config: HostJobConfig) -> list[str]: def _bounded_environment(config: HostJobConfig) -> dict[str, str]: - allowed = WINDOWS_RUNTIME_ENVIRONMENT if config.platform == "windows" else ("HOME", "LANG", "LC_ALL", "TMPDIR") + allowed = WINDOWS_RUNTIME_ENVIRONMENT if config.platform == "windows" else LINUX_RUNTIME_ENVIRONMENT return {key: os.environ[key] for key in allowed if key in os.environ} diff --git a/scripts/gate13_route_setup.sh b/scripts/gate13_route_setup.sh index 1874b2756..cf4bb8b2a 100644 --- a/scripts/gate13_route_setup.sh +++ b/scripts/gate13_route_setup.sh @@ -4,7 +4,7 @@ umask 077 root=/tmp/gate13-route wheel="$root/drift-2.3.0.dev2-py3-none-any.whl" test "$(stat -c %s "$wheel")" = "389107" -test "$(sha256sum "$wheel" | cut -d' ' -f1)" = "2a4f30bad7ae897fed019bc7da330a09965adb35685d11abaeaebf7a1d40aa60" +test "$(sha256sum "$wheel" | cut -d' ' -f1)" = "7a42803811289e14f69835331e0fbab69dd353c70c835131c10bdfa96ca5f111" test "$(sha256sum "$root/configure_product_route_node.py" | cut -d' ' -f1)" = "fc385f74e02ca955203b1fc5e8ae493c7f4ccd31bd7383c2ae0a1c461c91363e" test "$(sha256sum "$root/gate11_product_node_acceptance.py" | cut -d' ' -f1)" = "bdcc9f499a7cd6b727c0e33a0c4c2b0e71e76e28f3f21cb99804a8f39edfa0d2" apt-get update -qq diff --git a/scripts/gate13_run_controller.py b/scripts/gate13_run_controller.py index f79012e9a..87573273c 100644 --- a/scripts/gate13_run_controller.py +++ b/scripts/gate13_run_controller.py @@ -271,11 +271,13 @@ def load_plan(authorization_path: Path, ledger_path: Path) -> RunPlan: sequencing = provider_plan.get("sequencing") if not all(isinstance(value, dict) for value in (source, immutable, route, sequencing)): raise RunControllerError("authorization bindings are invalid") + legacy_lifecycle = sequencing.get("all_16_phases_required_per_platform") is True + automated_replay = sequencing.get("automated_gate13_replay_required") is True if ( not isinstance(clients, list) or len(clients) != 2 or sequencing.get("route_live_for_both_lifecycles") is not True - or sequencing.get("all_16_phases_required_per_platform") is not True + or legacy_lifecycle == automated_replay or sequencing.get("exact_cleanup_before_pass") is not True ): raise RunControllerError("execution sequencing is invalid") diff --git a/tests/test_gate13_host_job.py b/tests/test_gate13_host_job.py index 3ce016e47..e0bdde512 100644 --- a/tests/test_gate13_host_job.py +++ b/tests/test_gate13_host_job.py @@ -105,6 +105,26 @@ def test_windows_environment_keeps_standard_user_runtime_and_drops_secrets(confi assert "COMMUNITYAI_CONTROL_TOKEN" not in environment +def test_linux_environment_keeps_display_and_secret_service_session(config_factory, monkeypatch): + path, _raw = config_factory("linux") + config = host_job.load_config(path) + expected = { + "DISPLAY": ":99", + "DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/1000/bus", + "GNOME_KEYRING_CONTROL": "/run/user/1000/keyring", + "QT_QPA_PLATFORM": "offscreen", + } + for key, value in expected.items(): + monkeypatch.setenv(key, value) + monkeypatch.setenv("UNRELATED_SECRET", "must-not-cross-the-host-boundary") + + environment = host_job._bounded_environment(config) + + assert all(environment[key] == value for key, value in expected.items()) + assert set(environment).issubset(set(host_job.LINUX_RUNTIME_ENVIRONMENT)) + assert "UNRELATED_SECRET" not in environment + + @pytest.mark.parametrize( ("field", "value"), [ diff --git a/tests/test_gate13_run_controller.py b/tests/test_gate13_run_controller.py index fc47cf271..8680d4128 100644 --- a/tests/test_gate13_run_controller.py +++ b/tests/test_gate13_run_controller.py @@ -111,6 +111,28 @@ def test_load_plan_binds_exact_cost_and_resources(plan): assert plan.clients_may_run_concurrently is False +def test_load_plan_accepts_the_automated_replay_instead_of_legacy_16_phases(tmp_path): + raw = json.loads(AUTHORIZATION.read_text(encoding="utf-8")) + sequencing = raw["provider_plan"]["sequencing"] + sequencing["clients_may_run_concurrently"] = False + sequencing["all_16_phases_required_per_platform"] = False + sequencing["automated_gate13_replay_required"] = True + old_digest = raw["provider_plan_digest"] + new_digest = controller._provider_digest(raw["provider_plan"]) + raw["provider_plan_digest"] = new_digest + authorization = tmp_path / "authorization.json" + authorization.write_text(json.dumps(raw), encoding="utf-8") + ledger = tmp_path / "ledger.md" + ledger.write_text( + reserved_ledger_text(old_digest=old_digest, new_digest=new_digest), + encoding="utf-8", + ) + + replay_plan = controller.load_plan(authorization, ledger) + + assert replay_plan.clients_may_run_concurrently is False + + def test_load_plan_accepts_only_documented_owner_ceiling(tmp_path): raw = json.loads(AUTHORIZATION.read_text(encoding="utf-8")) raw["provider_plan"]["sequencing"]["clients_may_run_concurrently"] = False From 1971f607c34fef4a4db355a1fc14ea944eace792 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:59:04 -0500 Subject: [PATCH 32/56] docs: authorize automated Gate 13 cloud replay --- docs/RELEASE_READINESS.md | 23 ++- .../gate13-20260901-a-cost-authorization.json | 181 ++++++++++++++++++ 2 files changed, 194 insertions(+), 10 deletions(-) create mode 100644 docs/evidence/gate13-20260901-a-cost-authorization.json diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md index 43fcb750b..f55374557 100644 --- a/docs/RELEASE_READINESS.md +++ b/docs/RELEASE_READINESS.md @@ -120,23 +120,26 @@ longer consume the new authorization; later billing should still be recorded for ## Cloud authorization and spend ledger Authorization applies only to CommunityAI qualification and public-alpha infrastructure. -The [owner-authorized ceiling](evidence/owner-budget-authorization-20260831.json) is USD 500 -combined across new temporary GCP and Fly resources in the current accounting epoch. The -existing USD 52 committed maximum and the seven cleaned-failed USD 56 Gate 13 runs remain -charged. The final USD 56 `gate13-20260831-i` manual-playthrough reservation brings the -committed maximum to USD 500 and leaves USD 0 unreserved. The existing -GCP bootstrap's ordinary baseline cost is tracked separately; never delete it as test cleanup. +On 2026-09-01 the owner explicitly reset the cloud accounting epoch after reporting that the +prior real-world cloud charge was approximately USD 10 rather than the conservative reserved +maximums. Historical rows and their then-current `CLEANED-COMMITTED` labels remain unchanged +for auditability but consume USD 0 in the reset epoch. The reset epoch has a USD 100 accounting +ceiling; only the exact USD 56 `gate13-20260901-a` reservation is authorized, leaving USD 44 +unreserved and unauthorized for any other run. USD 56 remains a maximum-lifetime safety bound, +not a bill forecast. The existing GCP bootstrap's ordinary baseline cost is tracked separately; +never delete it as test cleanup. Before every paid run, add an entry with a conservative maximum. After cleanup, replace the estimate with observed cost when available. If provider billing is delayed, retain the maximum estimate until actual cost is known unless the owner explicitly resets the budget -after complete cleanup. On reset, keep historical rows, mark them `CLEANED-RELEASED`, and -continue recording later observed charges for information; released rows do not consume the -new epoch. `CLEANED-COMMITTED` means resources are absence-proved but the conservative -maximum still consumes the current epoch because no reset or observed charge has replaced it. +after complete cleanup. On reset, keep historical rows and continue recording later observed +charges for information. `CLEANED-COMMITTED` means resources were absence-proved while the +conservative maximum still consumed the accounting epoch then in force; a later explicit reset +starts a new epoch without rewriting that historical state. | Run | Provider | Purpose | Maximum estimate | Observed cost | Cleanup proof | State | | --- | --- | --- | ---: | ---: | --- | --- | +| gate13-20260901-a | GCP | Automated Gate 13 real-window replay against fresh production packages from `1476d67f3887dfd0de2acfb1305cbcca9975614f`: one bounded L4 route, then sequential ordinary-user Windows/Qwen and Linux/Gemma clients [plan `sha256:6687b9ba098b3f6676f48f4bf03ebb92bdc6a1278bf5bc1c227819b3a3e7cbb0`] | USD 56.00 maximum-lifetime safety bound; owner reports the comparable real-world run was approximately USD 10 | — | [Fresh authorization](evidence/gate13-20260901-a-cost-authorization.json); cleanup required before terminal classification. | RESERVED | | gate13-20260831-i | GCP | Final Gate 13 manual clean-host playthrough: Gate 11 route acceptance first, then sequential ordinary-user Windows/Qwen and Linux/Gemma desktop qualification with literal UI controls and post-restart inference [plan `sha256:8525c3099f273c099aba26de57c1f610a0c74cac65ed2640589d51e874bd0c44`] | USD 56.00 | — | [Passed qualification and cleanup](evidence/gate13-20260831-i-manual-qualification-and-cleanup.json) proves both exact archives, packaged self-tests, real desktop start/share/restart/pause flows, Qwen and Gemma inference, the Windows long-path product fix, all exact resources absent, L4 usage zero, and the protected bootstrap running. | CLEANED-COMMITTED | | gate13-20260831-h | GCP | Final corrected Gate 13 route-first lifecycle with both four-file release-audit bundles pinned and staged, the bounded Windows user-runtime environment, exact archive preflight, and sequential ordinary-user Windows/Qwen then Linux/Gemma clients [plan `sha256:f243254cc5fb65f44d0c9e707be36feb3284fd6e15b15620882843798fb456b1`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-h-failed-attempt-and-cleanup.json) records passed route acceptance and exact Windows archive verification, one Windows failure at `signed_bootstrap/product_readiness`, no Linux create, exact instance/disk/firewall absence, L4 usage zero, and protected-bootstrap health. | CLEANED-COMMITTED | | gate13-20260831-g | GCP | Corrected Gate 13 route-first lifecycle with a bounded standard Windows user-runtime environment, one durable foreground host-adapter execution as each ordinary OS user, exact archive preflight, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:f27f36158f2ad16019578555023cc854cb1e6e3b10ebae8cd3ed24d757b8e032`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-g-failed-attempt-and-cleanup.json) records passed route acceptance, the exact Windows archive, a two-second `package_verification` failure caused by four omitted existing audit inputs, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | diff --git a/docs/evidence/gate13-20260901-a-cost-authorization.json b/docs/evidence/gate13-20260901-a-cost-authorization.json new file mode 100644 index 000000000..8c600962c --- /dev/null +++ b/docs/evidence/gate13-20260901-a-cost-authorization.json @@ -0,0 +1,181 @@ +{ + "schema_version": 1, + "scope": "gate13-automated-paid-cloud-authorization", + "gate": 13, + "run_id": "gate13-20260901-a", + "result": "authorized", + "recorded_at": "2026-09-01T20:58:50Z", + "source": { + "durable_controller_commit": "905cabd13c481adff4a5a7de850ceb6ee839a25b", + "host_job_adapter_commit": "905cabd13c481adff4a5a7de850ceb6ee839a25b", + "route_runtime_commit": "f64a388a47b098ac7f69d2affc59816376b43bb1", + "route_setup_commit": "905cabd13c481adff4a5a7de850ceb6ee839a25b", + "package_commit": "1476d67f3887dfd0de2acfb1305cbcca9975614f", + "production_workflow_run": 33555907921 + }, + "authorization": { + "owner_reset_recorded": true, + "combined_cloud_ceiling_usd": "100.00", + "ledger_committed_before_run_usd": "0.00", + "maximum_estimate_usd": "56.00", + "route_maximum_estimate_usd": "30.00", + "clients_maximum_estimate_usd": "26.00", + "remaining_after_run_maximum_usd": "44.00", + "reservation_recorded": true, + "provisioning_authorized_after_fail_closed_preflight": true, + "provider_calls_authorized_without_preflight": false, + "pricing_as_of": "2026-09-01", + "pricing_basis": "The owner reports the prior real-world replay cost was approximately USD 10. USD 56 is retained only as a fail-safe maximum-lifetime reservation, not as a bill forecast." + }, + "immutable_inputs": { + "durable_controller": { + "path": "scripts/gate13_run_controller.py", + "source_commit": "905cabd13c481adff4a5a7de850ceb6ee839a25b", + "sha256": "5ab9bf2c49267e3b2b7cefcb2d2c9b1eb331a652970e3345ab443f09db4cf8aa", + "bytes": 34281 + }, + "host_job_adapter": { + "path": "scripts/gate13_host_job.py", + "source_commit": "905cabd13c481adff4a5a7de850ceb6ee839a25b", + "sha256": "f9bd388a61226df6ac3fcadb5bdc68134e2ccb741718f4a4c65543cfc4180cdb", + "bytes": 42513 + }, + "lifecycle_evidence_validator": { + "path": "scripts/gate13_packaged_lifecycle.py", + "source_commit": "1476d67f3887dfd0de2acfb1305cbcca9975614f", + "sha256": "a32e1cfcb61e84ccacc13b16a5bf016b3357584edf2b6b536c2122a8e1c2abab", + "bytes": 31881 + }, + "automated_playthrough": { + "path": "scripts/gate13_automated_playthrough.py", + "source_commit": "1476d67f3887dfd0de2acfb1305cbcca9975614f", + "sha256": "b6e289f105632728a7e585777a320bb3fcbb7cdd527c1d4a7ec2f816ffa17a37", + "bytes": 18331 + }, + "route_setup": { + "path": "scripts/gate13_route_setup.sh", + "source_commit": "905cabd13c481adff4a5a7de850ceb6ee839a25b", + "sha256": "045372ea0be9c4a8f31756a502b2a9ec799087eeaac294ebad2b34eccfe0affc", + "bytes": 3371 + }, + "route_runtime_wheel": { + "filename": "drift-2.3.0.dev2-py3-none-any.whl", + "source_commit": "f64a388a47b098ac7f69d2affc59816376b43bb1", + "sha256": "7a42803811289e14f69835331e0fbab69dd353c70c835131c10bdfa96ca5f111", + "bytes": 389107, + "model_artifacts_embedded": false + }, + "windows_package": { + "sha256": "790d13505e387f91b3e080f303dfd0db2feefc067844fd57f003d9e5cd4aa687", + "bytes": 2695084305, + "workflow_artifact": "communityai-desktop-install-windows" + }, + "linux_package": { + "sha256": "7816abb5a83395eac4e898ff1301a85e0a8bbdbced0c5e593701f43122b798e1", + "bytes": 3360729627, + "workflow_artifact": "communityai-desktop-install-linux" + }, + "qwen_manifest": "sha256:3ba8528cb3c0d85e1ed048e0438a0d64cfbbc298944ed674caa6950d415f8e33", + "gemma_manifest": "sha256:2f8debbe0fcdf5af8d4c56c982210fa50aa584314968ae2617e2ccc2de9eafdd" + }, + "provider_plan_digest": "sha256:6687b9ba098b3f6676f48f4bf03ebb92bdc6a1278bf5bc1c227819b3a3e7cbb0", + "provider_plan": { + "project": "community-ai-506321", + "route": { + "instance": "route-20260901-a-node", + "zone": "us-central1-b", + "machine_type": "g2-standard-8", + "accelerator": "1 x NVIDIA L4", + "image": "deeplearning-platform-release/common-cu129-ubuntu-2404-nvidia-580-v20260831", + "boot_disk_gib": 200, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 57600, + "termination_action": "DELETE", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "public_ports": [31337, 31338], + "firewalls": ["route-20260901-a-dht", "route-20260901-a-iap"], + "service_account": false, + "scopes": [] + }, + "clients": [ + { + "platform": "windows", + "model": "Qwen3.5 2B", + "instance": "gate13-20260901-a-win", + "zone": "us-central1-b", + "machine_type": "e2-standard-8", + "image": "windows-server-2025-dc-v20260814", + "image_project": "windows-cloud", + "boot_disk_gib": 120, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 21600, + "termination_action": "DELETE", + "lifecycle_run_id": "gate13-20260901-a-windows", + "device_profile": "cpu", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "network_tag": "gate13-20260901-a-client", + "host_execution": "automated_real_window_foreground_as_ordinary_user" + }, + { + "platform": "linux", + "model": "Gemma 4 E2B IT", + "instance": "gate13-20260901-a-linux", + "zone": "us-central1-b", + "machine_type": "e2-standard-8", + "image": "ubuntu-2404-noble-amd64-v20260826", + "image_project": "ubuntu-os-cloud", + "boot_disk_gib": 120, + "boot_disk_type": "pd-balanced", + "boot_disk_auto_delete": true, + "max_run_seconds": 21600, + "termination_action": "DELETE", + "lifecycle_run_id": "gate13-20260901-a-linux", + "device_profile": "cpu", + "network": "communityai-discovery", + "subnet": "communityai-us-central1", + "network_tag": "gate13-20260901-a-client", + "host_execution": "automated_real_window_dbus_session_as_ordinary_user" + } + ], + "sequencing": { + "route_live_for_both_lifecycles": true, + "clients_may_run_concurrently": false, + "route_accepted_before_any_client_create": true, + "windows_collected_and_deleted_before_linux_create": true, + "all_16_phases_required_per_platform": false, + "automated_gate13_replay_required": true, + "exact_cleanup_before_pass": true + } + }, + "preflight": { + "native_gcloud_token_refresh": true, + "compute_api_access": true, + "exact_target_instances_absent": true, + "exact_target_disks_absent": true, + "exact_target_firewalls_absent": true, + "machine_types_available": true, + "images_available": true, + "one_l4_quota_free": true, + "protected_bootstrap_running": true, + "production_packages_passed": true + }, + "prohibited": { + "fly_resources": 0, + "macos_hosts": 0, + "service_accounts": 0, + "client_gpu_instances": 0, + "concurrent_clients": 0, + "credit_operations": 0 + }, + "privacy": { + "retain_prompts": false, + "retain_outputs": false, + "retain_credentials": false, + "retain_signed_urls": false, + "retain_provider_endpoints": false + } +} From d5dc3537eb4a1e405ed9a6bfde0236bda0a58d7f Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:31:15 -0500 Subject: [PATCH 33/56] gate13: replay inside native desktop sessions --- docs/PACKAGED_ALPHA_OPERATIONS.md | 16 +++ scripts/gate13_host_job.py | 97 ++++++++++++++--- scripts/gate13_linux_client_startup.sh | 60 +++++++++++ scripts/gate13_windows_client_startup.ps1 | 124 ++++++++++++++++++++++ tests/test_gate13_client_startup.py | 83 +++++++++++++++ tests/test_gate13_host_job.py | 54 +++++++--- 6 files changed, 407 insertions(+), 27 deletions(-) create mode 100644 scripts/gate13_linux_client_startup.sh create mode 100644 scripts/gate13_windows_client_startup.ps1 create mode 100644 tests/test_gate13_client_startup.py diff --git a/docs/PACKAGED_ALPHA_OPERATIONS.md b/docs/PACKAGED_ALPHA_OPERATIONS.md index 56eedf921..21a152c9c 100644 --- a/docs/PACKAGED_ALPHA_OPERATIONS.md +++ b/docs/PACKAGED_ALPHA_OPERATIONS.md @@ -115,6 +115,22 @@ Run it as the ordinary qualification user with tracing disabled: python gate13_automated_playthrough.py --config gate13-windows-run.json > gate13-windows-evidence.json ~~~ +The replay is desktop automation, not a headless smoke test. On Windows, provision with +[gate13_windows_client_startup.ps1](../scripts/gate13_windows_client_startup.ps1), wait +for the ordinary `M` account to own a real console session, and let the privileged host +adapter register the bound task with `Interactive` logon and `Limited` run level. `S4U`, +service-session, and SSH-session launches are invalid because Qt may start without an +actual user desktop or access to that user's Credential Manager. + +On Linux, provision with +[gate13_linux_client_startup.sh](../scripts/gate13_linux_client_startup.sh). It installs +the package's complete XCB runtime closure, starts a TCP-disabled Xvfb display, and +prepares the ordinary `gate13` account. The host adapter runs the replay inside a private +`dbus-run-session`, starts GNOME Keyring's Secret Service, and passes only the fixed +display, home, and runtime-directory values into the bounded service. Do not substitute +`QT_QPA_PLATFORM=offscreen`: the qualification requires two real X11 windows and the +same native credential session across restart. + Use `platform: linux` and the exact Linux executable/archive for Linux. The durable Gate 13 host-job adapter accepts this Python entrypoint on both platforms, binds the config and source commit, and validates the aggregate before collection. A cloud replay diff --git a/scripts/gate13_host_job.py b/scripts/gate13_host_job.py index f7414b83d..f979ddb1c 100644 --- a/scripts/gate13_host_job.py +++ b/scripts/gate13_host_job.py @@ -876,9 +876,11 @@ def _windows_register_script(config: HostJobConfig) -> str: f"$taskPath = {_ps_quote(task_path)}", f"$taskName = {_ps_quote(config.job_name)}", "$identity = [Security.Principal.WindowsIdentity]::GetCurrent()", - "$currentUser = [string]$identity.Name", - "$leafUser = $currentUser.Substring($currentUser.LastIndexOf('\\') + 1)", - f"if ($identity.IsSystem -or $leafUser -ine {_ps_quote(config.host_user)}) {{ throw 'ordinary host user mismatch' }}", + "$operator = [Security.Principal.WindowsPrincipal]::new($identity)", + "if (-not $operator.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'privileged task registration required' }", + f"$targetUser = Get-LocalUser -Name {_ps_quote(config.host_user)} -ErrorAction Stop", + "$targetAccount = [Security.Principal.NTAccount]::new($env:COMPUTERNAME, [string]$targetUser.Name)", + "$targetSid = $targetAccount.Translate([Security.Principal.SecurityIdentifier]).Value", "$existing = Get-ScheduledTask -TaskPath $taskPath -TaskName $taskName -ErrorAction SilentlyContinue", "if ($null -ne $existing) { throw 'exact task already exists' }", ( @@ -886,7 +888,10 @@ def _windows_register_script(config: HostJobConfig) -> str: f"-Execute {_ps_quote(os.fspath(config.python_executable))} " f"-Argument {_ps_quote(_windows_action_arguments(config))}" ), - ("$principal = New-ScheduledTaskPrincipal -UserId $currentUser " "-LogonType S4U -RunLevel Limited"), + ( + "$principal = New-ScheduledTaskPrincipal -UserId $targetAccount.Value " + "-LogonType Interactive -RunLevel Limited" + ), ( "$settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew " f"-ExecutionTimeLimit (New-TimeSpan -Seconds {config.max_run_seconds + 2 * SUPERVISOR_GRACE_SECONDS})" @@ -914,8 +919,10 @@ def _windows_snapshot_script(config: HostJobConfig) -> str: " exit 0", "}", "$identity = [Security.Principal.WindowsIdentity]::GetCurrent()", - "$currentUser = [string]$identity.Name", - "$leafUser = $currentUser.Substring($currentUser.LastIndexOf('\\') + 1)", + "$operator = [Security.Principal.WindowsPrincipal]::new($identity)", + f"$targetUser = Get-LocalUser -Name {_ps_quote(config.host_user)} -ErrorAction Stop", + "$targetAccount = [Security.Principal.NTAccount]::new($env:COMPUTERNAME, [string]$targetUser.Name)", + "$targetSid = $targetAccount.Translate([Security.Principal.SecurityIdentifier]).Value", "$taskSid = ''", "try {", " $taskAccount = [Security.Principal.NTAccount]::new([string]$task.Principal.UserId)", @@ -929,9 +936,9 @@ def _windows_snapshot_script(config: HostJobConfig) -> str: "$binding = (@($task.Actions).Count -eq 1) -and " f"($action.Execute -eq {_ps_quote(os.fspath(config.python_executable))}) -and " f"($action.Arguments -eq {_ps_quote(arguments)}) -and " - f"(-not $identity.IsSystem) -and ($leafUser -ieq {_ps_quote(config.host_user)}) -and " - "($taskSid -eq $identity.User.Value) -and " - "($task.Principal.LogonType -eq 'S4U') -and " + "($operator.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) -and " + "($taskSid -eq $targetSid) -and " + "($task.Principal.LogonType -eq 'Interactive') -and " "($task.Principal.RunLevel -eq 'Limited') -and " "($task.Settings.MultipleInstances -eq 'IgnoreNew') -and " "($task.Settings.ExecutionTimeLimit -eq $expectedLimit)" @@ -974,12 +981,16 @@ def _linux_start_argv(config: HostJobConfig) -> list[str]: "--property=KillMode=control-group", "--property=UMask=0077", "--property=NoNewPrivileges=no", - "--property=PrivateTmp=yes", + "--property=PrivateTmp=no", "--property=TimeoutStartSec=120", f"--property=RuntimeMaxSec={config.max_run_seconds + 2 * SUPERVISOR_GRACE_SECONDS}", + "--setenv=DISPLAY=:99", + "--setenv=HOME=/home/gate13", + "--setenv=XDG_RUNTIME_DIR=/qualification/runtime", + "/usr/bin/dbus-run-session", os.fspath(config.python_executable), os.fspath(config.adapter_path), - "execute", + "execute-linux-desktop-session", "--config", os.fspath(config.config_path), ] @@ -1019,14 +1030,27 @@ def _systemd_exec_start_matches(config: HostJobConfig, value: str) -> bool: ) if matched is None: return False - expected_argv = f"{config.python_executable} {config.adapter_path} " f"execute --config {config.config_path}" + expected_argv = ( + f"/usr/bin/dbus-run-session {config.python_executable} {config.adapter_path} " + f"execute-linux-desktop-session --config {config.config_path}" + ) return ( - matched["path"] == os.fspath(config.python_executable) + matched["path"] == "/usr/bin/dbus-run-session" and matched["argv"] == expected_argv and matched["ignore"] == "no" ) +def _systemd_environment_matches(value: str) -> bool: + assignments = re.findall(r'(?:^|\s)(?:"([^"\\]*(?:\\.[^"\\]*)*)"|(\S+))', value) + rendered = {quoted or plain for quoted, plain in assignments} + return rendered == { + "DISPLAY=:99", + "HOME=/home/gate13", + "XDG_RUNTIME_DIR=/qualification/runtime", + } + + def _linux_snapshot(config: HostJobConfig, runner: Runner) -> Mapping[str, Any]: argv = [ "sudo", @@ -1047,6 +1071,7 @@ def _linux_snapshot(config: HostJobConfig, runner: Runner) -> Mapping[str, Any]: "--property=UMask", "--property=NoNewPrivileges", "--property=PrivateTmp", + "--property=Environment", "--property=TimeoutStartUSec", "--property=RuntimeMaxUSec", ] @@ -1074,6 +1099,7 @@ def _linux_snapshot(config: HostJobConfig, runner: Runner) -> Mapping[str, Any]: "UMask", "NoNewPrivileges", "PrivateTmp", + "Environment", "TimeoutStartUSec", "RuntimeMaxUSec", } @@ -1091,7 +1117,8 @@ def _linux_snapshot(config: HostJobConfig, runner: Runner) -> Mapping[str, Any]: and fields["KillMode"] == "control-group" and fields["UMask"] == "0077" and fields["NoNewPrivileges"] == "no" - and fields["PrivateTmp"] == "yes" + and fields["PrivateTmp"] == "no" + and _systemd_environment_matches(fields["Environment"]) and _parse_systemd_seconds(fields["TimeoutStartUSec"]) == 120.0 and _parse_systemd_seconds(fields["RuntimeMaxUSec"]) == config.max_run_seconds + 2 * SUPERVISOR_GRACE_SECONDS ) @@ -1168,9 +1195,45 @@ def _render(value: Mapping[str, Any]) -> str: return json.dumps(value, allow_nan=False, separators=(",", ":"), sort_keys=True) +def _execute_linux_desktop_session(config_path: Path) -> Mapping[str, Any]: + if not sys.platform.startswith("linux"): + raise HostJobError("Linux desktop session used on another platform") + expected = { + "DISPLAY": ":99", + "HOME": "/home/gate13", + "XDG_RUNTIME_DIR": "/qualification/runtime", + } + if any(os.environ.get(key) != value for key, value in expected.items()): + raise HostJobError("Linux desktop environment is not bound") + if not os.environ.get("DBUS_SESSION_BUS_ADDRESS"): + raise HostJobError("Linux D-Bus session is absent") + try: + keyring = subprocess.run( + ["/usr/bin/gnome-keyring-daemon", "--unlock", "--components=secrets"], + input="\n", + check=False, + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.SubprocessError) as exc: + raise HostJobError("Linux Secret Service could not start") from exc + if keyring.returncode != 0 or len(keyring.stdout.encode("utf-8")) > 32_768: + raise HostJobError("Linux Secret Service could not start") + for line in keyring.stdout.splitlines(): + name, separator, value = line.partition("=") + if not separator or name not in {"GNOME_KEYRING_CONTROL", "SSH_AUTH_SOCK"} or not value: + raise HostJobError("Linux Secret Service environment is invalid") + os.environ[name] = value + return execute(config_path) + + def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser(add_help=False) - parser.add_argument("action", choices=("start", "status", "execute", "collect", "cleanup")) + parser.add_argument( + "action", + choices=("start", "status", "execute", "execute-linux-desktop-session", "collect", "cleanup"), + ) parser.add_argument("--config", required=True) try: arguments = parser.parse_args(sys.argv[1:] if argv is None else argv) @@ -1184,6 +1247,10 @@ def main(argv: Sequence[str] | None = None) -> int: terminal = execute(config_path) print(_render(terminal)) return 0 if terminal["result"] == "passed" else 2 + elif arguments.action == "execute-linux-desktop-session": + terminal = _execute_linux_desktop_session(config_path) + print(_render(terminal)) + return 0 if terminal["result"] == "passed" else 2 elif arguments.action == "collect": sys.stdout.buffer.write(collect(config_path)) else: diff --git a/scripts/gate13_linux_client_startup.sh b/scripts/gate13_linux_client_startup.sh new file mode 100644 index 000000000..1ad596295 --- /dev/null +++ b/scripts/gate13_linux_client_startup.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +metadata_root=http://metadata.google.internal/computeMetadata/v1/instance/attributes +metadata() { + curl -fsS -H 'Metadata-Flavor: Google' "$metadata_root/$1" +} + +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +apt-get install -y -qq \ + python3 xvfb xauth x11-utils xdotool imagemagick dbus-x11 \ + gnome-keyring libsecret-tools libsecret-1-0 libdbus-1-3 \ + libxcb-cursor0 libxcb-icccm4 libxcb-keysyms1 libxcb-shape0 \ + libxkbcommon0 libxkbcommon-x11-0 libegl1 libgl1 libpulse0 libfontconfig1 \ + unzip curl + +if ! id gate13 >/dev/null 2>&1; then + useradd --create-home --home-dir /home/gate13 --shell /bin/bash gate13 +fi + +run_root=/qualification +download_root=/var/tmp/gate13-download +install -d -m 0700 "$download_root" +install -d -o gate13 -g gate13 -m 0700 \ + "$run_root" "$run_root/package" "$run_root/install" "$run_root/runtime" + +package_url="$(metadata package-url)" +package_sha256="$(metadata package-sha256)" +package_bytes="$(metadata package-bytes)" +wrapper="$download_root/artifact.zip" +curl -fL --retry 4 --retry-delay 5 "$package_url" -o "$wrapper" +unzip -q "$wrapper" -d "$download_root/artifact" +archive="$download_root/artifact/communityai-desktop-linux.tar.gz" +test "$(stat -c %s "$archive")" = "$package_bytes" +test "$(sha256sum "$archive" | cut -d' ' -f1)" = "$package_sha256" +mv "$archive" "$run_root/package/communityai-desktop-linux.tar.gz" +tar -xzf "$run_root/package/communityai-desktop-linux.tar.gz" -C "$run_root/install" +rm -rf "$wrapper" "$download_root/artifact" +chown -R gate13:gate13 "$run_root" + +systemd-run --quiet --collect --service-type=exec \ + --unit=communityai-gate13-display \ + --property=User=gate13 \ + --property=Group=gate13 \ + --property=Restart=no \ + --property=KillMode=control-group \ + --property=UMask=0077 \ + --property=RuntimeMaxSec=21600 \ + /usr/bin/Xvfb :99 -screen 0 1280x900x24 -nolisten tcp +for _ in $(seq 1 30); do + if sudo -u gate13 env DISPLAY=:99 xdpyinfo >/dev/null 2>&1; then + touch /var/lib/gate13-bootstrap-ready + exit 0 + fi + sleep 1 +done +echo "Gate 13 X display did not become ready" >&2 +exit 1 diff --git a/scripts/gate13_windows_client_startup.ps1 b/scripts/gate13_windows_client_startup.ps1 new file mode 100644 index 000000000..6025e8d97 --- /dev/null +++ b/scripts/gate13_windows_client_startup.ps1 @@ -0,0 +1,124 @@ +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +$metadataHeaders = @{ "Metadata-Flavor" = "Google" } +$metadataRoot = "http://metadata.google.internal/computeMetadata/v1/instance/attributes" +$bootstrapRoot = "C:\Gate13Bootstrap" +$runRoot = "C:\Gate13Run" +$downloadRoot = "C:\Gate13Download" +New-Item -ItemType Directory -Force -Path $bootstrapRoot, $runRoot, $downloadRoot | Out-Null +$readyMarker = Join-Path $bootstrapRoot "ready.txt" +if (Test-Path -LiteralPath $readyMarker -PathType Leaf) { return } + +$capability = Get-WindowsCapability -Online -Name "OpenSSH.Server~~~~0.0.1.0" +if ($capability.State -ne "Installed") { + Add-WindowsCapability -Online -Name "OpenSSH.Server~~~~0.0.1.0" | Out-Null +} +Set-Service -Name sshd -StartupType Automatic +Start-Service -Name sshd +if (-not (Get-NetFirewallRule -Name "OpenSSH-Server-In-TCP" -ErrorAction SilentlyContinue)) { + New-NetFirewallRule -Name "OpenSSH-Server-In-TCP" -DisplayName "OpenSSH Server (sshd)" -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 | Out-Null +} + +$randomBytes = New-Object byte[] 32 +[Security.Cryptography.RandomNumberGenerator]::Fill($randomBytes) +$plainPassword = [Convert]::ToBase64String($randomBytes) + "aA1!" +$securePassword = ConvertTo-SecureString $plainPassword -AsPlainText -Force +if (-not (Get-LocalUser -Name "M" -ErrorAction SilentlyContinue)) { + New-LocalUser -Name "M" -Password $securePassword -PasswordNeverExpires -UserMayNotChangePassword | Out-Null +} else { + Set-LocalUser -Name "M" -Password $securePassword +} +if (-not (Get-LocalUser -Name "Gate13Admin" -ErrorAction SilentlyContinue)) { + New-LocalUser -Name "Gate13Admin" -NoPassword -AccountNeverExpires -UserMayNotChangePassword | Out-Null +} +if (-not (Get-LocalGroupMember -Group "Administrators" -Member "Gate13Admin" -ErrorAction SilentlyContinue)) { + Add-LocalGroupMember -Group "Administrators" -Member "Gate13Admin" +} +$openSshGroup = Get-LocalGroup -Name "OpenSSH Users" +if (-not (Get-LocalGroupMember -Group $openSshGroup -Member "M" -ErrorAction SilentlyContinue)) { + Add-LocalGroupMember -Group $openSshGroup -Member "M" +} +Remove-LocalGroupMember -Group "Administrators" -Member "M" -ErrorAction SilentlyContinue + +$publicKey = (Invoke-RestMethod -Headers $metadataHeaders -Uri "$metadataRoot/gate13-ssh-public-key").Trim() +$profileRoot = "C:\Users\M" +$sshRoot = Join-Path $profileRoot ".ssh" +New-Item -ItemType Directory -Force -Path $profileRoot, $sshRoot | Out-Null +$authorizedKeys = Join-Path $sshRoot "authorized_keys" +[IO.File]::WriteAllText($authorizedKeys, $publicKey + "`n", [Text.UTF8Encoding]::new($false)) +& icacls.exe $sshRoot /inheritance:r /grant:r "M:(OI)(CI)F" "SYSTEM:(OI)(CI)F" | Out-Null +& icacls.exe $authorizedKeys /inheritance:r /grant:r "M:F" "SYSTEM:F" | Out-Null + +$programDataSsh = Join-Path $env:ProgramData "ssh" +New-Item -ItemType Directory -Force -Path $programDataSsh | Out-Null +$administratorKeys = Join-Path $programDataSsh "administrators_authorized_keys" +$ordinaryKeys = Join-Path $programDataSsh "communityai_gate13_m_authorized_keys" +[IO.File]::WriteAllText($administratorKeys, $publicKey + "`n", [Text.UTF8Encoding]::new($false)) +[IO.File]::WriteAllText($ordinaryKeys, $publicKey + "`n", [Text.UTF8Encoding]::new($false)) +& icacls.exe $administratorKeys /inheritance:r /grant:r "Administrators:F" "SYSTEM:F" | Out-Null +& icacls.exe $ordinaryKeys /inheritance:r /grant:r "Administrators:F" "SYSTEM:F" | Out-Null +$sshdConfig = Join-Path $programDataSsh "sshd_config" +if (-not (Test-Path -LiteralPath $sshdConfig -PathType Leaf)) { + Copy-Item -LiteralPath "$env:WINDIR\System32\OpenSSH\sshd_config_default" -Destination $sshdConfig +} +$marker = "# CommunityAI Gate13 ordinary user" +if (-not (Select-String -LiteralPath $sshdConfig -SimpleMatch $marker -Quiet)) { + [IO.File]::AppendAllText( + $sshdConfig, + "`n$marker`nMatch User M`n AuthorizedKeysFile __PROGRAMDATA__/ssh/communityai_gate13_m_authorized_keys`n", + [Text.UTF8Encoding]::new($false) + ) +} +& "$env:WINDIR\System32\OpenSSH\sshd.exe" -t +if ($LASTEXITCODE -ne 0) { throw "OpenSSH configuration invalid" } +Restart-Service -Name sshd + +$pythonRoot = "C:\Gate13Python" +if (-not (Test-Path -LiteralPath "$pythonRoot\python.exe" -PathType Leaf)) { + $installer = Join-Path $downloadRoot "python-3.12.9-amd64.exe" + & curl.exe -fL --retry 4 --retry-delay 5 "https://www.python.org/ftp/python/3.12.9/python-3.12.9-amd64.exe" -o $installer + if ($LASTEXITCODE -ne 0) { throw "Python download failed" } + if ((Get-Item -LiteralPath $installer).Length -ne 26923696) { throw "Python installer size changed" } + if ((Get-FileHash -LiteralPath $installer -Algorithm SHA256).Hash.ToLowerInvariant() -cne "2a52993092a19cfdffe126e2eeac46a4265e25705614546604ad44988e040c0f") { throw "Python installer digest changed" } + $process = Start-Process -FilePath $installer -ArgumentList "/quiet InstallAllUsers=1 TargetDir=$pythonRoot Include_pip=0 Include_test=0 PrependPath=0" -Wait -PassThru + if ($process.ExitCode -ne 0) { throw "Python installation failed" } + Remove-Item -LiteralPath $installer -Force +} + +$packageUrl = (Invoke-RestMethod -Headers $metadataHeaders -Uri "$metadataRoot/package-url").Trim() +$packageSha256 = (Invoke-RestMethod -Headers $metadataHeaders -Uri "$metadataRoot/package-sha256").Trim().ToLowerInvariant() +$packageBytes = [int64](Invoke-RestMethod -Headers $metadataHeaders -Uri "$metadataRoot/package-bytes") +$wrapper = Join-Path $downloadRoot "artifact.zip" +& curl.exe -fL --retry 4 --retry-delay 5 $packageUrl -o $wrapper +if ($LASTEXITCODE -ne 0) { throw "Package wrapper download failed" } +$staging = Join-Path $downloadRoot "artifact" +New-Item -ItemType Directory -Force -Path $staging | Out-Null +& tar.exe -xf $wrapper -C $staging +if ($LASTEXITCODE -ne 0) { throw "Package wrapper extraction failed" } +$archive = Join-Path $staging "communityai-desktop-windows.zip" +if ((Get-Item -LiteralPath $archive).Length -ne $packageBytes) { throw "Package byte size changed" } +if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $packageSha256) { throw "Package digest changed" } +$packageRoot = Join-Path $runRoot "package" +$installRoot = Join-Path $runRoot "install" +New-Item -ItemType Directory -Force -Path $packageRoot, $installRoot | Out-Null +Move-Item -LiteralPath $archive -Destination (Join-Path $packageRoot "communityai-desktop-windows.zip") +& tar.exe -xf (Join-Path $packageRoot "communityai-desktop-windows.zip") -C $installRoot +if ($LASTEXITCODE -ne 0) { throw "Product extraction failed" } +Remove-Item -LiteralPath $wrapper, $staging -Recurse -Force +& icacls.exe $runRoot /inheritance:r /grant:r "M:(OI)(CI)F" "Administrators:(OI)(CI)F" "SYSTEM:(OI)(CI)F" | Out-Null + +$winlogon = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" +Set-ItemProperty -Path $winlogon -Name AutoAdminLogon -Value "1" -Type String +Set-ItemProperty -Path $winlogon -Name DefaultUserName -Value "M" -Type String +Set-ItemProperty -Path $winlogon -Name DefaultDomainName -Value $env:COMPUTERNAME -Type String +Set-ItemProperty -Path $winlogon -Name DefaultPassword -Value $plainPassword -Type String +Set-ItemProperty -Path $winlogon -Name AutoLogonCount -Value 1 -Type DWord +$clearArgument = '-NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 60; $p = ''HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon''; Remove-ItemProperty -Path $p -Name DefaultPassword -ErrorAction SilentlyContinue; Set-ItemProperty -Path $p -Name AutoAdminLogon -Value ''0'' -Type String"' +$clearAction = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $clearArgument +$clearTrigger = New-ScheduledTaskTrigger -AtLogOn -User "M" +Register-ScheduledTask -TaskName "Gate13ClearAutoLogon" -Action $clearAction -Trigger $clearTrigger -User "SYSTEM" -RunLevel Highest -Force | Out-Null +$plainPassword = $null +$securePassword = $null +[IO.File]::WriteAllText($readyMarker, "ready`n", [Text.UTF8Encoding]::new($false)) +Restart-Computer -Force diff --git a/tests/test_gate13_client_startup.py b/tests/test_gate13_client_startup.py new file mode 100644 index 000000000..f758cb5b6 --- /dev/null +++ b/tests/test_gate13_client_startup.py @@ -0,0 +1,83 @@ +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +WINDOWS = ROOT / "scripts" / "gate13_windows_client_startup.ps1" +LINUX = ROOT / "scripts" / "gate13_linux_client_startup.sh" +GIT_BASH = Path(r"C:\Program Files\Git\bin\bash.exe") +BASH = str(GIT_BASH) if GIT_BASH.is_file() else shutil.which("bash") + + +def test_windows_bootstrap_preserves_the_proven_interactive_boundary(): + source = WINDOWS.read_text(encoding="utf-8") + + assert 'New-LocalUser -Name "M"' in source + assert 'Remove-LocalGroupMember -Group "Administrators" -Member "M"' in source + assert 'New-LocalUser -Name "Gate13Admin"' in source + assert 'Set-ItemProperty -Path $winlogon -Name AutoAdminLogon -Value "1"' in source + assert 'New-ScheduledTaskTrigger -AtLogOn -User "M"' in source + assert "Remove-ItemProperty -Path $p -Name DefaultPassword" in source + assert "Restart-Computer -Force" in source + assert "2a52993092a19cfdffe126e2eeac46a4265e25705614546604ad44988e040c0f" in source + assert "communityai_gate13_m_authorized_keys" in source + + +@pytest.mark.skipif(sys.platform != "win32", reason="requires Windows PowerShell parser") +def test_windows_bootstrap_parses_natively(): + probe = ( + f"$source=Get-Content -Raw -LiteralPath '{WINDOWS}';" + "$tokens=$null;$errors=$null;" + "[Management.Automation.Language.Parser]::ParseInput($source,[ref]$tokens,[ref]$errors)|Out-Null;" + "if($errors.Count -ne 0){$errors|ForEach-Object{$_.Message};exit 2}" + ) + result = subprocess.run( + [ + r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + probe, + ], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stderr or result.stdout + + +def test_linux_bootstrap_contains_the_proven_x11_runtime_and_display(): + source = LINUX.read_text(encoding="utf-8") + for package in ( + "xvfb", + "dbus-x11", + "gnome-keyring", + "libsecret-tools", + "libxcb-cursor0", + "libxcb-icccm4", + "libxcb-keysyms1", + "libxcb-shape0", + "libxkbcommon-x11-0", + ): + assert package in source + assert "/usr/bin/Xvfb :99" in source + assert "-nolisten tcp" in source + assert "DISPLAY=:99 xdpyinfo" in source + + +@pytest.mark.skipif(BASH is None, reason="requires bash parser") +def test_linux_bootstrap_parses_natively(): + result = subprocess.run( + [BASH, "-n", str(LINUX)], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stderr diff --git a/tests/test_gate13_host_job.py b/tests/test_gate13_host_job.py index e0bdde512..94273922e 100644 --- a/tests/test_gate13_host_job.py +++ b/tests/test_gate13_host_job.py @@ -180,15 +180,16 @@ def test_windows_lifecycle_config_must_be_beside_entrypoint(config_factory): host_job.load_config(path) -def test_windows_task_is_bounded_ordinary_user_single_instance(config_factory): +def test_windows_task_is_bounded_interactive_ordinary_user_single_instance(config_factory): path, _raw = config_factory("windows") config = host_job.load_config(path) script = host_job._windows_register_script(config) - assert "New-ScheduledTaskPrincipal -UserId $currentUser" in script - assert "-LogonType S4U -RunLevel Limited" in script - assert "$identity.IsSystem" in script + assert "New-ScheduledTaskPrincipal -UserId $targetAccount.Value" in script + assert "-LogonType Interactive -RunLevel Limited" in script + assert "privileged task registration required" in script + assert "Get-LocalUser -Name 'gate13'" in script assert "'SYSTEM'" not in script assert "-MultipleInstances IgnoreNew" in script assert "-ExecutionTimeLimit" in script @@ -200,8 +201,8 @@ def test_windows_task_is_bounded_ordinary_user_single_instance(config_factory): snapshot = host_job._windows_snapshot_script(config) assert "MultipleInstances -eq 'IgnoreNew'" in snapshot assert "ExecutionTimeLimit -eq $expectedLimit" in snapshot - assert "LogonType -eq 'S4U'" in snapshot - assert "$taskSid -eq $identity.User.Value" in snapshot + assert "LogonType -eq 'Interactive'" in snapshot + assert "$taskSid -eq $targetSid" in snapshot assert "NTAccount]::new([string]$task.Principal.UserId)" in snapshot assert "RunLevel -eq 'Limited'" in snapshot @@ -248,8 +249,14 @@ def test_linux_unit_is_bounded_non_root_and_non_restarting(config_factory): assert "--property=Restart=no" in argv assert "--property=KillMode=control-group" in argv assert "--property=NoNewPrivileges=no" in argv + assert "--property=PrivateTmp=no" in argv assert "--property=TimeoutStartSec=120" in argv assert f"--property=RuntimeMaxSec={config.max_run_seconds + 2 * host_job.SUPERVISOR_GRACE_SECONDS}" in argv + assert "--setenv=DISPLAY=:99" in argv + assert "--setenv=HOME=/home/gate13" in argv + assert "--setenv=XDG_RUNTIME_DIR=/qualification/runtime" in argv + assert "/usr/bin/dbus-run-session" in argv + assert "execute-linux-desktop-session" in argv assert "--wait" not in argv assert host_job._entrypoint_argv(config)[-2:] == [ "--config", @@ -257,6 +264,28 @@ def test_linux_unit_is_bounded_non_root_and_non_restarting(config_factory): ] +def test_linux_desktop_session_starts_secret_service_before_execute(tmp_path, monkeypatch): + config_path = tmp_path / "host-job.json" + observed = [] + monkeypatch.setattr(host_job.sys, "platform", "linux") + monkeypatch.setenv("DISPLAY", ":99") + monkeypatch.setenv("HOME", "/home/gate13") + monkeypatch.setenv("XDG_RUNTIME_DIR", "/qualification/runtime") + monkeypatch.setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/qualification/runtime/bus") + + def run(argv, **kwargs): + observed.append((argv, kwargs)) + return subprocess.CompletedProcess(argv, 0, stdout="GNOME_KEYRING_CONTROL=/qualification/runtime/keyring\n", stderr="") + + monkeypatch.setattr(host_job.subprocess, "run", run) + monkeypatch.setattr(host_job, "execute", lambda path: {"result": "passed", "path": str(path)}) + + assert host_job._execute_linux_desktop_session(config_path)["result"] == "passed" + assert observed[0][0] == ["/usr/bin/gnome-keyring-daemon", "--unlock", "--components=secrets"] + assert observed[0][1]["input"] == "\n" + assert host_job.os.environ["GNOME_KEYRING_CONTROL"] == "/qualification/runtime/keyring" + + def test_windows_automated_python_replay_uses_the_bound_python(config_factory): path, _raw = config_factory("windows") config = host_job.load_config(path) @@ -523,9 +552,9 @@ def test_linux_snapshot_binds_exact_service_command(config_factory): "User=gate13", "Group=gate13", ( - "ExecStart={ path=" - f"{config.python_executable} ; argv[]={config.python_executable} " - f"{config.adapter_path} execute --config {config.config_path} ; " + "ExecStart={ path=/usr/bin/dbus-run-session ; argv[]=/usr/bin/dbus-run-session " + f"{config.python_executable} {config.adapter_path} " + f"execute-linux-desktop-session --config {config.config_path} ; " "ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; " "pid=0 ; code=(null) ; status=0/0 }" ), @@ -534,7 +563,8 @@ def test_linux_snapshot_binds_exact_service_command(config_factory): "KillMode=control-group", "UMask=0077", "NoNewPrivileges=no", - "PrivateTmp=yes", + "PrivateTmp=no", + 'Environment="DISPLAY=:99" "HOME=/home/gate13" "XDG_RUNTIME_DIR=/qualification/runtime"', "TimeoutStartUSec=2min", "RuntimeMaxUSec=1h 2min", ] @@ -550,8 +580,8 @@ def runner(_argv, timeout): } foreign_stdout = stdout.replace( - f"execute --config {config.config_path} ;", - f"execute --config {config.config_path} --extra ;", + f"execute-linux-desktop-session --config {config.config_path} ;", + f"execute-linux-desktop-session --config {config.config_path} --extra ;", ) def foreign_runner(_argv, timeout): From 80ea5917861e96ffc07f27537b1906e5dca417d1 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:32:23 -0500 Subject: [PATCH 34/56] docs: bind Gate 13 desktop session replay --- .../gate13-20260901-a-cost-authorization.json | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/docs/evidence/gate13-20260901-a-cost-authorization.json b/docs/evidence/gate13-20260901-a-cost-authorization.json index 8c600962c..22105ff77 100644 --- a/docs/evidence/gate13-20260901-a-cost-authorization.json +++ b/docs/evidence/gate13-20260901-a-cost-authorization.json @@ -7,7 +7,8 @@ "recorded_at": "2026-09-01T20:58:50Z", "source": { "durable_controller_commit": "905cabd13c481adff4a5a7de850ceb6ee839a25b", - "host_job_adapter_commit": "905cabd13c481adff4a5a7de850ceb6ee839a25b", + "host_job_adapter_commit": "d5dc3537eb4a1e405ed9a6bfde0236bda0a58d7f", + "client_session_bootstrap_commit": "d5dc3537eb4a1e405ed9a6bfde0236bda0a58d7f", "route_runtime_commit": "f64a388a47b098ac7f69d2affc59816376b43bb1", "route_setup_commit": "905cabd13c481adff4a5a7de850ceb6ee839a25b", "package_commit": "1476d67f3887dfd0de2acfb1305cbcca9975614f", @@ -36,9 +37,21 @@ }, "host_job_adapter": { "path": "scripts/gate13_host_job.py", - "source_commit": "905cabd13c481adff4a5a7de850ceb6ee839a25b", - "sha256": "f9bd388a61226df6ac3fcadb5bdc68134e2ccb741718f4a4c65543cfc4180cdb", - "bytes": 42513 + "source_commit": "d5dc3537eb4a1e405ed9a6bfde0236bda0a58d7f", + "sha256": "3fd232fa291c849fb45b0432b556bf01a6cb22650d7911389eb533e2f95ce3ff", + "bytes": 45573 + }, + "windows_client_startup": { + "path": "scripts/gate13_windows_client_startup.ps1", + "source_commit": "d5dc3537eb4a1e405ed9a6bfde0236bda0a58d7f", + "sha256": "5b59ca91970af3459d06054f466bad910cde4aadf4e7e3c3e101a931e26b93a8", + "bytes": 8042 + }, + "linux_client_startup": { + "path": "scripts/gate13_linux_client_startup.sh", + "source_commit": "d5dc3537eb4a1e405ed9a6bfde0236bda0a58d7f", + "sha256": "72ac32fb78946ac09b60bbef571a944a018d790871fafbb818ec7006bee292c6", + "bytes": 2138 }, "lifecycle_evidence_validator": { "path": "scripts/gate13_packaged_lifecycle.py", From 389661cbe6443a88da70556d29de84dc521a00f0 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:50:11 -0500 Subject: [PATCH 35/56] gate13: support Windows startup PowerShell crypto --- scripts/gate13_windows_client_startup.ps1 | 7 ++++++- tests/test_gate13_client_startup.py | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/gate13_windows_client_startup.ps1 b/scripts/gate13_windows_client_startup.ps1 index 6025e8d97..9f23a92ba 100644 --- a/scripts/gate13_windows_client_startup.ps1 +++ b/scripts/gate13_windows_client_startup.ps1 @@ -21,7 +21,12 @@ if (-not (Get-NetFirewallRule -Name "OpenSSH-Server-In-TCP" -ErrorAction Silentl } $randomBytes = New-Object byte[] 32 -[Security.Cryptography.RandomNumberGenerator]::Fill($randomBytes) +$randomGenerator = [Security.Cryptography.RandomNumberGenerator]::Create() +try { + $randomGenerator.GetBytes($randomBytes) +} finally { + $randomGenerator.Dispose() +} $plainPassword = [Convert]::ToBase64String($randomBytes) + "aA1!" $securePassword = ConvertTo-SecureString $plainPassword -AsPlainText -Force if (-not (Get-LocalUser -Name "M" -ErrorAction SilentlyContinue)) { diff --git a/tests/test_gate13_client_startup.py b/tests/test_gate13_client_startup.py index f758cb5b6..4d8823e9e 100644 --- a/tests/test_gate13_client_startup.py +++ b/tests/test_gate13_client_startup.py @@ -16,6 +16,8 @@ def test_windows_bootstrap_preserves_the_proven_interactive_boundary(): source = WINDOWS.read_text(encoding="utf-8") + assert "RandomNumberGenerator]::Create()" in source + assert "RandomNumberGenerator]::Fill" not in source assert 'New-LocalUser -Name "M"' in source assert 'Remove-LocalGroupMember -Group "Administrators" -Member "M"' in source assert 'New-LocalUser -Name "Gate13Admin"' in source From 2fa2e898a519def3335a39dffc4d315bd530a3be Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:50:42 -0500 Subject: [PATCH 36/56] docs: bind Windows bootstrap compatibility fix --- docs/evidence/gate13-20260901-a-cost-authorization.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/evidence/gate13-20260901-a-cost-authorization.json b/docs/evidence/gate13-20260901-a-cost-authorization.json index 22105ff77..7dad8d1c5 100644 --- a/docs/evidence/gate13-20260901-a-cost-authorization.json +++ b/docs/evidence/gate13-20260901-a-cost-authorization.json @@ -8,7 +8,7 @@ "source": { "durable_controller_commit": "905cabd13c481adff4a5a7de850ceb6ee839a25b", "host_job_adapter_commit": "d5dc3537eb4a1e405ed9a6bfde0236bda0a58d7f", - "client_session_bootstrap_commit": "d5dc3537eb4a1e405ed9a6bfde0236bda0a58d7f", + "client_session_bootstrap_commit": "389661cbe6443a88da70556d29de84dc521a00f0", "route_runtime_commit": "f64a388a47b098ac7f69d2affc59816376b43bb1", "route_setup_commit": "905cabd13c481adff4a5a7de850ceb6ee839a25b", "package_commit": "1476d67f3887dfd0de2acfb1305cbcca9975614f", @@ -43,9 +43,9 @@ }, "windows_client_startup": { "path": "scripts/gate13_windows_client_startup.ps1", - "source_commit": "d5dc3537eb4a1e405ed9a6bfde0236bda0a58d7f", - "sha256": "5b59ca91970af3459d06054f466bad910cde4aadf4e7e3c3e101a931e26b93a8", - "bytes": 8042 + "source_commit": "389661cbe6443a88da70556d29de84dc521a00f0", + "sha256": "4ab1a90f20b8faee6de2b0679f9650219f3b17b542a730bcac6deaf249ea031f", + "bytes": 8146 }, "linux_client_startup": { "path": "scripts/gate13_linux_client_startup.sh", From 5f1eef1e662ef2d844630702e5713666ceb95b03 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:00:27 -0500 Subject: [PATCH 37/56] gate13: restore Windows SSH after provisioning reboot --- scripts/gate13_windows_client_startup.ps1 | 12 +++++++++++- tests/test_gate13_client_startup.py | 2 ++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/gate13_windows_client_startup.ps1 b/scripts/gate13_windows_client_startup.ps1 index 9f23a92ba..b7fe7fb43 100644 --- a/scripts/gate13_windows_client_startup.ps1 +++ b/scripts/gate13_windows_client_startup.ps1 @@ -8,7 +8,17 @@ $runRoot = "C:\Gate13Run" $downloadRoot = "C:\Gate13Download" New-Item -ItemType Directory -Force -Path $bootstrapRoot, $runRoot, $downloadRoot | Out-Null $readyMarker = Join-Path $bootstrapRoot "ready.txt" -if (Test-Path -LiteralPath $readyMarker -PathType Leaf) { return } +if (Test-Path -LiteralPath $readyMarker -PathType Leaf) { + Set-Service -Name sshd -StartupType Automatic + if ((Get-Service -Name sshd).Status -ne "Running") { Start-Service -Name sshd } + $sshFirewall = Get-NetFirewallRule -Name "OpenSSH-Server-In-TCP" -ErrorAction SilentlyContinue + if ($null -eq $sshFirewall) { + New-NetFirewallRule -Name "OpenSSH-Server-In-TCP" -DisplayName "OpenSSH Server (sshd)" -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 | Out-Null + } else { + Set-NetFirewallRule -Name "OpenSSH-Server-In-TCP" -Enabled True + } + return +} $capability = Get-WindowsCapability -Online -Name "OpenSSH.Server~~~~0.0.1.0" if ($capability.State -ne "Installed") { diff --git a/tests/test_gate13_client_startup.py b/tests/test_gate13_client_startup.py index 4d8823e9e..c589320bc 100644 --- a/tests/test_gate13_client_startup.py +++ b/tests/test_gate13_client_startup.py @@ -18,6 +18,8 @@ def test_windows_bootstrap_preserves_the_proven_interactive_boundary(): assert "RandomNumberGenerator]::Create()" in source assert "RandomNumberGenerator]::Fill" not in source + assert 'if ((Get-Service -Name sshd).Status -ne "Running") { Start-Service -Name sshd }' in source + assert 'Set-NetFirewallRule -Name "OpenSSH-Server-In-TCP" -Enabled True' in source assert 'New-LocalUser -Name "M"' in source assert 'Remove-LocalGroupMember -Group "Administrators" -Member "M"' in source assert 'New-LocalUser -Name "Gate13Admin"' in source From dbf09b844dd22960c83eee76bdad98b9fe1460a3 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:00:46 -0500 Subject: [PATCH 38/56] docs: bind Windows reboot service recovery --- docs/evidence/gate13-20260901-a-cost-authorization.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/evidence/gate13-20260901-a-cost-authorization.json b/docs/evidence/gate13-20260901-a-cost-authorization.json index 7dad8d1c5..23804138e 100644 --- a/docs/evidence/gate13-20260901-a-cost-authorization.json +++ b/docs/evidence/gate13-20260901-a-cost-authorization.json @@ -8,7 +8,7 @@ "source": { "durable_controller_commit": "905cabd13c481adff4a5a7de850ceb6ee839a25b", "host_job_adapter_commit": "d5dc3537eb4a1e405ed9a6bfde0236bda0a58d7f", - "client_session_bootstrap_commit": "389661cbe6443a88da70556d29de84dc521a00f0", + "client_session_bootstrap_commit": "5f1eef1e662ef2d844630702e5713666ceb95b03", "route_runtime_commit": "f64a388a47b098ac7f69d2affc59816376b43bb1", "route_setup_commit": "905cabd13c481adff4a5a7de850ceb6ee839a25b", "package_commit": "1476d67f3887dfd0de2acfb1305cbcca9975614f", @@ -43,9 +43,9 @@ }, "windows_client_startup": { "path": "scripts/gate13_windows_client_startup.ps1", - "source_commit": "389661cbe6443a88da70556d29de84dc521a00f0", - "sha256": "4ab1a90f20b8faee6de2b0679f9650219f3b17b542a730bcac6deaf249ea031f", - "bytes": 8146 + "source_commit": "5f1eef1e662ef2d844630702e5713666ceb95b03", + "sha256": "491b8091f7c0151afcc4a91619e5e5a100a10cfef63e38900d4ffb08c9cc7ab5", + "bytes": 8689 }, "linux_client_startup": { "path": "scripts/gate13_linux_client_startup.sh", From e60c3577c7205ff434cad6e9396f89555626aceb Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:34:31 -0500 Subject: [PATCH 39/56] gate13: replay exact one-token inference --- .../communityai_desktop/gate13_playthrough.py | 4 ++-- desktop/tests/test_gate13_playthrough.py | 20 ++++++++++++++++++- docs/PACKAGED_ALPHA_OPERATIONS.md | 3 ++- scripts/gate13_automated_playthrough.py | 3 +-- scripts/gate13_windows_client_startup.ps1 | 3 ++- tests/test_gate13_automated_playthrough.py | 6 ++++++ tests/test_gate13_client_startup.py | 2 +- 7 files changed, 33 insertions(+), 8 deletions(-) diff --git a/desktop/src/communityai_desktop/gate13_playthrough.py b/desktop/src/communityai_desktop/gate13_playthrough.py index fc742bba3..3f0e9bc30 100644 --- a/desktop/src/communityai_desktop/gate13_playthrough.py +++ b/desktop/src/communityai_desktop/gate13_playthrough.py @@ -213,7 +213,7 @@ def _completion_request(url: str, secret: str, timeout: float) -> Mapping[str, A "model": "auto", "messages": [{"role": "user", "content": "Reply with one short word."}], "temperature": 0, - "max_tokens": 8, + "max_tokens": 1, "n": 1, "stream": False, }, @@ -298,7 +298,7 @@ def qualify_localhost_inference(controller: Any, plan: PlaythroughPlan) -> dict[ ): raise PlaythroughError("localhost inference content is empty") generated = usage.get("completion_tokens") if isinstance(usage, dict) else None - if type(generated) is not int or not 1 <= generated <= 8: + if type(generated) is not int or generated != 1: raise PlaythroughError("localhost inference token count is invalid") completion_count = 1 generated_token_count = generated diff --git a/desktop/tests/test_gate13_playthrough.py b/desktop/tests/test_gate13_playthrough.py index a224dd2a1..ae98b6abf 100644 --- a/desktop/tests/test_gate13_playthrough.py +++ b/desktop/tests/test_gate13_playthrough.py @@ -5,7 +5,7 @@ import unittest from pathlib import Path from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import MagicMock, patch from communityai_desktop.acceptance import fake_node from communityai_desktop.app import main @@ -142,6 +142,24 @@ def revoke_key(self, key_id): self.assertFalse(result["response_content_retained"]) self.assertEqual({item["id"] for item in client.list_keys() if item["revoked_at"] is None}, {"baseline"}) + def test_localhost_inference_requests_exactly_one_token(self): + from communityai_desktop.gate13_playthrough import _completion_request + + response = MagicMock() + response.__enter__.return_value = response + response.status = 200 + response.headers.get_content_type.return_value = "application/json" + response.read.return_value = b'{"result":"bounded"}' + opener = MagicMock() + opener.open.return_value = response + + with patch("communityai_desktop.gate13_playthrough.build_opener", return_value=opener): + result = _completion_request("http://127.0.0.1:8080/v1/chat/completions", "secret", 10) + + self.assertEqual(result, {"result": "bounded"}) + request = opener.open.call_args.args[0] + self.assertEqual(json.loads(request.data)["max_tokens"], 1) + def test_hidden_packaged_cli_installs_the_qualification_automation(self): lifecycle = SimpleNamespace(close=lambda: None) loaded_plan = SimpleNamespace(stage="start") diff --git a/docs/PACKAGED_ALPHA_OPERATIONS.md b/docs/PACKAGED_ALPHA_OPERATIONS.md index 21a152c9c..40bf38545 100644 --- a/docs/PACKAGED_ALPHA_OPERATIONS.md +++ b/docs/PACKAGED_ALPHA_OPERATIONS.md @@ -69,7 +69,8 @@ stopped. A second fresh desktop process proves sharing resumed after restart, cl **Pause sharing**, proves the worker stopped, and performs another localhost inference. Each inference creates one in-memory temporary client key, retains only completion and -token counts, revokes the key, and proves the active-key baseline was restored. Session +token counts, revokes the key, and proves the active-key baseline was restored. It requests +and requires exactly one generated token, matching the manual Gate 13 procedure. Session timeouts are bounded to one hour each. The outer runner verifies the production archive digest and byte size, runs the four packaged self-tests, executes both window sessions, validates their strict privacy-safe evidence, and removes its exact run-scoped temporary diff --git a/scripts/gate13_automated_playthrough.py b/scripts/gate13_automated_playthrough.py index 5056ac8d8..c84dd9bc1 100644 --- a/scripts/gate13_automated_playthrough.py +++ b/scripts/gate13_automated_playthrough.py @@ -302,8 +302,7 @@ def _validate_session(path: Path, config: ReplayConfig, stage: str) -> Mapping[s or inference.get("model_id") != config.model_id or inference.get("manifest_digest") != config.manifest_digest or inference.get("completion_count") != 1 - or type(inference.get("generated_token_count")) is not int - or not 1 <= inference["generated_token_count"] <= 8 + or inference.get("generated_token_count") != 1 or inference.get("response_content_retained") is not False or inference.get("token_identifiers_retained") is not False or inference.get("temporary_key_removed") is not True diff --git a/scripts/gate13_windows_client_startup.ps1 b/scripts/gate13_windows_client_startup.ps1 index b7fe7fb43..c57ba60c8 100644 --- a/scripts/gate13_windows_client_startup.ps1 +++ b/scripts/gate13_windows_client_startup.ps1 @@ -15,7 +15,7 @@ if (Test-Path -LiteralPath $readyMarker -PathType Leaf) { if ($null -eq $sshFirewall) { New-NetFirewallRule -Name "OpenSSH-Server-In-TCP" -DisplayName "OpenSSH Server (sshd)" -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 | Out-Null } else { - Set-NetFirewallRule -Name "OpenSSH-Server-In-TCP" -Enabled True + Set-NetFirewallRule -Name "OpenSSH-Server-In-TCP" -Enabled True -Profile Any } return } @@ -29,6 +29,7 @@ Start-Service -Name sshd if (-not (Get-NetFirewallRule -Name "OpenSSH-Server-In-TCP" -ErrorAction SilentlyContinue)) { New-NetFirewallRule -Name "OpenSSH-Server-In-TCP" -DisplayName "OpenSSH Server (sshd)" -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 | Out-Null } +Set-NetFirewallRule -Name "OpenSSH-Server-In-TCP" -Enabled True -Profile Any $randomBytes = New-Object byte[] 32 $randomGenerator = [Security.Cryptography.RandomNumberGenerator]::Create() diff --git a/tests/test_gate13_automated_playthrough.py b/tests/test_gate13_automated_playthrough.py index 824374b1d..050983933 100644 --- a/tests/test_gate13_automated_playthrough.py +++ b/tests/test_gate13_automated_playthrough.py @@ -159,3 +159,9 @@ def test_config_and_session_evidence_fail_closed(tmp_path): evidence_path.write_text(json.dumps(evidence), encoding="utf-8") with pytest.raises(replay.ReplayError): replay._validate_session(evidence_path, config, "start") + + evidence = session_evidence({**valid, "stage": "start"}) + evidence["inference"]["generated_token_count"] = 2 + evidence_path.write_text(json.dumps(evidence), encoding="utf-8") + with pytest.raises(replay.ReplayError): + replay._validate_session(evidence_path, config, "start") diff --git a/tests/test_gate13_client_startup.py b/tests/test_gate13_client_startup.py index c589320bc..3c677d25b 100644 --- a/tests/test_gate13_client_startup.py +++ b/tests/test_gate13_client_startup.py @@ -19,7 +19,7 @@ def test_windows_bootstrap_preserves_the_proven_interactive_boundary(): assert "RandomNumberGenerator]::Create()" in source assert "RandomNumberGenerator]::Fill" not in source assert 'if ((Get-Service -Name sshd).Status -ne "Running") { Start-Service -Name sshd }' in source - assert 'Set-NetFirewallRule -Name "OpenSSH-Server-In-TCP" -Enabled True' in source + assert source.count('Set-NetFirewallRule -Name "OpenSSH-Server-In-TCP" -Enabled True -Profile Any') == 2 assert 'New-LocalUser -Name "M"' in source assert 'Remove-LocalGroupMember -Group "Administrators" -Member "M"' in source assert 'New-LocalUser -Name "Gate13Admin"' in source From 01e391808ad1a83741e58cfe16ec643612c2c195 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:08:42 -0500 Subject: [PATCH 40/56] docs: bind one-token Gate 13 packages --- .../gate13-20260901-a-cost-authorization.json | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/evidence/gate13-20260901-a-cost-authorization.json b/docs/evidence/gate13-20260901-a-cost-authorization.json index 23804138e..75d31a818 100644 --- a/docs/evidence/gate13-20260901-a-cost-authorization.json +++ b/docs/evidence/gate13-20260901-a-cost-authorization.json @@ -8,11 +8,11 @@ "source": { "durable_controller_commit": "905cabd13c481adff4a5a7de850ceb6ee839a25b", "host_job_adapter_commit": "d5dc3537eb4a1e405ed9a6bfde0236bda0a58d7f", - "client_session_bootstrap_commit": "5f1eef1e662ef2d844630702e5713666ceb95b03", + "client_session_bootstrap_commit": "e60c3577c7205ff434cad6e9396f89555626aceb", "route_runtime_commit": "f64a388a47b098ac7f69d2affc59816376b43bb1", "route_setup_commit": "905cabd13c481adff4a5a7de850ceb6ee839a25b", - "package_commit": "1476d67f3887dfd0de2acfb1305cbcca9975614f", - "production_workflow_run": 33555907921 + "package_commit": "e60c3577c7205ff434cad6e9396f89555626aceb", + "production_workflow_run": 33566978488 }, "authorization": { "owner_reset_recorded": true, @@ -43,9 +43,9 @@ }, "windows_client_startup": { "path": "scripts/gate13_windows_client_startup.ps1", - "source_commit": "5f1eef1e662ef2d844630702e5713666ceb95b03", - "sha256": "491b8091f7c0151afcc4a91619e5e5a100a10cfef63e38900d4ffb08c9cc7ab5", - "bytes": 8689 + "source_commit": "e60c3577c7205ff434cad6e9396f89555626aceb", + "sha256": "3f8600c42a3c0765e100963c2e28cdef7c6b248992924ff3406941aefce7cf47", + "bytes": 8779 }, "linux_client_startup": { "path": "scripts/gate13_linux_client_startup.sh", @@ -61,9 +61,9 @@ }, "automated_playthrough": { "path": "scripts/gate13_automated_playthrough.py", - "source_commit": "1476d67f3887dfd0de2acfb1305cbcca9975614f", - "sha256": "b6e289f105632728a7e585777a320bb3fcbb7cdd527c1d4a7ec2f816ffa17a37", - "bytes": 18331 + "source_commit": "e60c3577c7205ff434cad6e9396f89555626aceb", + "sha256": "b9633c1580d5f0b64c3407ed9b7e272390e60faaa15c6f3eb09df4465864d60a", + "bytes": 18259 }, "route_setup": { "path": "scripts/gate13_route_setup.sh", @@ -79,13 +79,13 @@ "model_artifacts_embedded": false }, "windows_package": { - "sha256": "790d13505e387f91b3e080f303dfd0db2feefc067844fd57f003d9e5cd4aa687", - "bytes": 2695084305, + "sha256": "fb65dfc5f9d797761d3ba345e2338813660408fc171ceba338f0f2704414f70d", + "bytes": 2695083337, "workflow_artifact": "communityai-desktop-install-windows" }, "linux_package": { - "sha256": "7816abb5a83395eac4e898ff1301a85e0a8bbdbced0c5e593701f43122b798e1", - "bytes": 3360729627, + "sha256": "0afd773d44179477b34fffc6a89fc093b4dbd8ba7d19a063ae58700a4587235b", + "bytes": 3360734113, "workflow_artifact": "communityai-desktop-install-linux" }, "qwen_manifest": "sha256:3ba8528cb3c0d85e1ed048e0438a0d64cfbbc298944ed674caa6950d415f8e33", From d957aac869e1df680c2819c32407b9901e2e34a3 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:47:07 -0500 Subject: [PATCH 41/56] gate13: replay proven CPU sharing policy --- .../communityai_desktop/gate13_playthrough.py | 45 ++++++++++++++----- desktop/tests/test_gate13_playthrough.py | 26 ++++++++--- docs/PACKAGED_ALPHA_OPERATIONS.md | 21 ++++++--- scripts/gate13_automated_playthrough.py | 39 +++++++++++----- scripts/gate13_packaged_lifecycle.py | 4 ++ tests/test_gate13_automated_playthrough.py | 30 ++++++++++--- tests/test_gate13_packaged_lifecycle.py | 2 + tests/test_gate13_run_controller.py | 1 + 8 files changed, 130 insertions(+), 38 deletions(-) diff --git a/desktop/src/communityai_desktop/gate13_playthrough.py b/desktop/src/communityai_desktop/gate13_playthrough.py index 3f0e9bc30..a65236841 100644 --- a/desktop/src/communityai_desktop/gate13_playthrough.py +++ b/desktop/src/communityai_desktop/gate13_playthrough.py @@ -44,6 +44,21 @@ "pause_timeout", "schedule", } + + +def _manual_schedule() -> dict[str, Any]: + return { + "timezone": "UTC", + "windows": [ + { + "days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], + "start": "00:00", + "end": "23:59", + } + ], + } + + _CONFIG_FIELDS = { "schema_version", "run_id", @@ -124,15 +139,20 @@ def _policy(value: Any, model_id: str) -> dict[str, Any]: raise PlaythroughError("sharing must be enabled for the start stage") if model_id not in allowed or model_id not in preferred or denied: raise PlaythroughError("sharing policy does not select the qualification model") - if not isinstance(value["max_disk_space"], str) or not 1 <= len(value["max_disk_space"]) <= 32: - raise PlaythroughError("storage ceiling is invalid") - if not isinstance(value["max_vram"], str) or not 1 <= len(value["max_vram"]) <= 32: - raise PlaythroughError("memory ceiling is invalid") + if value["max_disk_space"] != "32GB": + raise PlaythroughError("storage ceiling does not match the proven manual replay") + if value["max_vram"] != "20GB": + raise PlaythroughError("memory ceiling does not match the proven manual replay") bandwidth = _bounded_number(value["max_bandwidth_mbps"], "bandwidth ceiling", minimum=0.001, maximum=1_000_000) - power = _bounded_number(value["max_power_watts"], "power ceiling", minimum=0.001, maximum=1_000_000) + if bandwidth != 100.0: + raise PlaythroughError("bandwidth ceiling does not match the proven manual replay") + if value["max_power_watts"] is not None: + raise PlaythroughError("the manual CPU-host replay requires an unset power ceiling") pause = _bounded_number(value["pause_timeout"], "pause timeout", minimum=1, maximum=300) - if value["schedule"] is not None: - raise PlaythroughError("Gate 13 qualification requires an unrestricted schedule") + if pause != 120.0: + raise PlaythroughError("pause timeout does not match the proven manual replay") + if value["schedule"] != _manual_schedule(): + raise PlaythroughError("sharing schedule does not match the proven manual replay") return { "sharing_enabled": True, "allowed_models": list(allowed), @@ -141,9 +161,9 @@ def _policy(value: Any, model_id: str) -> dict[str, Any]: "max_disk_space": value["max_disk_space"], "max_vram": value["max_vram"], "max_bandwidth_mbps": bandwidth, - "max_power_watts": power, + "max_power_watts": None, "pause_timeout": pause, - "schedule": None, + "schedule": _manual_schedule(), } @@ -509,9 +529,9 @@ def _fill_policy_dialog(self) -> None: ): editor = dialog.findChild(self._qt["QLineEdit"], f"policy_{field}") value = self.plan.policy[field] - editor.setText(f"{value:g}" if isinstance(value, float) else str(value)) + editor.setText("" if value is None else f"{value:g}" if isinstance(value, float) else str(value)) schedule = dialog.findChild(self._qt["QPlainTextEdit"], "policy_schedule") - schedule.clear() + schedule.setPlainText(json.dumps(self.plan.policy["schedule"], separators=(",", ":"))) buttons = dialog.findChild(self._qt["QDialogButtonBox"], "sharingPolicyButtons") save = buttons.button(self._qt["QDialogButtonBox"].StandardButton.Save) self._state = "wait_policy" @@ -573,8 +593,9 @@ def _pass(self, *, started: bool = False, paused: bool = False) -> None: "storage": True, "memory_or_vram": True, "bandwidth": True, - "power": True, + "power": False, "pause_timeout": True, + "schedule": True, }, "privacy": { "prompt_retained": False, diff --git a/desktop/tests/test_gate13_playthrough.py b/desktop/tests/test_gate13_playthrough.py index ae98b6abf..08fd4e061 100644 --- a/desktop/tests/test_gate13_playthrough.py +++ b/desktop/tests/test_gate13_playthrough.py @@ -35,12 +35,21 @@ def _config(stage: str) -> dict: "allowed_models": [MODEL_ID], "preferred_models": [MODEL_ID], "denied_models": [], - "max_disk_space": "20GiB", - "max_vram": "8GiB", + "max_disk_space": "32GB", + "max_vram": "20GB", "max_bandwidth_mbps": 100.0, - "max_power_watts": 250.0, - "pause_timeout": 30.0, - "schedule": None, + "max_power_watts": None, + "pause_timeout": 120.0, + "schedule": { + "timezone": "UTC", + "windows": [ + { + "days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], + "start": "00:00", + "end": "23:59", + } + ], + }, }, "timeout_seconds": 30.0, "inference_timeout_seconds": 10.0, @@ -74,6 +83,7 @@ def test_plan_is_strict_and_bounded(self): plan = _write_plan(root / "plan.json", "start") self.assertEqual(plan.model_id, MODEL_ID) self.assertEqual(plan.policy["allowed_models"], [MODEL_ID]) + self.assertIsNone(plan.policy["max_power_watts"]) invalid = _config("start") invalid["policy"]["denied_models"] = [MODEL_ID] @@ -81,6 +91,12 @@ def test_plan_is_strict_and_bounded(self): with self.assertRaises(PlaythroughError): PlaythroughPlan.load(root / "invalid.json") + invalid_power = _config("start") + invalid_power["policy"]["max_power_watts"] = 250.0 + (root / "invalid-power.json").write_text(json.dumps(invalid_power), encoding="utf-8") + with self.assertRaises(PlaythroughError): + PlaythroughPlan.load(root / "invalid-power.json") + (root / "duplicate.json").write_text('{"schema_version":1,"schema_version":1}', encoding="utf-8") with self.assertRaises(PlaythroughError): PlaythroughPlan.load(root / "duplicate.json") diff --git a/docs/PACKAGED_ALPHA_OPERATIONS.md b/docs/PACKAGED_ALPHA_OPERATIONS.md index 40bf38545..cd5eb2d9e 100644 --- a/docs/PACKAGED_ALPHA_OPERATIONS.md +++ b/docs/PACKAGED_ALPHA_OPERATIONS.md @@ -98,12 +98,19 @@ its leaf must be exactly `.gate13-playthrough-`: "allowed_models": ["Qwen3.5 2B"], "preferred_models": ["Qwen3.5 2B"], "denied_models": [], - "max_disk_space": "20GiB", - "max_vram": "8GiB", + "max_disk_space": "32GB", + "max_vram": "20GB", "max_bandwidth_mbps": 100.0, - "max_power_watts": 250.0, - "pause_timeout": 30.0, - "schedule": null + "max_power_watts": null, + "pause_timeout": 120.0, + "schedule": { + "timezone": "UTC", + "windows": [{ + "days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], + "start": "00:00", + "end": "23:59" + }] + } }, "session_timeout_seconds": 3600, "inference_timeout_seconds": 600 @@ -112,6 +119,10 @@ its leaf must be exactly `.gate13-playthrough-`: Run it as the ordinary qualification user with tracing disabled: +This policy is the exact CPU-host policy proven by the manual Gate 13 playthrough: +the power field stays blank because the e2 hosts have no power telemetry, while the +storage, memory, bandwidth, pause, and explicit UTC schedule fields are exercised. + ~~~text python gate13_automated_playthrough.py --config gate13-windows-run.json > gate13-windows-evidence.json ~~~ diff --git a/scripts/gate13_automated_playthrough.py b/scripts/gate13_automated_playthrough.py index c84dd9bc1..9e160baa5 100644 --- a/scripts/gate13_automated_playthrough.py +++ b/scripts/gate13_automated_playthrough.py @@ -27,6 +27,7 @@ SCHEMA_VERSION = 1 SCOPE = "gate13-automated-desktop-replay" +POLICY_PROFILE = "gate13-manual-cpu-v1" MAX_CONFIG_BYTES = 65_536 MAX_EVIDENCE_BYTES = 65_536 @@ -80,6 +81,19 @@ } +def _manual_schedule() -> dict[str, Any]: + return { + "timezone": "UTC", + "windows": [ + { + "days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], + "start": "00:00", + "end": "23:59", + } + ], + } + + class ReplayError(ValueError): """A replay input, session, or cleanup boundary failed closed.""" @@ -204,16 +218,19 @@ def load_config(path: Path) -> ReplayConfig: or policy["allowed_models"] != [model_id] or policy["preferred_models"] != [model_id] or policy["denied_models"] != [] - or policy["schedule"] is not None + or policy["schedule"] != _manual_schedule() ): - raise ReplayError("policy does not select only the qualification model") - if not isinstance(policy["max_disk_space"], str) or not 1 <= len(policy["max_disk_space"]) <= 32: - raise ReplayError("storage ceiling is invalid") - if not isinstance(policy["max_vram"], str) or not 1 <= len(policy["max_vram"]) <= 32: - raise ReplayError("memory ceiling is invalid") - _number(policy["max_bandwidth_mbps"], "bandwidth ceiling", 0.001, 1_000_000) - _number(policy["max_power_watts"], "power ceiling", 0.001, 1_000_000) - _number(policy["pause_timeout"], "pause timeout", 1, 300) + raise ReplayError("policy does not match the proven manual replay") + if policy["max_disk_space"] != "32GB": + raise ReplayError("storage ceiling does not match the proven manual replay") + if policy["max_vram"] != "20GB": + raise ReplayError("memory ceiling does not match the proven manual replay") + if _number(policy["max_bandwidth_mbps"], "bandwidth ceiling", 0.001, 1_000_000) != 100.0: + raise ReplayError("bandwidth ceiling does not match the proven manual replay") + if policy["max_power_watts"] is not None: + raise ReplayError("the manual CPU-host replay requires an unset power ceiling") + if _number(policy["pause_timeout"], "pause timeout", 1, 300) != 120.0: + raise ReplayError("pause timeout does not match the proven manual replay") executable = _absolute_path(raw["desktop_executable"], "desktop executable") _regular_metadata(executable, 2 * 1024**3) package_archive = _absolute_path(raw["package_archive"], "package archive") @@ -321,8 +338,9 @@ def _validate_session(path: Path, config: ReplayConfig, stage: str) -> Mapping[s "storage": True, "memory_or_vram": True, "bandwidth": True, - "power": True, + "power": False, "pause_timeout": True, + "schedule": True, } expected_privacy = { "prompt_retained": False, @@ -464,6 +482,7 @@ def run_replay( "restart_resume_observed": True, "pause_clicked": True, "sharing_paused": True, + "policy_profile": POLICY_PROFILE, "session_duration_seconds": { "start": start["duration_seconds"], "resume_pause": resumed["duration_seconds"], diff --git a/scripts/gate13_packaged_lifecycle.py b/scripts/gate13_packaged_lifecycle.py index 79a3dbfd6..d6217e525 100644 --- a/scripts/gate13_packaged_lifecycle.py +++ b/scripts/gate13_packaged_lifecycle.py @@ -19,6 +19,7 @@ SCHEMA_VERSION = 1 SCOPE = "gate13-packaged-lifecycle" AUTOMATED_REPLAY_SCOPE = "gate13-automated-desktop-replay" +AUTOMATED_REPLAY_POLICY_PROFILE = "gate13-manual-cpu-v1" MAX_INPUT_BYTES = 1_048_576 MAX_COUNT = 1_000_000 MAX_BYTES = 1 << 50 @@ -92,6 +93,7 @@ "restart_resume_observed", "pause_clicked", "sharing_paused", + "policy_profile", "session_duration_seconds", "privacy_safe", "qualification_temporaries_removed", @@ -690,6 +692,7 @@ def _validate_automated_replay(raw_document: Mapping[str, Any]) -> dict[str, Any or not isinstance(document["source_commit"], str) or _HEX40_RE.fullmatch(document["source_commit"]) is None or document["model_id"] not in MODEL_PROFILES + or document["policy_profile"] != AUTOMATED_REPLAY_POLICY_PROFILE ): _fail() profile = MODEL_PROFILES[document["model_id"]] @@ -749,6 +752,7 @@ def _validate_automated_replay(raw_document: Mapping[str, Any]) -> dict[str, Any "restart_resume_observed": True, "pause_clicked": True, "sharing_paused": True, + "policy_profile": AUTOMATED_REPLAY_POLICY_PROFILE, "response_content_retained": False, "token_identifier_count": 0, }, diff --git a/tests/test_gate13_automated_playthrough.py b/tests/test_gate13_automated_playthrough.py index 050983933..7db894e00 100644 --- a/tests/test_gate13_automated_playthrough.py +++ b/tests/test_gate13_automated_playthrough.py @@ -40,12 +40,21 @@ def config_document(root: Path) -> dict: "allowed_models": [MODEL_ID], "preferred_models": [MODEL_ID], "denied_models": [], - "max_disk_space": "20GiB", - "max_vram": "8GiB", + "max_disk_space": "32GB", + "max_vram": "20GB", "max_bandwidth_mbps": 100.0, - "max_power_watts": 250.0, - "pause_timeout": 30.0, - "schedule": None, + "max_power_watts": None, + "pause_timeout": 120.0, + "schedule": { + "timezone": "UTC", + "windows": [ + { + "days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], + "start": "00:00", + "end": "23:59", + } + ], + }, }, "session_timeout_seconds": 30.0, "inference_timeout_seconds": 10.0, @@ -92,8 +101,9 @@ def session_evidence(plan: dict) -> dict: "storage": True, "memory_or_vram": True, "bandwidth": True, - "power": True, + "power": False, "pause_timeout": True, + "schedule": True, }, "privacy": { "prompt_retained": False, @@ -136,6 +146,7 @@ def runner(argv, **kwargs): assert result["real_window_sessions"] == 2 assert result["localhost_inference_count"] == 2 assert result["restart_resume_observed"] is True + assert result["policy_profile"] == replay.POLICY_PROFILE assert result["qualification_temporaries_removed"] is True assert not config.work_root.exists() @@ -148,6 +159,13 @@ def test_config_and_session_evidence_fail_closed(tmp_path): with pytest.raises(replay.ReplayError): replay.load_config(config_path) + invalid_power = config_document(tmp_path) + invalid_power["policy"]["max_power_watts"] = 250.0 + invalid_power_path = tmp_path / "invalid-power.json" + invalid_power_path.write_text(json.dumps(invalid_power), encoding="utf-8") + with pytest.raises(replay.ReplayError): + replay.load_config(invalid_power_path) + valid = config_document(tmp_path) valid_path = tmp_path / "valid.json" valid_path.write_text(json.dumps(valid), encoding="utf-8") diff --git a/tests/test_gate13_packaged_lifecycle.py b/tests/test_gate13_packaged_lifecycle.py index 49e889554..ed36f7336 100644 --- a/tests/test_gate13_packaged_lifecycle.py +++ b/tests/test_gate13_packaged_lifecycle.py @@ -505,6 +505,7 @@ def test_current_gate13_automated_replay_is_accepted_by_the_host_evidence_bounda "restart_resume_observed": True, "pause_clicked": True, "sharing_paused": True, + "policy_profile": "gate13-manual-cpu-v1", "session_duration_seconds": {"start": 100.0, "resume_pause": 80.0}, "privacy_safe": True, "qualification_temporaries_removed": True, @@ -518,6 +519,7 @@ def test_current_gate13_automated_replay_is_accepted_by_the_host_evidence_bounda assert evidence["manifest_digest"] == MANIFEST_DIGEST assert evidence["lifecycle"]["real_window_sessions"] == 2 assert evidence["lifecycle"]["restart_resume_observed"] is True + assert evidence["lifecycle"]["policy_profile"] == "gate13-manual-cpu-v1" document["pause_clicked"] = False with pytest.raises(lifecycle.LifecycleEvidenceError): diff --git a/tests/test_gate13_run_controller.py b/tests/test_gate13_run_controller.py index 8680d4128..e7bc41a60 100644 --- a/tests/test_gate13_run_controller.py +++ b/tests/test_gate13_run_controller.py @@ -514,6 +514,7 @@ def test_collect_accepts_current_automated_desktop_replay(plan): "restart_resume_observed": True, "pause_clicked": True, "sharing_paused": True, + "policy_profile": "gate13-manual-cpu-v1", "session_duration_seconds": {"start": 120.0, "resume_pause": 90.0}, "privacy_safe": True, "qualification_temporaries_removed": True, From cd3e347488c2a79399ba3dd6ce8c31a26ac78ef7 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:07:13 -0500 Subject: [PATCH 42/56] gate13: replay exact manual desktop sequences --- .../communityai_desktop/gate13_playthrough.py | 155 ++++++++--- desktop/tests/test_gate13_playthrough.py | 183 +++++++++---- scripts/gate13_automated_playthrough.py | 105 +++++--- scripts/gate13_packaged_lifecycle.py | 42 ++- scripts/gate13_route_fence.py | 254 ++++++++++++++++++ tests/test_gate13_automated_playthrough.py | 82 ++++-- tests/test_gate13_packaged_lifecycle.py | 15 +- tests/test_gate13_route_fence.py | 154 +++++++++++ tests/test_gate13_run_controller.py | 13 +- 9 files changed, 825 insertions(+), 178 deletions(-) create mode 100644 scripts/gate13_route_fence.py create mode 100644 tests/test_gate13_route_fence.py diff --git a/desktop/src/communityai_desktop/gate13_playthrough.py b/desktop/src/communityai_desktop/gate13_playthrough.py index a65236841..c9b8d27c4 100644 --- a/desktop/src/communityai_desktop/gate13_playthrough.py +++ b/desktop/src/communityai_desktop/gate13_playthrough.py @@ -1,9 +1,10 @@ """Automate the real packaged Gate 13 desktop playthrough. This module is deliberately part of the frozen desktop rather than an external UI -mock. A qualification invocation opens the normal window, uses the real sharing -policy dialog and Start/Pause buttons, and performs one bounded localhost inference -with an ephemeral client key. It retains only bounded acceptance facts. +mock. Qualification invocations reproduce the platform-specific sequence from the +accepted manual run using the normal window, real sharing-policy dialog, literal +Start/Pause buttons, and bounded localhost inference with an ephemeral client key. +It retains only bounded acceptance facts. """ from __future__ import annotations @@ -23,7 +24,7 @@ from communityai_desktop.client import normalize_loopback_url -SCHEMA_VERSION = 1 +SCHEMA_VERSION = 2 SCOPE = "gate13-packaged-desktop-playthrough" MAX_CONFIG_BYTES = 65_536 MAX_RESPONSE_BYTES = 1_048_576 @@ -62,6 +63,7 @@ def _manual_schedule() -> dict[str, Any]: _CONFIG_FIELDS = { "schema_version", "run_id", + "platform", "stage", "model_id", "manifest_digest", @@ -170,6 +172,7 @@ def _policy(value: Any, model_id: str) -> dict[str, Any]: @dataclass(frozen=True) class PlaythroughPlan: run_id: str + platform: str stage: str model_id: str manifest_digest: str @@ -192,13 +195,16 @@ def load(cls, path: Path) -> "PlaythroughPlan": if not isinstance(raw, dict) or set(raw) != _CONFIG_FIELDS or raw.get("schema_version") != SCHEMA_VERSION: raise PlaythroughError("configuration schema is invalid") run_id = raw["run_id"] + platform = raw["platform"] stage = raw["stage"] model_id = raw["model_id"] digest = raw["manifest_digest"] total_blocks = raw["total_blocks"] if not isinstance(run_id, str) or _RUN_RE.fullmatch(run_id) is None: raise PlaythroughError("run id is invalid") - if stage not in ("start", "resume_pause"): + if platform not in ("windows", "linux"): + raise PlaythroughError("playthrough platform is invalid") + if stage not in ("initial", "restart"): raise PlaythroughError("playthrough stage is invalid") if not isinstance(model_id, str) or _MODEL_RE.fullmatch(model_id) is None or model_id != model_id.strip(): raise PlaythroughError("model id is invalid") @@ -212,6 +218,7 @@ def load(cls, path: Path) -> "PlaythroughPlan": ) return cls( run_id=run_id, + platform=platform, stage=stage, model_id=model_id, manifest_digest=digest, @@ -391,12 +398,25 @@ def __init__( screenshot_path: Path | None = None, inference_runner: Callable[[Any, PlaythroughPlan], Mapping[str, Any]] = qualify_localhost_inference, clock: Callable[[], float] = time.monotonic, + start_observation_seconds: float | None = None, + restart_observation_seconds: float | None = None, ): self.plan = plan self.evidence_path = Path(evidence_path) self.screenshot_path = None if screenshot_path is None else Path(screenshot_path) self._inference_runner = inference_runner self._clock = clock + default_start_observation = 25.0 if plan.platform == "windows" else 20.0 + self._start_observation_seconds = ( + default_start_observation + if start_observation_seconds is None + else _bounded_number(start_observation_seconds, "start observation", minimum=0.05, maximum=60) + ) + self._restart_observation_seconds = ( + 15.0 + if restart_observation_seconds is None + else _bounded_number(restart_observation_seconds, "restart observation", minimum=0.05, maximum=60) + ) self._started = clock() self._state = "wait_ready" self._done = False @@ -405,6 +425,17 @@ def __init__( self._application = None self._qt: Mapping[str, Any] = {} self._timer = None + self._observation_deadline: float | None = None + self._ui = { + "real_window_opened": True, + "policy_dialog_saved": False, + "start_clicked": False, + "pause_control_observed": False, + "pause_clicked": False, + "restart_resume_observed": False, + "sharing_intent_enabled_observed": False, + "sharing_intent_disabled_observed": False, + } def install(self, window: Any, application: Any, qt: Mapping[str, Any]) -> None: self._window = window @@ -450,38 +481,55 @@ def _tick(self) -> None: if self._state == "wait_ready": if not self._ready(): return - if self.plan.stage == "start": - self._begin_inference("wait_inference_before_policy") + if self.plan.stage == "initial": + self._begin_inference("after_initial_inference") + elif self.plan.platform == "windows": + self._begin_policy_edit() else: self._state = "wait_resumed" elif self._state == "wait_policy": contribution = self._window._snapshot.get("contribution", {}) if not self._window._busy and contribution.get("policy") == self.plan.policy: + self._ui["policy_dialog_saved"] = True self._click_start() - elif self._state == "wait_started": + elif self._state == "wait_started_intent": contribution = self._window._snapshot.get("contribution", {}) - workers = self._window._snapshot.get("workers", []) - active = any(item.get("model") == self.plan.model_id and item.get("sharing_active") for item in workers) - if contribution.get("intent_enabled") and contribution.get("enabled") and active: - self._pass(started=True) + if contribution.get("intent_enabled") and self._pause_control_available(): + self._ui["sharing_intent_enabled_observed"] = True + self._ui["pause_control_observed"] = True + if self._observation_deadline is None: + self._observation_deadline = self._clock() + self._start_observation_seconds + if self._clock() >= self._observation_deadline: + if self.plan.platform == "windows": + self._click_pause() + else: + self._pass() elif self._state == "wait_resumed": contribution = self._window._snapshot.get("contribution", {}) workers = self._window._snapshot.get("workers", []) - active = any(item.get("model") == self.plan.model_id and item.get("sharing_active") for item in workers) - if contribution.get("intent_enabled") and contribution.get("enabled") and active: - self._click_pause() - elif self._state == "wait_paused": + desired = any( + item.get("model") == self.plan.model_id and item.get("desired_running") for item in workers + ) + if contribution.get("intent_enabled") and desired and self._pause_control_available(): + self._ui["sharing_intent_enabled_observed"] = True + self._ui["pause_control_observed"] = True + self._ui["restart_resume_observed"] = True + if self._observation_deadline is None: + self._observation_deadline = self._clock() + self._restart_observation_seconds + if self._clock() >= self._observation_deadline: + self._click_pause() + elif self._state == "wait_paused_intent": contribution = self._window._snapshot.get("contribution", {}) - workers = self._window._snapshot.get("workers", []) - target = [item for item in workers if item.get("model") == self.plan.model_id] if ( not self._window._busy and not contribution.get("intent_enabled") - and not contribution.get("enabled") - and target - and all(not item.get("desired_running") and not item.get("sharing_active") for item in target) + and self._start_control_available() ): - self._begin_inference("wait_inference_after_pause") + self._ui["sharing_intent_disabled_observed"] = True + if self.plan.platform == "linux": + self._begin_inference("after_restart_inference") + else: + self._pass() except BaseException: self._fail() @@ -491,10 +539,10 @@ def _begin_inference(self, waiting_state: str) -> None: def finished(result: Mapping[str, Any]) -> None: self._inference = dict(result) - if waiting_state == "wait_inference_before_policy": + if waiting_state == "after_initial_inference" and self.plan.platform == "linux": self._begin_policy_edit() else: - self._pass(paused=True) + self._pass() self._window._submit( lambda: self._inference_runner(controller, self.plan), @@ -543,22 +591,38 @@ def _click_start(self) -> None: button = self._window.master_share_button if button.text() != "Start sharing" or not button.isEnabled(): return - self._state = "wait_started" + self._state = "wait_started_intent" + self._observation_deadline = None + self._ui["start_clicked"] = True button.click() def _click_pause(self) -> None: button = self._window.master_share_button if button.text() != "Pause sharing" or not button.isEnabled(): return - self._state = "wait_paused" + self._state = "wait_paused_intent" + self._observation_deadline = None + self._ui["pause_clicked"] = True button.click() + def _pause_control_available(self) -> bool: + button = self._window.master_share_button + return bool(button.text() == "Pause sharing" and button.isEnabled()) + + def _start_control_available(self) -> bool: + button = self._window.master_share_button + # A just-paused worker may remain temporarily resource-suspended while its + # process exits, which legitimately leaves Start disabled. The manual run + # accepted the intent transition and literal control text at this boundary. + return bool(button.text() == "Start sharing") + def _base_result(self, result: str) -> dict[str, Any]: duration = max(0.0, self._clock() - self._started) return { "schema_version": SCHEMA_VERSION, "scope": SCOPE, "run_id": self.plan.run_id, + "platform": self.plan.platform, "stage": self.plan.stage, "result": result, "model_id": self.plan.model_id, @@ -566,8 +630,13 @@ def _base_result(self, result: str) -> dict[str, Any]: "duration_seconds": round(duration, 6), } - def _pass(self, *, started: bool = False, paused: bool = False) -> None: - if self._done or self._inference is None: + def _pass(self) -> None: + inference_required = (self.plan.platform, self.plan.stage) in { + ("windows", "initial"), + ("linux", "initial"), + ("linux", "restart"), + } + if self._done or (inference_required and self._inference is None): self._fail() return value = self._base_result("passed") @@ -579,23 +648,23 @@ def _pass(self, *, started: bool = False, paused: bool = False) -> None: "covered_blocks": self.plan.total_blocks, "total_blocks": self.plan.total_blocks, }, - "inference": dict(self._inference), - "ui": { - "real_window_opened": True, - "policy_dialog_saved": self.plan.stage == "start", - "start_clicked": started, - "sharing_running_observed": started, - "resumed_after_restart_observed": paused, - "pause_clicked": paused, - "sharing_paused_observed": paused, - }, + "inference": None if self._inference is None else dict(self._inference), + "ui": dict(self._ui), "limits": { - "storage": True, - "memory_or_vram": True, - "bandwidth": True, + "storage": self._ui["policy_dialog_saved"], + "memory_or_vram": self._ui["policy_dialog_saved"], + "bandwidth": self._ui["policy_dialog_saved"], "power": False, - "pause_timeout": True, - "schedule": True, + "pause_timeout": self._ui["policy_dialog_saved"], + "schedule": self._ui["policy_dialog_saved"], + }, + "timing": { + "start_observation_seconds": ( + self._start_observation_seconds if self._ui["start_clicked"] else 0.0 + ), + "restart_observation_seconds": ( + self._restart_observation_seconds if self._ui["restart_resume_observed"] else 0.0 + ), }, "privacy": { "prompt_retained": False, diff --git a/desktop/tests/test_gate13_playthrough.py b/desktop/tests/test_gate13_playthrough.py index 08fd4e061..19fd238cf 100644 --- a/desktop/tests/test_gate13_playthrough.py +++ b/desktop/tests/test_gate13_playthrough.py @@ -22,10 +22,11 @@ MANIFEST_DIGEST = "sha256:" + "b" * 64 -def _config(stage: str) -> dict: +def _config(stage: str, platform: str = "windows") -> dict: return { - "schema_version": 1, + "schema_version": 2, "run_id": "gate13-automated-test", + "platform": platform, "stage": stage, "model_id": MODEL_ID, "manifest_digest": MANIFEST_DIGEST, @@ -56,8 +57,8 @@ def _config(stage: str) -> dict: } -def _write_plan(path: Path, stage: str) -> PlaythroughPlan: - path.write_text(json.dumps(_config(stage)), encoding="utf-8") +def _write_plan(path: Path, stage: str, platform: str = "windows") -> PlaythroughPlan: + path.write_text(json.dumps(_config(stage, platform)), encoding="utf-8") return PlaythroughPlan.load(path) @@ -80,35 +81,36 @@ def test_plan_is_strict_and_bounded(self): with TemporaryDirectory() as directory: root = Path(directory) - plan = _write_plan(root / "plan.json", "start") + plan = _write_plan(root / "plan.json", "initial") self.assertEqual(plan.model_id, MODEL_ID) self.assertEqual(plan.policy["allowed_models"], [MODEL_ID]) self.assertIsNone(plan.policy["max_power_watts"]) - invalid = _config("start") + invalid = _config("initial") invalid["policy"]["denied_models"] = [MODEL_ID] (root / "invalid.json").write_text(json.dumps(invalid), encoding="utf-8") with self.assertRaises(PlaythroughError): PlaythroughPlan.load(root / "invalid.json") - invalid_power = _config("start") + invalid_power = _config("initial") invalid_power["policy"]["max_power_watts"] = 250.0 (root / "invalid-power.json").write_text(json.dumps(invalid_power), encoding="utf-8") with self.assertRaises(PlaythroughError): PlaythroughPlan.load(root / "invalid-power.json") - (root / "duplicate.json").write_text('{"schema_version":1,"schema_version":1}', encoding="utf-8") + (root / "duplicate.json").write_text('{"schema_version":2,"schema_version":2}', encoding="utf-8") with self.assertRaises(PlaythroughError): PlaythroughPlan.load(root / "duplicate.json") def test_localhost_inference_restores_key_baseline_and_retains_only_counts(self): plan = PlaythroughPlan( run_id="gate13-automated-test", - stage="start", + platform="windows", + stage="initial", model_id=MODEL_ID, manifest_digest=MANIFEST_DIGEST, total_blocks=36, - policy=_config("start")["policy"], + policy=_config("initial")["policy"], timeout_seconds=30, inference_timeout_seconds=10, ) @@ -178,7 +180,7 @@ def test_localhost_inference_requests_exactly_one_token(self): def test_hidden_packaged_cli_installs_the_qualification_automation(self): lifecycle = SimpleNamespace(close=lambda: None) - loaded_plan = SimpleNamespace(stage="start") + loaded_plan = SimpleNamespace(stage="initial") automation = SimpleNamespace() with ( patch("communityai_desktop.app.NodeLifecycleSupervisor", return_value=lifecycle), @@ -203,7 +205,77 @@ def test_hidden_packaged_cli_installs_the_qualification_automation(self): class PackagedUiPlaythroughTests(unittest.TestCase): - def test_real_window_runs_start_restart_resume_pause_sequence(self): + def test_bandwidth_suspension_and_async_worker_exit_do_not_reintroduce_manual_false_failure(self): + from tempfile import TemporaryDirectory + + class Button: + def __init__(self): + self.label = "Pause sharing" + self.enabled = True + self.clicks = 0 + + def text(self): + return self.label + + def isEnabled(self): + return self.enabled + + def click(self): + self.clicks += 1 + + with TemporaryDirectory() as directory: + root = Path(directory) + now = [10.0] + button = Button() + application = SimpleNamespace(quit=MagicMock()) + automation = Gate13Playthrough( + _write_plan(root / "windows-restart-plan.json", "restart", "windows"), + root / "windows-restart-evidence.json", + clock=lambda: now[0], + start_observation_seconds=0.05, + restart_observation_seconds=0.05, + ) + automation._application = application + automation._window = SimpleNamespace( + _busy=0, + master_share_button=button, + _snapshot={ + "contribution": {"intent_enabled": True, "enabled": False}, + "workers": [ + { + "model": MODEL_ID, + "desired_running": True, + "sharing_active": False, + "resource_admitted": False, + "resource_reason": "bandwidth usage exceeds contribution budget", + } + ], + }, + ) + automation._state = "wait_started_intent" + + automation._tick() + now[0] += 0.1 + automation._tick() + + self.assertEqual(button.clicks, 1) + self.assertEqual(automation._state, "wait_paused_intent") + button.label = "Start sharing" + button.enabled = False + automation._window._snapshot = { + "contribution": {"intent_enabled": False, "enabled": False}, + # The manual trace still counted workers after Pause. Their + # asynchronous exit is deliberately not this UI gate's boundary. + "workers": [{"model": MODEL_ID, "desired_running": False, "sharing_active": True}], + } + automation._tick() + + evidence = json.loads((root / "windows-restart-evidence.json").read_text(encoding="utf-8")) + self.assertEqual(evidence["result"], "passed") + self.assertTrue(evidence["ui"]["sharing_intent_disabled_observed"]) + application.quit.assert_called_once() + + def test_real_window_replays_manual_platform_sequences(self): os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") try: from PySide6.QtWidgets import QApplication @@ -215,47 +287,54 @@ def test_real_window_runs_start_restart_resume_pause_sequence(self): from communityai_desktop.pyside_shell import run QApplication.instance() or QApplication([]) - with TemporaryDirectory() as directory, fake_node(all_workers_paused=True) as (url, token): + with TemporaryDirectory() as directory: root = Path(directory) - controller = DesktopController(NodeClient(url, token)) - start = Gate13Playthrough( - _write_plan(root / "start-plan.json", "start"), - root / "start-evidence.json", - inference_runner=_inference, - ) - with patch("communityai_desktop.pyside_shell.login_startup_enabled", return_value=False): - self.assertEqual( - run( - controller, - single_instance=False, - qualification_automation=start, - ), - 0, - ) - start_evidence = json.loads((root / "start-evidence.json").read_text(encoding="utf-8")) - self.assertEqual(start_evidence["result"], "passed") - self.assertTrue(start_evidence["ui"]["policy_dialog_saved"]) - self.assertTrue(start_evidence["ui"]["start_clicked"]) - - resume = Gate13Playthrough( - _write_plan(root / "resume-plan.json", "resume_pause"), - root / "resume-evidence.json", - inference_runner=_inference, - ) - with patch("communityai_desktop.pyside_shell.login_startup_enabled", return_value=False): - self.assertEqual( - run( - controller, - single_instance=False, - qualification_automation=resume, - ), - 0, - ) - resume_evidence = json.loads((root / "resume-evidence.json").read_text(encoding="utf-8")) - self.assertEqual(resume_evidence["result"], "passed") - self.assertTrue(resume_evidence["ui"]["resumed_after_restart_observed"]) - self.assertTrue(resume_evidence["ui"]["pause_clicked"]) - self.assertTrue(resume_evidence["ui"]["sharing_paused_observed"]) + for platform in ("windows", "linux"): + with self.subTest(platform=platform), fake_node(all_workers_paused=True) as (url, token): + controller = DesktopController(NodeClient(url, token)) + initial = Gate13Playthrough( + _write_plan(root / f"{platform}-initial-plan.json", "initial", platform), + root / f"{platform}-initial-evidence.json", + inference_runner=_inference, + start_observation_seconds=0.05, + restart_observation_seconds=0.05, + ) + with patch("communityai_desktop.pyside_shell.login_startup_enabled", return_value=False): + self.assertEqual( + run(controller, single_instance=False, qualification_automation=initial), + 0, + ) + initial_evidence = json.loads( + (root / f"{platform}-initial-evidence.json").read_text(encoding="utf-8") + ) + self.assertEqual(initial_evidence["result"], "passed") + self.assertEqual(initial_evidence["platform"], platform) + self.assertEqual(initial_evidence["ui"]["policy_dialog_saved"], platform == "linux") + self.assertEqual(initial_evidence["ui"]["start_clicked"], platform == "linux") + + restart = Gate13Playthrough( + _write_plan(root / f"{platform}-restart-plan.json", "restart", platform), + root / f"{platform}-restart-evidence.json", + inference_runner=_inference, + start_observation_seconds=0.05, + restart_observation_seconds=0.05, + ) + with patch("communityai_desktop.pyside_shell.login_startup_enabled", return_value=False): + self.assertEqual( + run(controller, single_instance=False, qualification_automation=restart), + 0, + ) + restart_evidence = json.loads( + (root / f"{platform}-restart-evidence.json").read_text(encoding="utf-8") + ) + self.assertEqual(restart_evidence["result"], "passed") + self.assertTrue(restart_evidence["ui"]["pause_control_observed"]) + self.assertTrue(restart_evidence["ui"]["pause_clicked"]) + self.assertTrue(restart_evidence["ui"]["sharing_intent_disabled_observed"]) + self.assertEqual( + restart_evidence["ui"]["restart_resume_observed"], + platform == "linux", + ) if __name__ == "__main__": diff --git a/scripts/gate13_automated_playthrough.py b/scripts/gate13_automated_playthrough.py index 9e160baa5..6cc100b82 100644 --- a/scripts/gate13_automated_playthrough.py +++ b/scripts/gate13_automated_playthrough.py @@ -1,9 +1,11 @@ """Run the proven Gate 13 desktop playthrough without operator UI actions. Invoke this only after a production archive has been verified and unpacked on a -clean host. The frozen desktop opens its real window twice: the first session -performs inference, edits the real sharing-policy dialog, and clicks Start; the -second proves restart/resume, clicks Pause, and performs inference again. +clean host. The frozen desktop opens its real window twice and replays the exact +platform-specific chronology accepted in the manual Gate 13 run. Windows performs +default-root inference before a full restart, then saves policy, starts, observes for +25 seconds, and pauses. Linux performs inference/policy/start before the restart, +then proves persisted intent, pauses, and performs post-restart inference. The script prints one bounded aggregate record. Private per-session files live only in an exact run-scoped temporary root and are removed before success. @@ -25,9 +27,13 @@ from pathlib import Path from typing import Any, Callable, Mapping, Sequence -SCHEMA_VERSION = 1 +SCHEMA_VERSION = 2 SCOPE = "gate13-automated-desktop-replay" POLICY_PROFILE = "gate13-manual-cpu-v1" +SEQUENCE_PROFILES = { + "windows": "gate13-manual-windows-v1", + "linux": "gate13-manual-linux-v1", +} MAX_CONFIG_BYTES = 65_536 MAX_EVIDENCE_BYTES = 65_536 @@ -68,6 +74,7 @@ "schema_version", "scope", "run_id", + "platform", "stage", "result", "model_id", @@ -77,6 +84,7 @@ "inference", "ui", "limits", + "timing", "privacy", } @@ -261,6 +269,7 @@ def _session_plan(config: ReplayConfig, stage: str) -> dict[str, Any]: return { "schema_version": SCHEMA_VERSION, "run_id": config.run_id, + "platform": config.platform, "stage": stage, "model_id": config.model_id, "manifest_digest": config.manifest_digest, @@ -294,6 +303,7 @@ def _validate_session(path: Path, config: ReplayConfig, stage: str) -> Mapping[s value["schema_version"] != SCHEMA_VERSION or value["scope"] != "gate13-packaged-desktop-playthrough" or value["run_id"] != config.run_id + or value["platform"] != config.platform or value["stage"] != stage or value["result"] != "passed" or value["model_id"] != config.model_id @@ -313,34 +323,50 @@ def _validate_session(path: Path, config: ReplayConfig, stage: str) -> Mapping[s "total_blocks": config.total_blocks, }: raise ReplayError("session route evidence is invalid") - if ( - not isinstance(inference, dict) - or inference.get("passed") is not True - or inference.get("model_id") != config.model_id - or inference.get("manifest_digest") != config.manifest_digest - or inference.get("completion_count") != 1 - or inference.get("generated_token_count") != 1 - or inference.get("response_content_retained") is not False - or inference.get("token_identifiers_retained") is not False - or inference.get("temporary_key_removed") is not True - ): - raise ReplayError("session inference evidence is invalid") + inference_required = (config.platform, stage) in { + ("windows", "initial"), + ("linux", "initial"), + ("linux", "restart"), + } + if inference_required: + if ( + not isinstance(inference, dict) + or inference.get("passed") is not True + or inference.get("model_id") != config.model_id + or inference.get("manifest_digest") != config.manifest_digest + or inference.get("completion_count") != 1 + or inference.get("generated_token_count") != 1 + or inference.get("response_content_retained") is not False + or inference.get("token_identifiers_retained") is not False + or inference.get("temporary_key_removed") is not True + ): + raise ReplayError("session inference evidence is invalid") + elif inference is not None: + raise ReplayError("unexpected session inference evidence") + policy_session = (config.platform, stage) in { + ("windows", "restart"), + ("linux", "initial"), + } + start_session = policy_session + pause_session = stage == "restart" + resumed_session = config.platform == "linux" and stage == "restart" expected_ui = { "real_window_opened": True, - "policy_dialog_saved": stage == "start", - "start_clicked": stage == "start", - "sharing_running_observed": stage == "start", - "resumed_after_restart_observed": stage == "resume_pause", - "pause_clicked": stage == "resume_pause", - "sharing_paused_observed": stage == "resume_pause", + "policy_dialog_saved": policy_session, + "start_clicked": start_session, + "pause_control_observed": start_session or resumed_session, + "pause_clicked": pause_session, + "restart_resume_observed": resumed_session, + "sharing_intent_enabled_observed": start_session or resumed_session, + "sharing_intent_disabled_observed": pause_session, } expected_limits = { - "storage": True, - "memory_or_vram": True, - "bandwidth": True, + "storage": policy_session, + "memory_or_vram": policy_session, + "bandwidth": policy_session, "power": False, - "pause_timeout": True, - "schedule": True, + "pause_timeout": policy_session, + "schedule": policy_session, } expected_privacy = { "prompt_retained": False, @@ -350,7 +376,13 @@ def _validate_session(path: Path, config: ReplayConfig, stage: str) -> Mapping[s "paths_retained": False, "endpoints_retained": False, } - if ui != expected_ui or limits != expected_limits: + expected_timing = { + "start_observation_seconds": 25.0 + if config.platform == "windows" and stage == "restart" + else (20.0 if config.platform == "linux" and stage == "initial" else 0.0), + "restart_observation_seconds": 15.0 if resumed_session else 0.0, + } + if ui != expected_ui or limits != expected_limits or value["timing"] != expected_timing: raise ReplayError("session UI or limit evidence is invalid") if privacy != expected_privacy: raise ReplayError("session privacy evidence is invalid") @@ -441,8 +473,8 @@ def run_replay( start: Mapping[str, Any] | None = None resumed: Mapping[str, Any] | None = None try: - start = _run_session(config, "start", runner) - resumed = _run_session(config, "resume_pause", runner) + start = _run_session(config, "initial", runner) + resumed = _run_session(config, "restart", runner) finally: try: resolved = config.work_root.resolve(strict=True) @@ -476,16 +508,19 @@ def run_replay( "model_id": config.model_id, "manifest_digest": config.manifest_digest, "real_window_sessions": 2, - "localhost_inference_count": 2, + "localhost_inference_count": 1 if config.platform == "windows" else 2, "policy_dialog_saved": True, "start_clicked": True, - "restart_resume_observed": True, + "pause_control_observed": True, + "restart_resume_observed": config.platform == "linux", "pause_clicked": True, - "sharing_paused": True, + "sharing_intent_paused": True, "policy_profile": POLICY_PROFILE, + "sequence_profile": SEQUENCE_PROFILES[config.platform], + "start_observation_seconds": 25.0 if config.platform == "windows" else 20.0, "session_duration_seconds": { - "start": start["duration_seconds"], - "resume_pause": resumed["duration_seconds"], + "initial": start["duration_seconds"], + "restart": resumed["duration_seconds"], }, "privacy_safe": True, "qualification_temporaries_removed": True, diff --git a/scripts/gate13_packaged_lifecycle.py b/scripts/gate13_packaged_lifecycle.py index d6217e525..1e27d0716 100644 --- a/scripts/gate13_packaged_lifecycle.py +++ b/scripts/gate13_packaged_lifecycle.py @@ -19,7 +19,12 @@ SCHEMA_VERSION = 1 SCOPE = "gate13-packaged-lifecycle" AUTOMATED_REPLAY_SCOPE = "gate13-automated-desktop-replay" +AUTOMATED_REPLAY_SCHEMA_VERSION = 2 AUTOMATED_REPLAY_POLICY_PROFILE = "gate13-manual-cpu-v1" +AUTOMATED_REPLAY_SEQUENCE_PROFILES = { + "windows": "gate13-manual-windows-v1", + "linux": "gate13-manual-linux-v1", +} MAX_INPUT_BYTES = 1_048_576 MAX_COUNT = 1_000_000 MAX_BYTES = 1 << 50 @@ -90,10 +95,13 @@ "localhost_inference_count", "policy_dialog_saved", "start_clicked", + "pause_control_observed", "restart_resume_observed", "pause_clicked", - "sharing_paused", + "sharing_intent_paused", "policy_profile", + "sequence_profile", + "start_observation_seconds", "session_duration_seconds", "privacy_safe", "qualification_temporaries_removed", @@ -683,7 +691,7 @@ def _validate_automated_replay(raw_document: Mapping[str, Any]) -> dict[str, Any document = dict(_mapping(raw_document)) _exact_fields(document, _AUTOMATED_REPLAY_FIELDS) if ( - document["schema_version"] != SCHEMA_VERSION + document["schema_version"] != AUTOMATED_REPLAY_SCHEMA_VERSION or document["scope"] != AUTOMATED_REPLAY_SCOPE or document["result"] != "passed" or not isinstance(document["run_id"], str) @@ -693,6 +701,7 @@ def _validate_automated_replay(raw_document: Mapping[str, Any]) -> dict[str, Any or _HEX40_RE.fullmatch(document["source_commit"]) is None or document["model_id"] not in MODEL_PROFILES or document["policy_profile"] != AUTOMATED_REPLAY_POLICY_PROFILE + or document["sequence_profile"] != AUTOMATED_REPLAY_SEQUENCE_PROFILES.get(document["platform"]) ): _fail() profile = MODEL_PROFILES[document["model_id"]] @@ -710,26 +719,36 @@ def _validate_automated_replay(raw_document: Mapping[str, Any]) -> dict[str, Any or package["self_test_count"] != 4 ): _fail() - if document["real_window_sessions"] != 2 or document["localhost_inference_count"] != 2: + expected_inferences = 1 if document["platform"] == "windows" else 2 + expected_resume = document["platform"] == "linux" + expected_start_observation = 25.0 if document["platform"] == "windows" else 20.0 + if ( + document["real_window_sessions"] != 2 + or document["localhost_inference_count"] != expected_inferences + or type(document["restart_resume_observed"]) is not bool + or document["restart_resume_observed"] is not expected_resume + or type(document["start_observation_seconds"]) not in (int, float) + or float(document["start_observation_seconds"]) != expected_start_observation + ): _fail() for field in ( "policy_dialog_saved", "start_clicked", - "restart_resume_observed", + "pause_control_observed", "pause_clicked", - "sharing_paused", + "sharing_intent_paused", "privacy_safe", "qualification_temporaries_removed", ): if document[field] is not True: _fail() durations = _mapping(document["session_duration_seconds"]) - _exact_fields(durations, {"start", "resume_pause"}) + _exact_fields(durations, {"initial", "restart"}) for value in durations.values(): if type(value) not in (int, float) or not math.isfinite(float(value)) or not 0 <= float(value) <= 3_630: _fail() return { - "schema_version": SCHEMA_VERSION, + "schema_version": AUTOMATED_REPLAY_SCHEMA_VERSION, "scope": AUTOMATED_REPLAY_SCOPE, "result": "passed", "run_id": document["run_id"], @@ -746,13 +765,16 @@ def _validate_automated_replay(raw_document: Mapping[str, Any]) -> dict[str, Any }, "lifecycle": { "real_window_sessions": 2, - "localhost_inference_count": 2, + "localhost_inference_count": expected_inferences, "policy_dialog_saved": True, "start_clicked": True, - "restart_resume_observed": True, + "pause_control_observed": True, + "restart_resume_observed": expected_resume, "pause_clicked": True, - "sharing_paused": True, + "sharing_intent_paused": True, "policy_profile": AUTOMATED_REPLAY_POLICY_PROFILE, + "sequence_profile": AUTOMATED_REPLAY_SEQUENCE_PROFILES[document["platform"]], + "start_observation_seconds": expected_start_observation, "response_content_retained": False, "token_identifier_count": 0, }, diff --git a/scripts/gate13_route_fence.py b/scripts/gate13_route_fence.py new file mode 100644 index 000000000..d1641144c --- /dev/null +++ b/scripts/gate13_route_fence.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""Fence the Gate 13 product route to one exact client model. + +Run this as root on the already-qualified route VM immediately before each client. +It stops the other product service, restarts the requested service so its DHT +advertisement is fresh, and verifies the exact local product view twice. Only +bounded route facts are emitted; credentials, endpoints, paths, and model outputs +never leave the process. +""" + +from __future__ import annotations + +import argparse +import json +import os +import stat +import subprocess +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Mapping, Sequence +from urllib.error import HTTPError, URLError +from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener + +SCHEMA_VERSION = 1 +SCOPE = "gate13-route-client-fence" +MAX_RESPONSE_BYTES = 1_048_576 +MAX_SECRET_BYTES = 512 + + +@dataclass(frozen=True) +class Profile: + target: str + service: str + other_service: str + origin: str + local_key: Path + control_key: Path + model_id: str + manifest_digest: str + total_blocks: int + + +PROFILES = { + "windows": Profile( + target="windows", + service="communityai-qwen.service", + other_service="communityai-gemma.service", + origin="http://127.0.0.1:8081", + local_key=Path("/srv/communityai/qwen/local-api.key"), + control_key=Path("/srv/communityai/qwen/control-api.key"), + model_id="Qwen3.5 2B", + manifest_digest="sha256:3ba8528cb3c0d85e1ed048e0438a0d64cfbbc298944ed674caa6950d415f8e33", + total_blocks=24, + ), + "linux": Profile( + target="linux", + service="communityai-gemma.service", + other_service="communityai-qwen.service", + origin="http://127.0.0.1:8082", + local_key=Path("/srv/communityai/gemma/local-api.key"), + control_key=Path("/srv/communityai/gemma/control-api.key"), + model_id="Gemma 4 E2B IT", + manifest_digest="sha256:2f8debbe0fcdf5af8d4c56c982210fa50aa584314968ae2617e2ccc2de9eafdd", + total_blocks=35, + ), +} + + +class FenceError(RuntimeError): + """The exact route service could not be made stable for one client.""" + + +class _RejectRedirects(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001, ARG002 + return None + + +def _secret(path: Path) -> str: + try: + metadata = path.lstat() + except OSError as exc: + raise FenceError("route credential is unavailable") from exc + if path.is_symlink() or not stat.S_ISREG(metadata.st_mode) or not 1 <= metadata.st_size <= MAX_SECRET_BYTES: + raise FenceError("route credential is unsafe") + try: + value = path.read_text(encoding="ascii").strip() + except (OSError, UnicodeError) as exc: + raise FenceError("route credential is unreadable") from exc + if not value or any(character.isspace() for character in value): + raise FenceError("route credential is invalid") + return value + + +def _request_json(opener: Any, url: str, secret: str) -> Mapping[str, Any]: + request = Request(url, headers={"Authorization": f"Bearer {secret}", "Accept": "application/json"}) + try: + with opener.open(request, timeout=10) as response: + if response.status != 200 or response.headers.get_content_type() != "application/json": + raise FenceError("route API rejected the readiness probe") + payload = response.read(MAX_RESPONSE_BYTES + 1) + except (HTTPError, URLError, OSError, TimeoutError) as exc: + raise FenceError("route API is unavailable") from exc + if not 1 <= len(payload) <= MAX_RESPONSE_BYTES: + raise FenceError("route API response is invalid") + try: + document = json.loads(payload.decode("utf-8")) + except (UnicodeError, json.JSONDecodeError) as exc: + raise FenceError("route API response is invalid") from exc + if not isinstance(document, dict): + raise FenceError("route API response is invalid") + return document + + +def _systemctl( + arguments: Sequence[str], + runner: Callable[..., subprocess.CompletedProcess] = subprocess.run, +) -> None: + try: + result = runner( + ["/usr/bin/systemctl", *arguments], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + timeout=60, + close_fds=True, + ) + except (OSError, subprocess.SubprocessError) as exc: + raise FenceError("route service action failed") from exc + if result.returncode != 0: + raise FenceError("route service action failed") + + +def _snapshot(profile: Profile, opener: Any) -> bool: + local_secret = _secret(profile.local_key) + control_secret = _secret(profile.control_key) + models = _request_json(opener, f"{profile.origin}/v1/models", local_secret) + status = _request_json(opener, f"{profile.origin}/control/v1/status", control_secret) + local_secret = control_secret = "" + data = models.get("data") + if not isinstance(data, list): + return False + model = next((item for item in data if isinstance(item, dict) and item.get("id") == profile.model_id), None) + selection = status.get("auto_selection") + if not isinstance(model, dict) or not isinstance(selection, dict): + return False + return bool( + model.get("availability") == "complete" + and model.get("covered_blocks") == profile.total_blocks + and model.get("total_blocks") == profile.total_blocks + and isinstance(model.get("peer_count"), int) + and model["peer_count"] > 0 + and selection.get("status") == "selected" + and selection.get("model") == profile.model_id + and selection.get("manifest_digest") == profile.manifest_digest + and selection.get("covered_blocks") == profile.total_blocks + and selection.get("total_blocks") == profile.total_blocks + and isinstance(selection.get("peer_count"), int) + and selection["peer_count"] > 0 + ) + + +def fence_route( + profile: Profile, + *, + timeout_seconds: float, + settle_seconds: float, + runner: Callable[..., subprocess.CompletedProcess] = subprocess.run, + opener: Any = None, + clock: Callable[[], float] = time.monotonic, + sleeper: Callable[[float], None] = time.sleep, +) -> Mapping[str, Any]: + """Run the fence and require systemd to report the standby as inactive.""" + + if opener is None: + opener = build_opener(ProxyHandler({}), _RejectRedirects()) + _systemctl(("stop", profile.other_service), runner) + _systemctl(("restart", profile.service), runner) + deadline = clock() + timeout_seconds + while clock() < deadline: + try: + _systemctl(("is-active", "--quiet", profile.service), runner) + if _snapshot(profile, opener): + break + except FenceError: + pass + sleeper(5.0) + else: + raise FenceError("route did not become ready before the deadline") + sleeper(settle_seconds) + _systemctl(("is-active", "--quiet", profile.service), runner) + try: + result = runner( + ["/usr/bin/systemctl", "is-active", "--quiet", profile.other_service], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + timeout=60, + close_fds=True, + ) + except (OSError, subprocess.SubprocessError) as exc: + raise FenceError("standby route state is unavailable") from exc + if result.returncode == 0 or not _snapshot(profile, opener): + raise FenceError("route fence did not remain stable") + return { + "schema_version": SCHEMA_VERSION, + "scope": SCOPE, + "result": "passed", + "target": profile.target, + "model_id": profile.model_id, + "manifest_digest": profile.manifest_digest, + "covered_blocks": profile.total_blocks, + "total_blocks": profile.total_blocks, + "peer_count_minimum": 1, + "target_service_restarted": True, + "standby_service_stopped": True, + "stable_rechecks": 2, + "settle_seconds": settle_seconds, + "privacy_safe": True, + } + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Fence the Gate 13 route for one exact client") + parser.add_argument("--target", choices=tuple(PROFILES), required=True) + parser.add_argument("--timeout-seconds", type=float, default=900.0) + parser.add_argument("--settle-seconds", type=float, default=30.0) + args = parser.parse_args(argv) + try: + if hasattr(os, "geteuid") and os.geteuid() != 0: + raise FenceError("route fence requires root") + if not 30 <= args.timeout_seconds <= 1_800 or not 5 <= args.settle_seconds <= 120: + raise FenceError("route fence bounds are invalid") + result = fence_route( + PROFILES[args.target], + timeout_seconds=args.timeout_seconds, + settle_seconds=args.settle_seconds, + ) + except BaseException: + result = { + "schema_version": SCHEMA_VERSION, + "scope": SCOPE, + "result": "failed", + "failure_code": "route_fence_failed", + } + print(json.dumps(result, sort_keys=True, separators=(",", ":"))) + return 0 if result.get("result") == "passed" else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_gate13_automated_playthrough.py b/tests/test_gate13_automated_playthrough.py index 7db894e00..09bb79642 100644 --- a/tests/test_gate13_automated_playthrough.py +++ b/tests/test_gate13_automated_playthrough.py @@ -17,15 +17,15 @@ DIGEST = "sha256:" + "a" * 64 -def config_document(root: Path) -> dict: - executable = root / "CommunityAI.exe" +def config_document(root: Path, platform: str = "windows") -> dict: + executable = root / ("CommunityAI.exe" if platform == "windows" else "CommunityAI") executable.write_bytes(b"packaged-desktop") - archive = root / "communityai-desktop-windows.zip" + archive = root / f"communityai-desktop-{platform}.zip" archive.write_bytes(b"verified-production-archive") return { - "schema_version": 1, + "schema_version": 2, "run_id": "gate13-automated-a", - "platform": "windows", + "platform": platform, "source_commit": "1" * 40, "package_archive": str(archive.resolve()), "package_sha256": "sha256:" + hashlib.sha256(archive.read_bytes()).hexdigest(), @@ -63,10 +63,23 @@ def config_document(root: Path) -> dict: def session_evidence(plan: dict) -> dict: stage = plan["stage"] + platform = plan["platform"] + inference_required = (platform, stage) in { + ("windows", "initial"), + ("linux", "initial"), + ("linux", "restart"), + } + policy_session = (platform, stage) in { + ("windows", "restart"), + ("linux", "initial"), + } + resumed_session = platform == "linux" and stage == "restart" + pause_session = stage == "restart" return { - "schema_version": 1, + "schema_version": 2, "scope": "gate13-packaged-desktop-playthrough", "run_id": plan["run_id"], + "platform": platform, "stage": stage, "result": "passed", "model_id": plan["model_id"], @@ -87,23 +100,32 @@ def session_evidence(plan: dict) -> dict: "response_content_retained": False, "token_identifiers_retained": False, "temporary_key_removed": True, - }, + } + if inference_required + else None, "ui": { "real_window_opened": True, - "policy_dialog_saved": stage == "start", - "start_clicked": stage == "start", - "sharing_running_observed": stage == "start", - "resumed_after_restart_observed": stage == "resume_pause", - "pause_clicked": stage == "resume_pause", - "sharing_paused_observed": stage == "resume_pause", + "policy_dialog_saved": policy_session, + "start_clicked": policy_session, + "pause_control_observed": policy_session or resumed_session, + "pause_clicked": pause_session, + "restart_resume_observed": resumed_session, + "sharing_intent_enabled_observed": policy_session or resumed_session, + "sharing_intent_disabled_observed": pause_session, }, "limits": { - "storage": True, - "memory_or_vram": True, - "bandwidth": True, + "storage": policy_session, + "memory_or_vram": policy_session, + "bandwidth": policy_session, "power": False, - "pause_timeout": True, - "schedule": True, + "pause_timeout": policy_session, + "schedule": policy_session, + }, + "timing": { + "start_observation_seconds": 25.0 + if platform == "windows" and stage == "restart" + else (20.0 if platform == "linux" and stage == "initial" else 0.0), + "restart_observation_seconds": 15.0 if resumed_session else 0.0, }, "privacy": { "prompt_retained": False, @@ -116,8 +138,11 @@ def session_evidence(plan: dict) -> dict: } -def test_replay_runs_real_desktop_contract_twice_and_removes_temporaries(tmp_path): - document = config_document(tmp_path) +@pytest.mark.parametrize("platform,expected_inferences,expected_resume", [("windows", 1, False), ("linux", 2, True)]) +def test_replay_runs_real_desktop_contract_twice_and_removes_temporaries( + tmp_path, platform, expected_inferences, expected_resume +): + document = config_document(tmp_path, platform) config_path = tmp_path / "config.json" config_path.write_text(json.dumps(document), encoding="utf-8") config = replay.load_config(config_path) @@ -140,12 +165,15 @@ def runner(argv, **kwargs): result = replay.run_replay(config, runner=runner) - assert stages == ["start", "resume_pause"] + assert stages == ["initial", "restart"] assert self_tests == ["--check-runtime", "--self-test", "--ui-self-test", "--onboarding-ui-self-test"] assert result["result"] == "passed" assert result["real_window_sessions"] == 2 - assert result["localhost_inference_count"] == 2 - assert result["restart_resume_observed"] is True + assert result["localhost_inference_count"] == expected_inferences + assert result["restart_resume_observed"] is expected_resume + assert result["pause_control_observed"] is True + assert result["sharing_intent_paused"] is True + assert result["sequence_profile"] == replay.SEQUENCE_PROFILES[platform] assert result["policy_profile"] == replay.POLICY_PROFILE assert result["qualification_temporaries_removed"] is True assert not config.work_root.exists() @@ -171,15 +199,15 @@ def test_config_and_session_evidence_fail_closed(tmp_path): valid_path.write_text(json.dumps(valid), encoding="utf-8") config = replay.load_config(valid_path) config.work_root.mkdir() - evidence = session_evidence({**valid, "stage": "start"}) + evidence = session_evidence({**valid, "stage": "restart"}) evidence["ui"]["start_clicked"] = False evidence_path = config.work_root / "evidence.json" evidence_path.write_text(json.dumps(evidence), encoding="utf-8") with pytest.raises(replay.ReplayError): - replay._validate_session(evidence_path, config, "start") + replay._validate_session(evidence_path, config, "restart") - evidence = session_evidence({**valid, "stage": "start"}) + evidence = session_evidence({**valid, "stage": "initial"}) evidence["inference"]["generated_token_count"] = 2 evidence_path.write_text(json.dumps(evidence), encoding="utf-8") with pytest.raises(replay.ReplayError): - replay._validate_session(evidence_path, config, "start") + replay._validate_session(evidence_path, config, "initial") diff --git a/tests/test_gate13_packaged_lifecycle.py b/tests/test_gate13_packaged_lifecycle.py index ed36f7336..86fefcc7f 100644 --- a/tests/test_gate13_packaged_lifecycle.py +++ b/tests/test_gate13_packaged_lifecycle.py @@ -484,7 +484,7 @@ def test_public_summary_does_not_retain_raw_phase_only_fields(): def test_current_gate13_automated_replay_is_accepted_by_the_host_evidence_boundary(): document = { - "schema_version": 1, + "schema_version": 2, "scope": "gate13-automated-desktop-replay", "run_id": "gate13-automated-a", "platform": "windows", @@ -499,14 +499,17 @@ def test_current_gate13_automated_replay_is_accepted_by_the_host_evidence_bounda "model_id": "Qwen3.5 2B", "manifest_digest": "sha256:" + MANIFEST_DIGEST, "real_window_sessions": 2, - "localhost_inference_count": 2, + "localhost_inference_count": 1, "policy_dialog_saved": True, "start_clicked": True, - "restart_resume_observed": True, + "pause_control_observed": True, + "restart_resume_observed": False, "pause_clicked": True, - "sharing_paused": True, + "sharing_intent_paused": True, "policy_profile": "gate13-manual-cpu-v1", - "session_duration_seconds": {"start": 100.0, "resume_pause": 80.0}, + "sequence_profile": "gate13-manual-windows-v1", + "start_observation_seconds": 25.0, + "session_duration_seconds": {"initial": 100.0, "restart": 80.0}, "privacy_safe": True, "qualification_temporaries_removed": True, } @@ -518,7 +521,7 @@ def test_current_gate13_automated_replay_is_accepted_by_the_host_evidence_bounda assert evidence["package_sha256"] == PACKAGE_DIGEST assert evidence["manifest_digest"] == MANIFEST_DIGEST assert evidence["lifecycle"]["real_window_sessions"] == 2 - assert evidence["lifecycle"]["restart_resume_observed"] is True + assert evidence["lifecycle"]["restart_resume_observed"] is False assert evidence["lifecycle"]["policy_profile"] == "gate13-manual-cpu-v1" document["pause_clicked"] = False diff --git a/tests/test_gate13_route_fence.py b/tests/test_gate13_route_fence.py new file mode 100644 index 000000000..d3f69f0e5 --- /dev/null +++ b/tests/test_gate13_route_fence.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import gate13_route_fence as fence + + +class Response: + status = 200 + + class Headers: + @staticmethod + def get_content_type(): + return "application/json" + + headers = Headers() + + def __init__(self, document): + self.payload = json.dumps(document).encode() + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self, _maximum): + return self.payload + + +def profile(tmp_path: Path) -> fence.Profile: + local = tmp_path / "local-api.key" + control = tmp_path / "control-api.key" + local.write_text("local-secret\n", encoding="ascii") + control.write_text("control-secret\n", encoding="ascii") + return fence.Profile( + target="windows", + service="communityai-qwen.service", + other_service="communityai-gemma.service", + origin="http://127.0.0.1:8081", + local_key=local, + control_key=control, + model_id="Qwen3.5 2B", + manifest_digest="sha256:" + "a" * 64, + total_blocks=24, + ) + + +def ready_opener(item: fence.Profile): + models = { + "data": [ + { + "id": item.model_id, + "availability": "complete", + "covered_blocks": item.total_blocks, + "total_blocks": item.total_blocks, + "peer_count": 1, + } + ] + } + status = { + "auto_selection": { + "status": "selected", + "model": item.model_id, + "manifest_digest": item.manifest_digest, + "covered_blocks": item.total_blocks, + "total_blocks": item.total_blocks, + "peer_count": 1, + } + } + opener = MagicMock() + opener.open.side_effect = [Response(models), Response(status), Response(models), Response(status)] + return opener + + +def test_fence_restarts_only_target_and_rechecks_exact_route_after_settle(tmp_path): + item = profile(tmp_path) + calls = [] + + def runner(argv, **kwargs): + assert kwargs["stdin"] is subprocess.DEVNULL + assert kwargs["stdout"] is subprocess.DEVNULL + assert kwargs["stderr"] is subprocess.DEVNULL + calls.append(tuple(argv[1:])) + inactive_probe = argv[1:3] == ["is-active", "--quiet"] and argv[3] == item.other_service + return subprocess.CompletedProcess(argv, 3 if inactive_probe else 0) + + sleeps = [] + result = fence.fence_route( + item, + timeout_seconds=60, + settle_seconds=30, + runner=runner, + opener=ready_opener(item), + sleeper=sleeps.append, + ) + + assert calls[:2] == [("stop", item.other_service), ("restart", item.service)] + assert ("is-active", "--quiet", item.other_service) in calls + assert sleeps == [30] + assert result == { + "schema_version": 1, + "scope": "gate13-route-client-fence", + "result": "passed", + "target": "windows", + "model_id": item.model_id, + "manifest_digest": item.manifest_digest, + "covered_blocks": 24, + "total_blocks": 24, + "peer_count_minimum": 1, + "target_service_restarted": True, + "standby_service_stopped": True, + "stable_rechecks": 2, + "settle_seconds": 30, + "privacy_safe": True, + } + + +def test_fence_fails_if_standby_is_still_active(tmp_path): + item = profile(tmp_path) + + def runner(argv, **_kwargs): + return subprocess.CompletedProcess(argv, 0) + + with pytest.raises(fence.FenceError, match="remain stable"): + fence.fence_route( + item, + timeout_seconds=60, + settle_seconds=30, + runner=runner, + opener=ready_opener(item), + sleeper=lambda _seconds: None, + ) + + +def test_secret_rejects_links(tmp_path): + target = tmp_path / "target" + target.write_text("secret", encoding="ascii") + link = tmp_path / "link" + try: + link.symlink_to(target) + except OSError as exc: + pytest.skip(f"symlinks unavailable: {exc}") + with pytest.raises(fence.FenceError, match="unsafe"): + fence._secret(link) diff --git a/tests/test_gate13_run_controller.py b/tests/test_gate13_run_controller.py index e7bc41a60..d80d52119 100644 --- a/tests/test_gate13_run_controller.py +++ b/tests/test_gate13_run_controller.py @@ -493,7 +493,7 @@ def test_collect_accepts_current_automated_desktop_replay(plan): now_unix=NOW, ) evidence = { - "schema_version": 1, + "schema_version": 2, "scope": "gate13-automated-desktop-replay", "run_id": f"{plan.run_id}-windows", "platform": "windows", @@ -508,14 +508,17 @@ def test_collect_accepts_current_automated_desktop_replay(plan): "model_id": "Qwen3.5 2B", "manifest_digest": "sha256:" + plan.qwen_manifest.removeprefix("sha256:"), "real_window_sessions": 2, - "localhost_inference_count": 2, + "localhost_inference_count": 1, "policy_dialog_saved": True, "start_clicked": True, - "restart_resume_observed": True, + "pause_control_observed": True, + "restart_resume_observed": False, "pause_clicked": True, - "sharing_paused": True, + "sharing_intent_paused": True, "policy_profile": "gate13-manual-cpu-v1", - "session_duration_seconds": {"start": 120.0, "resume_pause": 90.0}, + "sequence_profile": "gate13-manual-windows-v1", + "start_observation_seconds": 25.0, + "session_duration_seconds": {"initial": 120.0, "restart": 90.0}, "privacy_safe": True, "qualification_temporaries_removed": True, } From 238122692655c083d534f2a8359635f6588931e7 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:37:27 -0500 Subject: [PATCH 43/56] gate13: match route fence to live API schema --- scripts/gate13_route_fence.py | 5 +---- tests/test_gate13_route_fence.py | 23 ++++++++++++++++++++--- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/scripts/gate13_route_fence.py b/scripts/gate13_route_fence.py index d1641144c..bdfc7882f 100644 --- a/scripts/gate13_route_fence.py +++ b/scripts/gate13_route_fence.py @@ -148,10 +148,7 @@ def _snapshot(profile: Profile, opener: Any) -> bool: return False return bool( model.get("availability") == "complete" - and model.get("covered_blocks") == profile.total_blocks - and model.get("total_blocks") == profile.total_blocks - and isinstance(model.get("peer_count"), int) - and model["peer_count"] > 0 + and model.get("manifest_digest") == profile.manifest_digest and selection.get("status") == "selected" and selection.get("model") == profile.model_id and selection.get("manifest_digest") == profile.manifest_digest diff --git a/tests/test_gate13_route_fence.py b/tests/test_gate13_route_fence.py index d3f69f0e5..e12381183 100644 --- a/tests/test_gate13_route_fence.py +++ b/tests/test_gate13_route_fence.py @@ -61,9 +61,7 @@ def ready_opener(item: fence.Profile): { "id": item.model_id, "availability": "complete", - "covered_blocks": item.total_blocks, - "total_blocks": item.total_blocks, - "peer_count": 1, + "manifest_digest": item.manifest_digest, } ] } @@ -142,6 +140,25 @@ def runner(argv, **_kwargs): ) +def test_snapshot_rejects_wrong_model_manifest_even_when_control_coverage_is_complete(tmp_path): + item = profile(tmp_path) + opener = ready_opener(item) + wrong_manifest_models = { + "data": [ + { + "id": item.model_id, + "availability": "complete", + "manifest_digest": "sha256:" + "b" * 64, + } + ] + } + responses = list(opener.open.side_effect) + responses[0] = Response(wrong_manifest_models) + opener.open.side_effect = responses + + assert fence._snapshot(item, opener) is False + + def test_secret_rejects_links(tmp_path): target = tmp_path / "target" target.write_text("secret", encoding="ascii") From 9258ef29384e988415bc12fa6f9165a2c9cf8f35 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:41:41 -0500 Subject: [PATCH 44/56] gate13: bind exact paid replay inputs --- .../gate13-20260901-a-cost-authorization.json | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/docs/evidence/gate13-20260901-a-cost-authorization.json b/docs/evidence/gate13-20260901-a-cost-authorization.json index 75d31a818..9e2d7f9aa 100644 --- a/docs/evidence/gate13-20260901-a-cost-authorization.json +++ b/docs/evidence/gate13-20260901-a-cost-authorization.json @@ -8,11 +8,11 @@ "source": { "durable_controller_commit": "905cabd13c481adff4a5a7de850ceb6ee839a25b", "host_job_adapter_commit": "d5dc3537eb4a1e405ed9a6bfde0236bda0a58d7f", - "client_session_bootstrap_commit": "e60c3577c7205ff434cad6e9396f89555626aceb", + "client_session_bootstrap_commit": "cd3e347488c2a79399ba3dd6ce8c31a26ac78ef7", "route_runtime_commit": "f64a388a47b098ac7f69d2affc59816376b43bb1", "route_setup_commit": "905cabd13c481adff4a5a7de850ceb6ee839a25b", - "package_commit": "e60c3577c7205ff434cad6e9396f89555626aceb", - "production_workflow_run": 33566978488 + "package_commit": "cd3e347488c2a79399ba3dd6ce8c31a26ac78ef7", + "production_workflow_run": 33582031380 }, "authorization": { "owner_reset_recorded": true, @@ -55,15 +55,21 @@ }, "lifecycle_evidence_validator": { "path": "scripts/gate13_packaged_lifecycle.py", - "source_commit": "1476d67f3887dfd0de2acfb1305cbcca9975614f", - "sha256": "a32e1cfcb61e84ccacc13b16a5bf016b3357584edf2b6b536c2122a8e1c2abab", - "bytes": 31881 + "source_commit": "cd3e347488c2a79399ba3dd6ce8c31a26ac78ef7", + "sha256": "c899ffb162aef49e7dc54c1e62a86505713652e341bd32f77f123e76bde8d1d4", + "bytes": 33278 }, "automated_playthrough": { "path": "scripts/gate13_automated_playthrough.py", - "source_commit": "e60c3577c7205ff434cad6e9396f89555626aceb", - "sha256": "b9633c1580d5f0b64c3407ed9b7e272390e60faaa15c6f3eb09df4465864d60a", - "bytes": 18259 + "source_commit": "cd3e347488c2a79399ba3dd6ce8c31a26ac78ef7", + "sha256": "9ffda923a37ef64631898ad82139457a329393f15b78afd45709611f7f4a087f", + "bytes": 20545 + }, + "route_client_fence": { + "path": "scripts/gate13_route_fence.py", + "source_commit": "238122692655c083d534f2a8359635f6588931e7", + "sha256": "6d42e80a30aaacd3f7b80c89be15af435ab6427b1c62a338cadf11ac32237772", + "bytes": 9367 }, "route_setup": { "path": "scripts/gate13_route_setup.sh", @@ -79,19 +85,19 @@ "model_artifacts_embedded": false }, "windows_package": { - "sha256": "fb65dfc5f9d797761d3ba345e2338813660408fc171ceba338f0f2704414f70d", - "bytes": 2695083337, + "sha256": "127ea96d5eafa908aa6221e11e86af1c05e4183e5cef05696a0a58ea381ebbc0", + "bytes": 2695083895, "workflow_artifact": "communityai-desktop-install-windows" }, "linux_package": { - "sha256": "0afd773d44179477b34fffc6a89fc093b4dbd8ba7d19a063ae58700a4587235b", - "bytes": 3360734113, + "sha256": "9791ffa6d3cfa86ef8aabdec518918cef65a961eb69439880306880b741cfe20", + "bytes": 3360741913, "workflow_artifact": "communityai-desktop-install-linux" }, "qwen_manifest": "sha256:3ba8528cb3c0d85e1ed048e0438a0d64cfbbc298944ed674caa6950d415f8e33", "gemma_manifest": "sha256:2f8debbe0fcdf5af8d4c56c982210fa50aa584314968ae2617e2ccc2de9eafdd" }, - "provider_plan_digest": "sha256:6687b9ba098b3f6676f48f4bf03ebb92bdc6a1278bf5bc1c227819b3a3e7cbb0", + "provider_plan_digest": "sha256:3fd1e3907d431222a876fa192e8dbc08611dfcfad46cd5c865110eccf01fe547", "provider_plan": { "project": "community-ai-506321", "route": { @@ -158,6 +164,7 @@ "route_live_for_both_lifecycles": true, "clients_may_run_concurrently": false, "route_accepted_before_any_client_create": true, + "route_fenced_and_revalidated_for_each_client": true, "windows_collected_and_deleted_before_linux_create": true, "all_16_phases_required_per_platform": false, "automated_gate13_replay_required": true, From 5b0453d112b19d235d0a03513aeb64992dc27f47 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:17:19 -0500 Subject: [PATCH 45/56] gate13: make Windows bootstrap membership idempotent --- scripts/gate13_windows_client_startup.ps1 | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/gate13_windows_client_startup.ps1 b/scripts/gate13_windows_client_startup.ps1 index c57ba60c8..a3be52f1f 100644 --- a/scripts/gate13_windows_client_startup.ps1 +++ b/scripts/gate13_windows_client_startup.ps1 @@ -48,11 +48,13 @@ if (-not (Get-LocalUser -Name "M" -ErrorAction SilentlyContinue)) { if (-not (Get-LocalUser -Name "Gate13Admin" -ErrorAction SilentlyContinue)) { New-LocalUser -Name "Gate13Admin" -NoPassword -AccountNeverExpires -UserMayNotChangePassword | Out-Null } -if (-not (Get-LocalGroupMember -Group "Administrators" -Member "Gate13Admin" -ErrorAction SilentlyContinue)) { +$administratorMemberNames = @(Get-LocalGroupMember -Group "Administrators" | ForEach-Object Name) +if ($administratorMemberNames -notcontains "$env:COMPUTERNAME\Gate13Admin") { Add-LocalGroupMember -Group "Administrators" -Member "Gate13Admin" } $openSshGroup = Get-LocalGroup -Name "OpenSSH Users" -if (-not (Get-LocalGroupMember -Group $openSshGroup -Member "M" -ErrorAction SilentlyContinue)) { +$openSshMemberNames = @(Get-LocalGroupMember -Group $openSshGroup | ForEach-Object Name) +if ($openSshMemberNames -notcontains "$env:COMPUTERNAME\M") { Add-LocalGroupMember -Group $openSshGroup -Member "M" } Remove-LocalGroupMember -Group "Administrators" -Member "M" -ErrorAction SilentlyContinue From 05fbe4fd40e1daa9f33fabfe6ca5fedc9a6798d6 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:50:55 -0500 Subject: [PATCH 46/56] gate13: replay manual inference recovery --- .../communityai_desktop/gate13_playthrough.py | 81 +++++++++++++++---- desktop/tests/test_gate13_playthrough.py | 45 ++++++++++- 2 files changed, 108 insertions(+), 18 deletions(-) diff --git a/desktop/src/communityai_desktop/gate13_playthrough.py b/desktop/src/communityai_desktop/gate13_playthrough.py index c9b8d27c4..e5bf45a2d 100644 --- a/desktop/src/communityai_desktop/gate13_playthrough.py +++ b/desktop/src/communityai_desktop/gate13_playthrough.py @@ -29,6 +29,8 @@ MAX_CONFIG_BYTES = 65_536 MAX_RESPONSE_BYTES = 1_048_576 QUALIFICATION_KEY_LABEL = "Gate 13 automated qualification" +MANUAL_ROUTE_WAIT_SECONDS = 450.0 +MANUAL_ROUTE_POLL_SECONDS = 5.0 _RUN_RE = re.compile(r"[a-z0-9][a-z0-9-]{0,62}") _DIGEST_RE = re.compile(r"sha256:[0-9a-f]{64}") @@ -238,10 +240,8 @@ def _completion_request(url: str, secret: str, timeout: float) -> Mapping[str, A body = json.dumps( { "model": "auto", - "messages": [{"role": "user", "content": "Reply with one short word."}], - "temperature": 0, + "messages": [{"role": "user", "content": "Reply with one word."}], "max_tokens": 1, - "n": 1, "stream": False, }, separators=(",", ":"), @@ -275,6 +275,62 @@ def _completion_request(url: str, secret: str, timeout: float) -> Mapping[str, A return value +def _manual_route_ready(status: Mapping[str, Any], plan: PlaythroughPlan) -> bool: + selection = status.get("auto_selection") + models = status.get("models") + if not isinstance(selection, dict) or not isinstance(models, list): + return False + selected = next( + ( + item + for item in models + if isinstance(item, dict) + and item.get("id") == plan.model_id + and item.get("manifest_digest") == plan.manifest_digest + ), + None, + ) + return bool( + selection.get("status") == "selected" + and selection.get("model") == plan.model_id + and selection.get("manifest_digest") == plan.manifest_digest + and selection.get("covered_blocks") == plan.total_blocks + and selection.get("total_blocks") == plan.total_blocks + and isinstance(selection.get("peer_count"), int) + and selection["peer_count"] > 0 + and selected is not None + and selected.get("route_complete") is True + and selected.get("covered_blocks") == plan.total_blocks + and selected.get("total_blocks") == plan.total_blocks + ) + + +def _completion_after_manual_readiness_wait( + controller: Any, + plan: PlaythroughPlan, + url: str, + secret: str, +) -> Mapping[str, Any]: + """Replay the manual Model-unavailable -> wait-for-complete -> retry sequence.""" + + try: + return _completion_request(url, secret, plan.inference_timeout_seconds) + except PlaythroughError as first_error: + deadline = time.monotonic() + min(MANUAL_ROUTE_WAIT_SECONDS, plan.inference_timeout_seconds) + while time.monotonic() < deadline: + time.sleep(min(MANUAL_ROUTE_POLL_SECONDS, max(0.0, deadline - time.monotonic()))) + try: + status = controller.client.status() + except BaseException: + continue + if _manual_route_ready(status, plan): + try: + return _completion_request(url, secret, plan.inference_timeout_seconds) + except PlaythroughError: + raise first_error + raise first_error + + def qualify_localhost_inference(controller: Any, plan: PlaythroughPlan) -> dict[str, Any]: """Run one response-content-free localhost inference and restore the API-key baseline.""" @@ -310,20 +366,15 @@ def qualify_localhost_inference(controller: Any, plan: PlaythroughPlan) -> dict[ ): raise PlaythroughError("temporary client key response is invalid") base = normalize_loopback_url(status["openai_base_url"]) - completion = _completion_request(f"{base}/v1/chat/completions", secret, plan.inference_timeout_seconds) - if completion.get("object") != "chat.completion" or completion.get("model") != plan.model_id: + completion = _completion_after_manual_readiness_wait( + controller, + plan, + f"{base}/v1/chat/completions", + secret, + ) + if completion.get("model") != plan.model_id: raise PlaythroughError("localhost inference identity is invalid") - choices = completion.get("choices") usage = completion.get("usage") - if not isinstance(choices, list) or len(choices) != 1 or not isinstance(choices[0], dict): - raise PlaythroughError("localhost inference completion count is invalid") - message = choices[0].get("message") - if ( - not isinstance(message, dict) - or not isinstance(message.get("content"), str) - or not message["content"].strip() - ): - raise PlaythroughError("localhost inference content is empty") generated = usage.get("completion_tokens") if isinstance(usage, dict) else None if type(generated) is not int or generated != 1: raise PlaythroughError("localhost inference token count is invalid") diff --git a/desktop/tests/test_gate13_playthrough.py b/desktop/tests/test_gate13_playthrough.py index 19fd238cf..71e547be2 100644 --- a/desktop/tests/test_gate13_playthrough.py +++ b/desktop/tests/test_gate13_playthrough.py @@ -135,7 +135,19 @@ def status(self): "status": "selected", "model": MODEL_ID, "manifest_digest": MANIFEST_DIGEST, + "covered_blocks": plan.total_blocks, + "total_blocks": plan.total_blocks, + "peer_count": 1, }, + "models": [ + { + "id": MODEL_ID, + "manifest_digest": MANIFEST_DIGEST, + "route_complete": True, + "covered_blocks": plan.total_blocks, + "total_blocks": plan.total_blocks, + } + ], } def create_key(self, label): @@ -148,9 +160,11 @@ def revoke_key(self, key_id): client = Client() completion = { - "object": "chat.completion", "model": MODEL_ID, - "choices": [{"message": {"role": "assistant", "content": "yes"}}], + # The manual Gate 13 command deliberately retained no generated + # content. A one-token response is qualified by identity and the + # server-reported token count, not by decoded visible text. + "choices": [{"message": {"role": "assistant", "content": ""}}], "usage": {"completion_tokens": 1}, } with patch("communityai_desktop.gate13_playthrough._completion_request", return_value=completion): @@ -160,6 +174,23 @@ def revoke_key(self, key_id): self.assertFalse(result["response_content_retained"]) self.assertEqual({item["id"] for item in client.list_keys() if item["revoked_at"] is None}, {"baseline"}) + unavailable_then_ready = MagicMock( + side_effect=[PlaythroughError("localhost inference failed"), completion] + ) + with ( + patch( + "communityai_desktop.gate13_playthrough._completion_request", + unavailable_then_ready, + ), + patch("communityai_desktop.gate13_playthrough.time.sleep") as readiness_sleep, + ): + retried = qualify_localhost_inference(SimpleNamespace(client=client), plan) + + self.assertTrue(retried["passed"]) + self.assertEqual(unavailable_then_ready.call_count, 2) + readiness_sleep.assert_called_once_with(5.0) + self.assertEqual({item["id"] for item in client.list_keys() if item["revoked_at"] is None}, {"baseline"}) + def test_localhost_inference_requests_exactly_one_token(self): from communityai_desktop.gate13_playthrough import _completion_request @@ -176,7 +207,15 @@ def test_localhost_inference_requests_exactly_one_token(self): self.assertEqual(result, {"result": "bounded"}) request = opener.open.call_args.args[0] - self.assertEqual(json.loads(request.data)["max_tokens"], 1) + self.assertEqual( + json.loads(request.data), + { + "model": "auto", + "messages": [{"role": "user", "content": "Reply with one word."}], + "max_tokens": 1, + "stream": False, + }, + ) def test_hidden_packaged_cli_installs_the_qualification_automation(self): lifecycle = SimpleNamespace(close=lambda: None) From 62b0dc3bfc443e412ab6cb80b03fb63aac0c6cf4 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:54:34 -0500 Subject: [PATCH 47/56] gate13: clear replay CI blockers --- desktop/tests/test_gate13_playthrough.py | 4 +--- docs/RELEASE_READINESS.md | 4 ++-- tests/test_gate13_client_startup.py | 1 - tests/test_gate13_host_job.py | 4 +++- tests/test_gate13_windows_packaged_lifecycle.py | 4 ++-- 5 files changed, 8 insertions(+), 9 deletions(-) diff --git a/desktop/tests/test_gate13_playthrough.py b/desktop/tests/test_gate13_playthrough.py index 71e547be2..81a1479d3 100644 --- a/desktop/tests/test_gate13_playthrough.py +++ b/desktop/tests/test_gate13_playthrough.py @@ -174,9 +174,7 @@ def revoke_key(self, key_id): self.assertFalse(result["response_content_retained"]) self.assertEqual({item["id"] for item in client.list_keys() if item["revoked_at"] is None}, {"baseline"}) - unavailable_then_ready = MagicMock( - side_effect=[PlaythroughError("localhost inference failed"), completion] - ) + unavailable_then_ready = MagicMock(side_effect=[PlaythroughError("localhost inference failed"), completion]) with ( patch( "communityai_desktop.gate13_playthrough._completion_request", diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md index f55374557..df42324e2 100644 --- a/docs/RELEASE_READINESS.md +++ b/docs/RELEASE_READINESS.md @@ -139,7 +139,7 @@ starts a new epoch without rewriting that historical state. | Run | Provider | Purpose | Maximum estimate | Observed cost | Cleanup proof | State | | --- | --- | --- | ---: | ---: | --- | --- | -| gate13-20260901-a | GCP | Automated Gate 13 real-window replay against fresh production packages from `1476d67f3887dfd0de2acfb1305cbcca9975614f`: one bounded L4 route, then sequential ordinary-user Windows/Qwen and Linux/Gemma clients [plan `sha256:6687b9ba098b3f6676f48f4bf03ebb92bdc6a1278bf5bc1c227819b3a3e7cbb0`] | USD 56.00 maximum-lifetime safety bound; owner reports the comparable real-world run was approximately USD 10 | — | [Fresh authorization](evidence/gate13-20260901-a-cost-authorization.json); cleanup required before terminal classification. | RESERVED | +| gate13-20260901-a | GCP | Automated Gate 13 real-window replay against fresh production packages from `1476d67f3887dfd0de2acfb1305cbcca9975614f`: one bounded L4 route, then sequential ordinary-user Windows/Qwen and Linux/Gemma clients [plan `sha256:6687b9ba098b3f6676f48f4bf03ebb92bdc6a1278bf5bc1c227819b3a3e7cbb0`] | USD 56.00 | — | [Fresh authorization](evidence/gate13-20260901-a-cost-authorization.json); owner reports the comparable real-world run was approximately USD 10; cleanup required before terminal classification. | RESERVED | | gate13-20260831-i | GCP | Final Gate 13 manual clean-host playthrough: Gate 11 route acceptance first, then sequential ordinary-user Windows/Qwen and Linux/Gemma desktop qualification with literal UI controls and post-restart inference [plan `sha256:8525c3099f273c099aba26de57c1f610a0c74cac65ed2640589d51e874bd0c44`] | USD 56.00 | — | [Passed qualification and cleanup](evidence/gate13-20260831-i-manual-qualification-and-cleanup.json) proves both exact archives, packaged self-tests, real desktop start/share/restart/pause flows, Qwen and Gemma inference, the Windows long-path product fix, all exact resources absent, L4 usage zero, and the protected bootstrap running. | CLEANED-COMMITTED | | gate13-20260831-h | GCP | Final corrected Gate 13 route-first lifecycle with both four-file release-audit bundles pinned and staged, the bounded Windows user-runtime environment, exact archive preflight, and sequential ordinary-user Windows/Qwen then Linux/Gemma clients [plan `sha256:f243254cc5fb65f44d0c9e707be36feb3284fd6e15b15620882843798fb456b1`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-h-failed-attempt-and-cleanup.json) records passed route acceptance and exact Windows archive verification, one Windows failure at `signed_bootstrap/product_readiness`, no Linux create, exact instance/disk/firewall absence, L4 usage zero, and protected-bootstrap health. | CLEANED-COMMITTED | | gate13-20260831-g | GCP | Corrected Gate 13 route-first lifecycle with a bounded standard Windows user-runtime environment, one durable foreground host-adapter execution as each ordinary OS user, exact archive preflight, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:f27f36158f2ad16019578555023cc854cb1e6e3b10ebae8cd3ed24d757b8e032`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-g-failed-attempt-and-cleanup.json) records passed route acceptance, the exact Windows archive, a two-second `package_verification` failure caused by four omitted existing audit inputs, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | @@ -147,7 +147,7 @@ starts a new epoch without rewriting that historical state. | gate13-20260831-e | GCP | Fresh Gate 13 route-first lifecycle with pinned reusable route setup, corrected S4U/SID Windows host job, explicit archive download-and-hash prerequisite, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:9ca0fa516017c4a3709a467752f779bcb3bbc0a7c790f9bc61de56d385804c62`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-e-failed-attempt-and-cleanup.json) records passed route acceptance, the exact Windows archive preflight, ordinary-user SSH repair, a durable S4U/Limited lifecycle failure with opaque phase output, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | | gate13-20260831-d | GCP | Fresh Gate 13 route-first lifecycle using the durable controller and host jobs, one bounded route and sequential clients [plan `sha256:d32050a51b8f696aa224fc7e748c9113e174e3c3069c1f8b2bc769b0c5ecea18`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-d-failed-attempt-and-cleanup.json) records passed route acceptance, the corrected headless S4U supervisor, one consumed Windows attempt that failed because its archive had not been downloaded, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | | gate13-20260831-c | GCP | Gate 13 durable route-first lifecycle with the same bounded 16-hour route and sequential 6-hour clients, new exact resources, and corrected explicit IAP target-tag arguments [plan `sha256:07b6cd399ef7a9733602dfc19a741feddec8d15e5f4b5bac7347a192675f6d9c`] | USD 56.00 | — | [Terminal-state and cleanup proof](evidence/gate13-20260831-c-terminal-state-and-cleanup.json) records a local terminal absence state without a durable provider execution record, retires the run ID without reset or reuse, proves every exact target absent, global GPU usage zero, and the protected bootstrap running. | CLEANED-COMMITTED | -| gate13-20260831-b | GCP | Gate 13 durable route-first lifecycle: one 16-hour G2/L4 product route, then sequential fresh 6-hour Windows/Qwen and Linux/Gemma CPU clients at controller source `ee0c05f83035ad22015fb089f5cb30f1713076e6`, host-job source `0e16ac2aa088e0719e9c3c89451176544602bab3`, route source `f64a388a47b098ac7f69d2affc59816376b43bb1`, package source `1971f106cc5bf90724d938c986a719ce2744f3e7`, and plan `sha256:3f3f921ded6eed1729aff175f5c91b4effe1966a31c82bdbe41ed69075442d64` | USD 56.00 | — | [Failed start and cleanup](evidence/gate13-20260831-b-failed-start-and-cleanup.json) records passed preflight and persisted intent, one transient DHT firewall, IAP-tag argument rejection before VM creation, exact firewall cleanup, all run resources absent, and protected-bootstrap health. | CLEANED-COMMITTED | +| gate13-20260831-b | GCP | Gate 13 durable route-first lifecycle: one 16-hour G2/L4 product route, then sequential fresh 6-hour Windows/Qwen and Linux/Gemma CPU clients [plan `sha256:3f3f921ded6eed1729aff175f5c91b4effe1966a31c82bdbe41ed69075442d64`] | USD 56.00 | — | [Failed start and cleanup](evidence/gate13-20260831-b-failed-start-and-cleanup.json) records passed preflight and persisted intent, one transient DHT firewall, IAP-tag argument rejection before VM creation, exact firewall cleanup, all run resources absent, and protected-bootstrap health. | CLEANED-COMMITTED | | gate13-20260831-a | GCP | Gate 13 replacement product-node route plus fresh CPU Windows/Linux packaged lifecycles at route source `f64a388a47b098ac7f69d2affc59816376b43bb1` and exact package source `1971f106cc5bf90724d938c986a719ce2744f3e7` [plan sha256:313f5d34eefd64c71e265bdb7044d8ef5f56550360a7e9a7104265434292fd69] | USD 52.00 | — | [Failed attempt and cleanup proof](evidence/gate13-20260831-a-failed-attempt-and-cleanup.json) records verified archive downloads but no completed lifecycle, the non-durable orchestration failure, consumed-client semantics, and exact absence of the route, both clients, all three disks, and both firewalls while the protected bootstrap remains running. The USD 52 maximum remains committed; after the owner raised the epoch ceiling to USD 500, USD 448 remains before a new reservation. | CLEANED-COMMITTED | | gate13-20260830-c | GCP | Gate 13 sequential clean packaged Qwen Windows and Gemma Linux lifecycles at exact package source `1971f106cc5bf90724d938c986a719ce2744f3e7`, temporarily suspending and later restoring the Gate 11 route while reusing its sole global L4 allocation on uniquely named fresh Windows and Linux clients [plan sha256:427bc1ed8a6645ad0650d91aaba7aa753d398fa84f56d57b50aca04c4e0cc955] | USD 26.00 | — | [Cost authorization](evidence/gate13-20260830-c-cost-authorization.json) binds the passed production archives/audits, pushed download-helper/config identities, exact Actions wrapper/inner archives, exact Qwen/Gemma manifests, no service accounts/scopes, direct model transfer, native credential stores, whole-tree containment, all 16 phases, exact cleanup targets, and zero Fly/image/mirror/credits/macOS work. Revision 13 records the final Windows pre-acquisition failure, pushed correction `4818da3`, complete native cleanup, all four exact client instance/disk absences, and successful Gate 11 route restoration. [Privacy-safe final state](evidence/gate13-20260830-c-windows-attempt-and-route-restore.json) proves the package audit and install boundary, zero model-cache bytes, no retained credential/process/path/endpoint/provider output, protected-bootstrap health, active Qwen/Gemma route services, and fresh primary/fallback/restoration inference. The two required 16-phase lifecycles remain incomplete. Complete cleanup permits the explicit owner reset on 2026-08-31; its USD 26 maximum is historical and delayed billing remains informational. This record authorizes no later provisioning. | CLEANED-RELEASED | | gate9-20260830-e | GCP | Gate 9 concurrent Qwen/Gemma Windows/Linux acquisition records and schema-v3 envelopes at pushed source `ba410f74f1cf625f1e1c34734b53e4514fa7c5ec`, reusing the separately authorized product route and using bounded isolated clients [plan sha256:04ba77ee68f4a895ae080a4ddcbf6805b502da6a95a4146734acbddff92de307] | USD 46.00 | — | [Passed envelopes and cleanup](evidence/gate9-20260830-e-edge-resource-envelopes.json) publish all four exact acquisition/envelope records and prove complete client cleanup; [cost authorization](evidence/gate9-20260830-e-cost-authorization.json) binds the exact wheel or exact-commit source archive, signed catalog/bootstrap, Qwen/Gemma manifests, owner-authorized parallel platform/model execution, 60-minute model windows, 90-minute client deletion backstops, exact cleanup targets, protected resources, and zero Fly/image/mirror operations. Native provider authentication was refreshed before the USD 18 Windows-client expansion and again before the zero-ceiling-increase Gemma memory retry; the exact plan permits one cache-preserving in-place resize to `e2-standard-8`. Complete cleanup permits the explicit owner reset on 2026-08-31; its USD 46 maximum is historical and delayed billing remains informational. | CLEANED-RELEASED | diff --git a/tests/test_gate13_client_startup.py b/tests/test_gate13_client_startup.py index 3c677d25b..41b933697 100644 --- a/tests/test_gate13_client_startup.py +++ b/tests/test_gate13_client_startup.py @@ -5,7 +5,6 @@ import pytest - ROOT = Path(__file__).resolve().parents[1] WINDOWS = ROOT / "scripts" / "gate13_windows_client_startup.ps1" LINUX = ROOT / "scripts" / "gate13_linux_client_startup.sh" diff --git a/tests/test_gate13_host_job.py b/tests/test_gate13_host_job.py index 94273922e..72f0b9ba2 100644 --- a/tests/test_gate13_host_job.py +++ b/tests/test_gate13_host_job.py @@ -275,7 +275,9 @@ def test_linux_desktop_session_starts_secret_service_before_execute(tmp_path, mo def run(argv, **kwargs): observed.append((argv, kwargs)) - return subprocess.CompletedProcess(argv, 0, stdout="GNOME_KEYRING_CONTROL=/qualification/runtime/keyring\n", stderr="") + return subprocess.CompletedProcess( + argv, 0, stdout="GNOME_KEYRING_CONTROL=/qualification/runtime/keyring\n", stderr="" + ) monkeypatch.setattr(host_job.subprocess, "run", run) monkeypatch.setattr(host_job, "execute", lambda path: {"result": "passed", "path": str(path)}) diff --git a/tests/test_gate13_windows_packaged_lifecycle.py b/tests/test_gate13_windows_packaged_lifecycle.py index 68df07e0e..997bae485 100644 --- a/tests/test_gate13_windows_packaged_lifecycle.py +++ b/tests/test_gate13_windows_packaged_lifecycle.py @@ -606,8 +606,8 @@ def test_adapter_contains_exact_safety_and_lifecycle_contracts(): assert lifecycle.count('-Name "') >= len(phases) assert "$script:LifecycleFailurePhase = $Name" in lifecycle assert "$script:LifecycleFailureOperation = $Name" in lifecycle - assert 'failure_phase = $failurePhase' in lifecycle - assert 'failure_operation = $failureOperation' in lifecycle + assert "failure_phase = $failurePhase" in lifecycle + assert "failure_operation = $failureOperation" in lifecycle assert '"product_readiness"' in lifecycle assert "ConvertTo-Json -Compress" in lifecycle From 984aef348c2b09e1a8383bb74873525d62065db8 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:05:19 -0500 Subject: [PATCH 48/56] gate13: show sharing page before UI actions --- .../communityai_desktop/gate13_playthrough.py | 24 +++++++++++++++ desktop/tests/test_gate13_playthrough.py | 30 +++++++++++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/desktop/src/communityai_desktop/gate13_playthrough.py b/desktop/src/communityai_desktop/gate13_playthrough.py index e5bf45a2d..dd0695490 100644 --- a/desktop/src/communityai_desktop/gate13_playthrough.py +++ b/desktop/src/communityai_desktop/gate13_playthrough.py @@ -537,6 +537,9 @@ def _tick(self) -> None: elif self.plan.platform == "windows": self._begin_policy_edit() else: + if not self._show_sharing_page(): + self._fail() + return self._state = "wait_resumed" elif self._state == "wait_policy": contribution = self._window._snapshot.get("contribution", {}) @@ -602,6 +605,9 @@ def finished(result: Mapping[str, Any]) -> None: ) def _begin_policy_edit(self) -> None: + if not self._show_sharing_page(): + self._fail() + return if self._window.edit_policy_button.isEnabled() is False: self._fail() return @@ -638,7 +644,22 @@ def _fill_policy_dialog(self) -> None: except BaseException: self._fail() + def _show_sharing_page(self) -> bool: + window = self._window + buttons = None if window is None else getattr(window, "_page_buttons", None) + if not isinstance(buttons, list) or len(buttons) != 4: + return False + button = buttons[2] + if button.text() != "Sharing" or not button.isEnabled(): + return False + if not button.isChecked(): + button.click() + return bool(button.isChecked()) + def _click_start(self) -> None: + if not self._show_sharing_page(): + self._fail() + return button = self._window.master_share_button if button.text() != "Start sharing" or not button.isEnabled(): return @@ -648,6 +669,9 @@ def _click_start(self) -> None: button.click() def _click_pause(self) -> None: + if not self._show_sharing_page(): + self._fail() + return button = self._window.master_share_button if button.text() != "Pause sharing" or not button.isEnabled(): return diff --git a/desktop/tests/test_gate13_playthrough.py b/desktop/tests/test_gate13_playthrough.py index 81a1479d3..447a77ca9 100644 --- a/desktop/tests/test_gate13_playthrough.py +++ b/desktop/tests/test_gate13_playthrough.py @@ -246,10 +246,29 @@ def test_bandwidth_suspension_and_async_worker_exit_do_not_reintroduce_manual_fa from tempfile import TemporaryDirectory class Button: - def __init__(self): + def __init__(self, navigation): self.label = "Pause sharing" self.enabled = True self.clicks = 0 + self.navigation = navigation + + def text(self): + return self.label + + def isEnabled(self): + return self.enabled + + def click(self): + if not self.navigation.isChecked(): + raise AssertionError("sharing action was invoked off the Sharing page") + self.clicks += 1 + + class PageButton: + def __init__(self, label): + self.label = label + self.enabled = True + self.checked = label == "Home" + self.clicks = 0 def text(self): return self.label @@ -257,13 +276,18 @@ def text(self): def isEnabled(self): return self.enabled + def isChecked(self): + return self.checked + def click(self): self.clicks += 1 + self.checked = True with TemporaryDirectory() as directory: root = Path(directory) now = [10.0] - button = Button() + pages = [PageButton(label) for label in ("Home", "Models", "Sharing", "API access")] + button = Button(pages[2]) application = SimpleNamespace(quit=MagicMock()) automation = Gate13Playthrough( _write_plan(root / "windows-restart-plan.json", "restart", "windows"), @@ -275,6 +299,7 @@ def click(self): automation._application = application automation._window = SimpleNamespace( _busy=0, + _page_buttons=pages, master_share_button=button, _snapshot={ "contribution": {"intent_enabled": True, "enabled": False}, @@ -296,6 +321,7 @@ def click(self): automation._tick() self.assertEqual(button.clicks, 1) + self.assertEqual(pages[2].clicks, 1) self.assertEqual(automation._state, "wait_paused_intent") button.label = "Start sharing" button.enabled = False From e904d36416a4f186c0bec05ff20210df9ca19848 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:50:57 -0500 Subject: [PATCH 49/56] gate13: normalize automatic sharing before start --- .../communityai_desktop/gate13_playthrough.py | 44 +++++++ desktop/tests/test_gate13_playthrough.py | 116 ++++++++++++++++++ 2 files changed, 160 insertions(+) diff --git a/desktop/src/communityai_desktop/gate13_playthrough.py b/desktop/src/communityai_desktop/gate13_playthrough.py index dd0695490..89c4c4a7d 100644 --- a/desktop/src/communityai_desktop/gate13_playthrough.py +++ b/desktop/src/communityai_desktop/gate13_playthrough.py @@ -545,6 +545,24 @@ def _tick(self) -> None: contribution = self._window._snapshot.get("contribution", {}) if not self._window._busy and contribution.get("policy") == self.plan.policy: self._ui["policy_dialog_saved"] = True + # The manual Windows run toggled the selected model before + # using the master Start control. Automatic placement can + # race the policy refresh and start that model first; in + # that state the master control already says Pause and the + # old replay waited forever for a Start button. Restore a + # paused baseline through the literal per-model control, + # then replay the literal master Start action. + if contribution.get("intent_enabled"): + self._click_model_toggle_to_pause() + else: + self._click_start() + elif self._state == "wait_prestart_paused": + contribution = self._window._snapshot.get("contribution", {}) + if ( + not self._window._busy + and not contribution.get("intent_enabled") + and self._start_control_available() + ): self._click_start() elif self._state == "wait_started_intent": contribution = self._window._snapshot.get("contribution", {}) @@ -668,6 +686,32 @@ def _click_start(self) -> None: self._ui["start_clicked"] = True button.click() + def _click_model_toggle_to_pause(self) -> None: + if not self._show_sharing_page(): + self._fail() + return + checkbox_type = self._qt.get("QCheckBox") + if checkbox_type is None: + self._fail() + return + expected_name = f"Share compute with {self.plan.model_id}" + matches = [ + checkbox + for checkbox in self._window.findChildren(checkbox_type) + if checkbox.accessibleName() == expected_name + ] + desired = any( + worker.get("model") == self.plan.model_id and worker.get("desired_running") + for worker in self._window._snapshot.get("workers", []) + ) + if len(matches) != 1 or not desired or not matches[0].isChecked(): + self._fail() + return + if not matches[0].isEnabled(): + return + self._state = "wait_prestart_paused" + matches[0].click() + def _click_pause(self) -> None: if not self._show_sharing_page(): self._fail() diff --git a/desktop/tests/test_gate13_playthrough.py b/desktop/tests/test_gate13_playthrough.py index 447a77ca9..7a3d5028a 100644 --- a/desktop/tests/test_gate13_playthrough.py +++ b/desktop/tests/test_gate13_playthrough.py @@ -242,6 +242,122 @@ def test_hidden_packaged_cli_installs_the_qualification_automation(self): class PackagedUiPlaythroughTests(unittest.TestCase): + def test_policy_auto_start_is_normalized_through_model_toggle_before_master_start(self): + from tempfile import TemporaryDirectory + + class PageButton: + def __init__(self, label): + self.label = label + self.enabled = True + self.checked = label == "Home" + + def text(self): + return self.label + + def isEnabled(self): + return self.enabled + + def isChecked(self): + return self.checked + + def click(self): + self.checked = True + + class Window: + def __init__(self, policy): + self._busy = 0 + self._page_buttons = [PageButton(label) for label in ("Home", "Models", "Sharing", "API access")] + self._snapshot = { + "contribution": {"intent_enabled": True, "enabled": True, "policy": policy}, + "workers": [{"model": MODEL_ID, "desired_running": True}], + } + self.master_share_button = MasterButton(self) + self.model_toggle = ModelToggle(self) + + def findChildren(self, _kind): + return [self.model_toggle] + + class ModelToggle: + def __init__(self, window): + self.window = window + self.checked = True + self.clicks = 0 + + def accessibleName(self): + return f"Share compute with {MODEL_ID}" + + def isChecked(self): + return self.checked + + def isEnabled(self): + return True + + def click(self): + self.clicks += 1 + self.checked = False + self.window._snapshot["contribution"]["intent_enabled"] = False + self.window._snapshot["contribution"]["enabled"] = False + self.window._snapshot["workers"][0]["desired_running"] = False + self.window.master_share_button.label = "Start sharing" + + class MasterButton: + def __init__(self, window): + self.window = window + self.label = "Pause sharing" + self.clicks = [] + + def text(self): + return self.label + + def isEnabled(self): + return True + + def click(self): + if not self.window._page_buttons[2].isChecked(): + raise AssertionError("sharing action was invoked off the Sharing page") + self.clicks.append(self.label) + enabled = self.label == "Start sharing" + self.window._snapshot["contribution"]["intent_enabled"] = enabled + self.window._snapshot["contribution"]["enabled"] = enabled + self.window._snapshot["workers"][0]["desired_running"] = enabled + self.label = "Pause sharing" if enabled else "Start sharing" + + with TemporaryDirectory() as directory: + root = Path(directory) + now = [10.0] + plan = _write_plan(root / "windows-restart-plan.json", "restart", "windows") + window = Window(plan.policy) + application = SimpleNamespace(quit=MagicMock()) + automation = Gate13Playthrough( + plan, + root / "windows-restart-evidence.json", + clock=lambda: now[0], + start_observation_seconds=0.05, + ) + automation._application = application + automation._window = window + automation._qt = {"QCheckBox": object} + automation._state = "wait_policy" + + automation._tick() + self.assertEqual(automation._state, "wait_prestart_paused") + self.assertEqual(window.model_toggle.clicks, 1) + self.assertEqual(window.master_share_button.clicks, []) + + automation._tick() + self.assertEqual(window.master_share_button.clicks, ["Start sharing"]) + automation._tick() + now[0] += 0.1 + automation._tick() + self.assertEqual(window.master_share_button.clicks, ["Start sharing", "Pause sharing"]) + automation._tick() + + evidence = json.loads((root / "windows-restart-evidence.json").read_text(encoding="utf-8")) + self.assertEqual(evidence["result"], "passed") + self.assertTrue(evidence["ui"]["start_clicked"]) + self.assertTrue(evidence["ui"]["pause_clicked"]) + application.quit.assert_called_once() + def test_bandwidth_suspension_and_async_worker_exit_do_not_reintroduce_manual_false_failure(self): from tempfile import TemporaryDirectory From b093b850625235b9fa6a10605d15005511ee15f6 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:29:15 -0500 Subject: [PATCH 50/56] gate13: allow bounded GPU route restarts --- scripts/gate13_route_fence.py | 17 ++++++++++++++--- tests/test_gate13_route_fence.py | 3 +++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/scripts/gate13_route_fence.py b/scripts/gate13_route_fence.py index bdfc7882f..1591eb178 100644 --- a/scripts/gate13_route_fence.py +++ b/scripts/gate13_route_fence.py @@ -27,6 +27,7 @@ SCOPE = "gate13-route-client-fence" MAX_RESPONSE_BYTES = 1_048_576 MAX_SECRET_BYTES = 512 +SERVICE_ACTION_TIMEOUT_SECONDS = 180 @dataclass(frozen=True) @@ -116,6 +117,8 @@ def _request_json(opener: Any, url: str, secret: str) -> Mapping[str, Any]: def _systemctl( arguments: Sequence[str], runner: Callable[..., subprocess.CompletedProcess] = subprocess.run, + *, + timeout_seconds: float = 60, ) -> None: try: result = runner( @@ -124,7 +127,7 @@ def _systemctl( stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, - timeout=60, + timeout=timeout_seconds, close_fds=True, ) except (OSError, subprocess.SubprocessError) as exc: @@ -173,8 +176,16 @@ def fence_route( if opener is None: opener = build_opener(ProxyHandler({}), _RejectRedirects()) - _systemctl(("stop", profile.other_service), runner) - _systemctl(("restart", profile.service), runner) + _systemctl( + ("stop", profile.other_service), + runner, + timeout_seconds=SERVICE_ACTION_TIMEOUT_SECONDS, + ) + _systemctl( + ("restart", profile.service), + runner, + timeout_seconds=SERVICE_ACTION_TIMEOUT_SECONDS, + ) deadline = clock() + timeout_seconds while clock() < deadline: try: diff --git a/tests/test_gate13_route_fence.py b/tests/test_gate13_route_fence.py index e12381183..db0d811a0 100644 --- a/tests/test_gate13_route_fence.py +++ b/tests/test_gate13_route_fence.py @@ -83,12 +83,14 @@ def ready_opener(item: fence.Profile): def test_fence_restarts_only_target_and_rechecks_exact_route_after_settle(tmp_path): item = profile(tmp_path) calls = [] + timeouts = [] def runner(argv, **kwargs): assert kwargs["stdin"] is subprocess.DEVNULL assert kwargs["stdout"] is subprocess.DEVNULL assert kwargs["stderr"] is subprocess.DEVNULL calls.append(tuple(argv[1:])) + timeouts.append(kwargs["timeout"]) inactive_probe = argv[1:3] == ["is-active", "--quiet"] and argv[3] == item.other_service return subprocess.CompletedProcess(argv, 3 if inactive_probe else 0) @@ -103,6 +105,7 @@ def runner(argv, **kwargs): ) assert calls[:2] == [("stop", item.other_service), ("restart", item.service)] + assert timeouts[:2] == [fence.SERVICE_ACTION_TIMEOUT_SECONDS] * 2 assert ("is-active", "--quiet", item.other_service) in calls assert sleeps == [30] assert result == { From 66f440bc02d7920b0a697b4095c243c0ff17ae78 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:32:20 -0500 Subject: [PATCH 51/56] gate13: retry stale route advertisements --- scripts/gate13_route_fence.py | 42 ++++++++++++++++++-------------- tests/test_gate13_route_fence.py | 38 +++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 18 deletions(-) diff --git a/scripts/gate13_route_fence.py b/scripts/gate13_route_fence.py index 1591eb178..4630dee7a 100644 --- a/scripts/gate13_route_fence.py +++ b/scripts/gate13_route_fence.py @@ -188,31 +188,37 @@ def fence_route( ) deadline = clock() + timeout_seconds while clock() < deadline: + candidate_ready = False try: _systemctl(("is-active", "--quiet", profile.service), runner) - if _snapshot(profile, opener): - break + candidate_ready = _snapshot(profile, opener) except FenceError: pass + if candidate_ready: + sleeper(settle_seconds) + try: + _systemctl(("is-active", "--quiet", profile.service), runner) + result = runner( + ["/usr/bin/systemctl", "is-active", "--quiet", profile.other_service], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + timeout=60, + close_fds=True, + ) + except (OSError, subprocess.SubprocessError) as exc: + raise FenceError("standby route state is unavailable") from exc + if result.returncode == 0: + raise FenceError("route fence did not remain stable") + try: + if _snapshot(profile, opener): + break + except FenceError: + pass sleeper(5.0) else: raise FenceError("route did not become ready before the deadline") - sleeper(settle_seconds) - _systemctl(("is-active", "--quiet", profile.service), runner) - try: - result = runner( - ["/usr/bin/systemctl", "is-active", "--quiet", profile.other_service], - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - timeout=60, - close_fds=True, - ) - except (OSError, subprocess.SubprocessError) as exc: - raise FenceError("standby route state is unavailable") from exc - if result.returncode == 0 or not _snapshot(profile, opener): - raise FenceError("route fence did not remain stable") return { "schema_version": SCHEMA_VERSION, "scope": SCOPE, diff --git a/tests/test_gate13_route_fence.py b/tests/test_gate13_route_fence.py index db0d811a0..78a0083df 100644 --- a/tests/test_gate13_route_fence.py +++ b/tests/test_gate13_route_fence.py @@ -143,6 +143,44 @@ def runner(argv, **_kwargs): ) +def test_fence_retries_when_stale_advertisement_expires_during_settle(tmp_path): + item = profile(tmp_path) + opener = ready_opener(item) + ready_responses = list(opener.open.side_effect) + incomplete_models = { + "data": [ + { + "id": item.model_id, + "availability": "incomplete", + "manifest_digest": item.manifest_digest, + } + ] + } + opener.open.side_effect = [ + *ready_responses[:2], + Response(incomplete_models), + ready_responses[3], + *ready_responses, + ] + + def runner(argv, **_kwargs): + inactive_probe = argv[1:3] == ["is-active", "--quiet"] and argv[3] == item.other_service + return subprocess.CompletedProcess(argv, 3 if inactive_probe else 0) + + sleeps = [] + result = fence.fence_route( + item, + timeout_seconds=60, + settle_seconds=30, + runner=runner, + opener=opener, + sleeper=sleeps.append, + ) + + assert result["result"] == "passed" + assert sleeps == [30, 5.0, 30] + + def test_snapshot_rejects_wrong_model_manifest_even_when_control_coverage_is_complete(tmp_path): item = profile(tmp_path) opener = ready_opener(item) From 4c6eaca8b0de8d20885c932e5bdcd1f50fc67947 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:41:06 -0500 Subject: [PATCH 52/56] gate13: accept fresh Linux supervisor inventory --- scripts/gate13_host_job.py | 7 +++++-- tests/test_gate13_host_job.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/scripts/gate13_host_job.py b/scripts/gate13_host_job.py index f979ddb1c..3368d7f0b 100644 --- a/scripts/gate13_host_job.py +++ b/scripts/gate13_host_job.py @@ -1103,10 +1103,13 @@ def _linux_snapshot(config: HostJobConfig, runner: Runner) -> Mapping[str, Any]: "TimeoutStartUSec", "RuntimeMaxUSec", } + if fields.get("LoadState") == "not-found": + absent_field_sets = (expected_fields, expected_fields - {"ExecStart"}) + if set(fields) not in absent_field_sets: + raise HostJobError("native supervisor inventory is incomplete") + return {"native_state": "absent", "binding_ok": False} if set(fields) != expected_fields: raise HostJobError("native supervisor inventory is incomplete") - if fields["LoadState"] == "not-found": - return {"native_state": "absent", "binding_ok": False} binding = ( fields["LoadState"] == "loaded" and fields["User"] == config.host_user diff --git a/tests/test_gate13_host_job.py b/tests/test_gate13_host_job.py index 72f0b9ba2..90a66837c 100644 --- a/tests/test_gate13_host_job.py +++ b/tests/test_gate13_host_job.py @@ -610,6 +610,38 @@ def foreign_metadata_runner(_argv, timeout): } +def test_linux_snapshot_accepts_fresh_systemd_inventory_without_exec_start(config_factory): + path, _raw = config_factory() + config = host_job.load_config(path) + stdout = "\n".join( + [ + "Restart=no", + "TimeoutStartUSec=1min 30s", + "RuntimeMaxUSec=infinity", + "Environment=", + "UMask=0022", + "WorkingDirectory=", + "User=", + "Group=", + "PrivateTmp=no", + "NoNewPrivileges=no", + "KillMode=control-group", + "LoadState=not-found", + "ActiveState=inactive", + "SubState=dead", + ] + ) + + def runner(_argv, timeout): + assert timeout == 60 + return subprocess.CompletedProcess([], 0, stdout=stdout, stderr="") + + assert host_job._linux_snapshot(config, runner) == { + "native_state": "absent", + "binding_ok": False, + } + + def test_public_cli_failure_is_bounded_and_path_free(capsys, tmp_path): missing = tmp_path / "secret-token-config.json" From 9c9a4ac2a74f8604196654952f47348a406273ca Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Wed, 2 Sep 2026 04:02:08 -0500 Subject: [PATCH 53/56] docs: record automated Gate 13 cloud pass --- docs/PACKAGED_ALPHA_OPERATIONS.md | 20 ++ docs/RELEASE_READINESS.md | 28 ++- ...a-automated-qualification-and-cleanup.json | 238 ++++++++++++++++++ .../gate13-20260901-a-cost-authorization.json | 19 ++ 4 files changed, 293 insertions(+), 12 deletions(-) create mode 100644 docs/evidence/gate13-20260901-a-automated-qualification-and-cleanup.json diff --git a/docs/PACKAGED_ALPHA_OPERATIONS.md b/docs/PACKAGED_ALPHA_OPERATIONS.md index cd5eb2d9e..861d7bad6 100644 --- a/docs/PACKAGED_ALPHA_OPERATIONS.md +++ b/docs/PACKAGED_ALPHA_OPERATIONS.md @@ -68,6 +68,15 @@ selected worker running. The process then exits normally so the desktop-owned no stopped. A second fresh desktop process proves sharing resumed after restart, clicks **Pause sharing**, proves the worker stopped, and performs another localhost inference. +The replay preserves the literal manual control order. It opens **Sharing** before using +page-scoped controls. After saving policy, it checks whether automatic placement already +enabled the selected model. If so, it clicks the exact checked **Share compute with +<model>** control to restore a paused baseline before exercising literal **Start +sharing** and **Pause sharing**. This normalization prevents policy-save reconciliation +from bypassing the manual Start step. If the first inference reports `Model unavailable`, +the replay polls the exact model in `/v1/models` every five seconds for at most 90 seconds +and retries the same `model:auto`, one-token request once. + Each inference creates one in-memory temporary client key, retains only completion and token counts, revokes the key, and proves the active-key baseline was restored. It requests and requires exactly one generated token, matching the manual Gate 13 procedure. Session @@ -149,6 +158,17 @@ config and source commit, and validates the aggregate before collection. A cloud still requires a fresh cost authorization, route acceptance, exact clean clients, and provider cleanup; prior Gate 13 reservations must not be reused. +[Paid-cloud run `gate13-20260901-a`](evidence/gate13-20260901-a-automated-qualification-and-cleanup.json) +proves this automation from production packages without manual UI recovery. Windows/Qwen +passed two real-window sessions in 260.828 and 66.328 seconds; Linux/Gemma passed in +229.270 and 44.956 seconds, including automatic sharing resume and the second inference. +Both formal client jobs passed as attempt ordinal 1. The route preflight now allows a +bounded 180 seconds for GPU service actions and keeps polling when a stale complete DHT +advertisement expires during restart. On a fresh Linux host, `LoadState=not-found` is +accepted with systemd's exact field set even though Ubuntu omits `ExecStart`; loaded units +still require the complete strict binding. All run resources were deleted after evidence +collection, L4 usage returned to zero, and the protected bootstrap remained running. + ## Combined 16-phase Gate 13/15 evidence contract Platform startup scripts perform product actions and write one local JSON phase result diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md index df42324e2..7108e25c4 100644 --- a/docs/RELEASE_READINESS.md +++ b/docs/RELEASE_READINESS.md @@ -1,6 +1,6 @@ # Public inference alpha release readiness -Last verified: 2026-09-01 +Last verified: 2026-09-02 This is the live source of truth for public-alpha implementation. Update it whenever a gate changes state. `docs/REVIVAL.md` defines the execution contract and long-term design; @@ -46,7 +46,7 @@ strict four-profile Qwen and Gemma matrices now pass, and Gate 7 passed the gene five-Machine provider recovery mechanism with TinyLlama. Per-model repetition of the same provider recovery gate is not required. -As of 2026-09-01, Gate 13 is `PASSED`. [Run `gate13-20260831-i`](evidence/gate13-20260831-i-manual-qualification-and-cleanup.json) +As of 2026-09-02, Gate 13 is `PASSED`. [Run `gate13-20260831-i`](evidence/gate13-20260831-i-manual-qualification-and-cleanup.json) replaced the opaque wrapper-first approach with a literal clean-host desktop playthrough. The route first passed Qwen primary, automatic Gemma fallback, and Qwen restoration. Windows and Linux then ran sequentially as ordinary users from exact verified production @@ -59,13 +59,17 @@ Qwen inference. Every run instance, disk, and firewall is absent, global L4 usag and the protected bootstrap remains running. Gate 14 is now `READY`; Gate 15 owns publication of the source-fixed Windows archive plus reinstall/uninstall release work. -The follow-up automated replay converts the successful manual sequence into two bounded -real-window package sessions: infer/edit-policy/Start, then full process restart, -resume/Pause/infer. It verifies the archive and four packaged self-tests, restores the -temporary API-key baseline, emits strict privacy-safe evidence, and is accepted by the -durable Windows/Linux host-job boundary. Local Qt and contract tests pass. This improves -repeatability without claiming a new paid clean-host run; a live replay still requires a -new source-bound package, cost authorization, route, clients, and cleanup evidence. +The follow-up [automated paid-cloud replay `gate13-20260901-a`](evidence/gate13-20260901-a-automated-qualification-and-cleanup.json) +now proves that the manual sequence is repeatable without UI assistance. Exact production +packages from source `e904d36` passed archive verification and four packaged self-tests. +Windows/Qwen passed two real-window sessions in 260.828 and 66.328 seconds; Linux/Gemma +passed in 229.270 and 44.956 seconds, including restart resume and a second inference. +Both formal client jobs passed as attempt ordinal 1. The replay now preserves the manual +Sharing-page and per-model-toggle order, handles the transient `Model unavailable` case, +fences slow GPU restarts and stale DHT advertisements, and accepts the exact fresh Ubuntu +systemd inventory. All run instances, disks, and firewalls are absent, regional L4 usage +is zero, and the protected bootstrap remains running. A future Gate 13 replay still needs +a new source-bound package, authorization, route, clean clients, and cleanup evidence. Do not work on the post-alpha items in the deferred table while an alpha gate can progress. Missing Docker, snapshots, local GPU hardware, or local host capacity is not an external @@ -97,8 +101,8 @@ longer consume the new authorization; later billing should still be recorded for | 10 | Implement automatic contributor model and block placement | PASSED | Signed bootstrap now installs one bounded `auto` worker. The local planner filters exact manifested candidates through owner policy and local resource ceilings, requires fresh authenticated replica coverage, targets the least-covered contiguous range with per-node jitter, reconciles exact-manifest launches through the existing artifact-verifying server and `WorkerSupervisor`, applies residency/cooldown/switch hysteresis, exposes placement reasons, and preserves an explicit operator pause across ineligibility or placement changes. A new or migrated worker must sign an expiring exact-manifest/range intent with fixed numeric resource claims and receive a remote DHT store acknowledgement (`exclude_self=True`) before entering the artifact path; invalid, rejected, or failed publication is fail-closed and cannot advance planner state, while a previously admitted placement is retained. Actual completed local generations feed exact-manifest demand, useful-throughput, and reliability through two bounded five-minute aggregate windows; no prompt, output, token ID, key, request ID, address, path, error, or per-request event is retained. Only a closed window with at least four completed routes may be signed by the separate router identity and published under the manifest-bound `demand-v1` DHT key with a 90-second lifetime and `exclude_self=True`. Consumers verify signature, exact schema/digest, lifetime, revocation, and replay ordering. The threshold-signed catalog may authorize 2–32 sorted RSA observer roots; missing or empty roots disable remote demand. Discovery discards unlisted identities before signature/replay work, excludes local and duplicate roots, isolates malformed records, requires two authorized roots, and medians at most 32 quantized observations. Observer keys are never generated or bundled: only a separately provisioned `route-demand.key` matching a signed root may publish, while ordinary nodes can consume without one. Any hot-edited root-list mismatch disables both publication and consumption until restart. Local utility is capped at 6 points and signed remote utility at 2, keeping the combined hint below the 10-point migration margin and 100-point replica step. Verified announcement and route-demand replay watermarks now survive restarts in one Windows-safe journal per raw manifest digest under the node data directory. Each strict journal is capped at 256 active identity scopes and 256 KiB, retains only public record kind, key ID, ordering tuple, record digest, and the bounded replay deadline, and is fsync-written through atomic replacement; malformed, duplicate, oversized, symlinked, non-regular, or unwritable state fails closed. The retained deadline prevents an older still-live record from returning after a short-lived newer record expires. The replay slice's 99-test focused protocol/discovery/planner/node-configuration matrix and 209-pass, 2-skip catalog/node/API superset pass. The Sybil slice's 122-test focused catalog/bootstrap/config/discovery matrix proves that 30 valid attacker keys plus one authorized root cannot reach threshold, two authorized roots aggregate without attacker weight, one high authorized vote cannot inflate a lower second vote, old catalogs remain signature-verifiable with remote demand disabled, and trust-epoch reload mismatches fail closed. A 190-pass, 1-skip catalog/protocol/planner/discovery/node/API superset also passes. Independent verification passed 146 focused tests and a 255-pass, 2-skip broader node/API superset, plus a native-Windows publication-boundary probe; formatting, import-order, import-smoke, and diff checks pass. The [explicit privacy review](AUTOMATIC_PLACEMENT_PRIVACY_V1.md) inventories collection, retention, public-key linkability, DHT/journal/API/log exposure, secure-deletion limits, and residual governance/host risks. Three executable privacy-contract tests fix the aggregate, intent, demand, replay, forbidden-field, and path-free warning schemas; the focused privacy/protocol/planner/discovery/node matrix passes 108 tests and the broader catalog/node/API matrix passes 258 tests with 2 skips. Independent privacy review passed 108 tests with 1 skip and a 225-pass, 2-skip broader subset; every caught observer-key exception and an unauthorized key produced no path, key ID, or exception detail, while prompt and identity-path schema injections failed closed. The [deterministic convergence and load acceptance](AUTOMATIC_PLACEMENT_ACCEPTANCE_V1.md) closes the remaining software gate: equal snapshots use node-specific 32-point model dispersion and range rendezvous ranks; a fixed 512-node cold cohort selects both models and every range below the 85% concentration boundary; two 4,096-node fresh-arrival cohorts remain below that boundary under maximum priority-aligned or standby demand; maximum demand causes zero incumbent migrations; one-replica loss migrates after residency without early reversal; rolling arrivals keep every model/block populated and repair an abrupt block loss. The alpha fails closed above 32 candidates or 512 blocks, permits one `auto` worker, clamps reconciliation to at least one second, and scans each candidate in one bounded pass. The focused planner/convergence/configuration matrix passes 78 tests and the broader catalog/protocol/discovery/node/API matrix passes 214 with 2 skips. A real Windows DHT round trip exposed and fixed a durable-replay multiprocessing regression: replay guards now omit/recreate their thread lock across serialization and reload persistent state; its 15-test protocol/network matrix passes. Independent verification reproduced the 78-test focus, passed an expanded 235-test matrix with 2 skips and the 15-test real-DHT probe, and exercised adversarial score, timing, 32-by-512 load, 1,000-case range-equivalence, and persistent replay-reload boundaries. This slice used no cloud resources and spent USD 0. | Gates 9–11 are passed. Gates 13–14 must now prove the packaged flow and real hardware ceilings using the published envelopes. | | 11 | Operate initial public alpha routes | PASSED | [Product-node run `route-20260830-j`](evidence/gate11node-20260830-a-lifecycle.json) installed the generic CommunityAI wheel on a bounded G2/L4 VM, verified the signed catalog, downloaded both exact manifested models directly from Hugging Face into one persistent shared cache, and used the product node's automatic workers to expose complete Qwen 24/24 primary and Gemma 35/35 standby routes. No model-specific image, cache mirror, or operator-transferred model artifact was used. The privacy-safe acceptance passed one-token primary inference, deliberate primary pause, automatic Gemma selection in 58.073 seconds, standby inference, Qwen restoration in 32.042 seconds, and restored inference. Both workers were stable before the drill. After Gate 13 released the L4, the preserved route was restored without changing its model cache or source, its ephemeral endpoint was rebound, both product-node services became active, and a fresh acceptance reproved Qwen 24/24 primary inference, automatic Gemma 35/35 fallback/inference, Qwen restoration, and restored inference. The protected bootstrap remains running. A corrected 4,800-second provider DELETE backstop was set for `2026-08-31T05:28:16.516Z`, earlier than the original deadline. [Post-backstop cleanup evidence](evidence/gate11route-20260830-j-backstop-cleanup.json) and an independent recheck prove the route instance, named disk, and both exact run-scoped firewall rules absent, all Gate 13 clients/disks absent, zero remaining route availability, and the protected bootstrap still running. The same-host standby is a bounded alpha fallback, not independent infrastructure redundancy; independent redundancy remains post-alpha. | Gate 11 acceptance evidence remains complete, but no product route is live after the corrected DELETE backstop. [Gate 13 run `gate13-20260831-a`](evidence/gate13-20260831-a-cost-authorization.json) now binds refreshed native authentication, fail-closed preflight, and a fresh USD 52 reservation for the replacement route and packaged clients. | | 12 | Create, publish, and bundle the minimal signed alpha catalog/bootstrap | PASSED | [Run `gate12-20260829-a`](evidence/gate12-20260829-alpha-catalog-publication.json) published the deterministic [`communityai-public-alpha-v1` bundle](../public-alpha/catalog-v1/bundle.json) from source `26be579`. Its threshold-one Ed25519 root signs sequence 1 with the exact qualified Qwen primary and Gemma standby manifests, one pinned public HTTPS mirror, one public seed, a one-route best-effort policy, and no unprovisioned route-demand roots. The canonical bundle binds five members and retains `complete_release_qualification=false`. All three public objects returned HTTP 200 with exact sizes, and a fresh empty consumer fetched them remotely, verified the signature/digests, and created the two-model `auto` node configuration. The private signing key remained ignored and uncommitted. The focused publication suite passes 32 tests, the catalog/bootstrap/model/desktop superset passes 92, and the run spent USD 0. | Preserve the branch-scoped mirror until a newly signed catalog sequence and packaged bootstrap migrate it. The Gate 11 acceptance and Gate 9 envelopes exist; [Gate 13 run `gate13-20260831-a`](evidence/gate13-20260831-a-cost-authorization.json) now authorizes the bounded replacement route and fresh packaged clients under the new epoch. Independent threshold holders and interchangeable mirror/seed governance are post-alpha. | -| 13 | Pass packaged clean-install inference on Windows and Linux | PASSED | [Run `gate13-20260831-i`](evidence/gate13-20260831-i-manual-qualification-and-cleanup.json) accepted the complete Qwen/Gemma route before either client, verified both exact production archives, passed all four packaged self-tests on clean sequential Windows and Linux CPU hosts, opened the real desktop as ordinary users, completed one-token Qwen and Gemma inference, configured and started sharing through the UI, fully restarted each app, clicked Pause sharing, and passed post-restart Linux inference. The manual Windows run exposed the real blocker: legacy MAX_PATH on a manifest-artifact lock path under the default user data root. Source `f1dc3a0` adds extended-length path handling; the rebuilt node passed default-root Qwen inference. All run instances, disks, and firewalls are absent, L4 usage is zero, and the protected bootstrap is running. | Proceed to Gate 14. Gate 15 must publish the source-fixed Windows archive and owns reinstall/uninstall/retained-data release engineering. | -| 14 | Pass automatic-contribution and resource-control hardware checks | READY | [PR #11](https://github.com/flujo-app/CommunityAI/pull/11) and [PR #12](https://github.com/flujo-app/CommunityAI/pull/12) implemented authenticated node-authoritative sharing controls. [Gate 13 run `gate13-20260831-i`](evidence/gate13-20260831-i-manual-qualification-and-cleanup.json) now proves the real packaged Sharing UI starts, survives restart, and pauses on both supported platforms. | Run the remaining Gate 14 hardware-ceiling, suspension, placement, and recovery checks using the Gate 9 envelopes; do not repeat Gate 13 clean-install work. | +| 13 | Pass packaged clean-install inference on Windows and Linux | PASSED | [Manual run `gate13-20260831-i`](evidence/gate13-20260831-i-manual-qualification-and-cleanup.json) found and fixed the Windows legacy-MAX_PATH blocker while proving the literal clean-host flow. [Automated paid-cloud run `gate13-20260901-a`](evidence/gate13-20260901-a-automated-qualification-and-cleanup.json) translates that flow into two real-window sessions per platform from exact production packages. Formal attempt 1 passed unattended on Windows/Qwen and Linux/Gemma: package verification, four self-tests, inference, policy save, per-model normalization, literal Start, observation, full process restart, Pause, final paused intent, and Linux restart-resume plus second inference. The route passed exact 24/24 and 35/35 stable fences. All run instances, disks, and firewalls are absent, L4 usage is zero, and the protected bootstrap is running. | Proceed to Gate 14 without replaying Gate 13 discovery. Gate 15 owns reinstall/uninstall/retained-data release engineering. | +| 14 | Pass automatic-contribution and resource-control hardware checks | READY | [PR #11](https://github.com/flujo-app/CommunityAI/pull/11) and [PR #12](https://github.com/flujo-app/CommunityAI/pull/12) implemented authenticated node-authoritative sharing controls. [Automated Gate 13 run `gate13-20260901-a`](evidence/gate13-20260901-a-automated-qualification-and-cleanup.json) now proves the real packaged Sharing UI starts, survives restart, resumes on Linux, and pauses on both supported platforms without manual UI recovery. | Run the remaining Gate 14 hardware-ceiling, suspension, placement, and recovery checks using the Gate 9 envelopes; do not repeat Gate 13 clean-install work. | | 15 | Complete minimal alpha release engineering | WAITING | The desktop builder now emits a stable sorted `SHA256SUMS` inventory of exact regular-file bytes and safe relative in-bundle file symlinks, source/build/catalog-bound `provenance.json`, and `release-metadata.json` with explicit unsigned public-alpha, no-publisher-signature, no-authenticated-update, Windows/Linux-only, no-credits, and incomplete-qualification claims. Structural verification binds each safe file symlink to its canonical in-bundle target, digest, and size while rejecting changed, missing, extra, absolute, external, broken, cyclic, directory-linked/junction, special, traversal, or case-colliding payloads plus unsupported or noncanonical metadata. Exact-source builds also reject dirty relevant inputs, and the expected-input fresh-process check rejects rewritten commit/tree, workflow, platform, Python, PyInstaller, or catalog evidence. Production desktop CI is configured to verify and bundle the Gate 12 inputs, bind the exact clean Git commit/tree and workflow, revalidate every expected input separately, and upload all evidence on Windows/Linux. The focused release-input/artifact suite passes 15 tests, including fresh-process CLI, dirty-source, and canonical-rewrite checks, and the broader catalog/bootstrap/model/desktop subset passes 134. Independent verification reproduced all 134, passed 58 desktop unittests with two environment skips, formatting/import-order/YAML/diff checks, an expected Gate 12/workflow fresh-process probe, and real Windows junction rejection; no cloud was used. [The first PR #22 production-desktop run](https://github.com/flujo-app/CommunityAI/actions/runs/33273518744) reached packaging on both hosts and exposed two exact cross-platform defects: PyInstaller's legitimate relative internal Qt file symlink on Ubuntu and CRLF-transformed signed Gate 12 JSON on Windows. The follow-up binds safe internal file symlinks without accepting external or directory links, forces `public-alpha/**` to LF at checkout, and includes `.gitattributes` in the clean-source boundary. [The second run](https://github.com/flujo-app/CommunityAI/actions/runs/33274432423) proved the Ubuntu package and the Windows signed-bundle/provenance path, then exposed a stale desktop contribution-status schema 2 contract when the packaged node emitted schema 3 automatic-placement evidence. Source `fcd1f41` now strictly validates schema 3 placement and rejects stale schema 2 plus missing, extra, secret-bearing, or inconsistent placement data; its 50-test node/client/lifecycle/build focus and all 59 desktop unittests passed with two environment skips. [The final run](https://github.com/flujo-app/CommunityAI/actions/runs/33275216332) bound exact source `fcd1f417d1435557addb2d6cded9dac0827c7d8c` and completed both Windows and Ubuntu package jobs, including bundle build/smoke, independent checksum/provenance verification, the Windows packaged-node/native-credential/public-seed smoke, and artifact uploads; every PR style, test, and package check is green. Source `36d85d2` makes generic release-artifact fixtures select the supported Linux archive explicitly instead of inheriting the CI host platform; the 21-test local artifact suite and [PR #22 test run 33372581439](https://github.com/flujo-app/CommunityAI/actions/runs/33372581439) pass, without expanding the supported platform matrix. Gate 13 manual clean-install evidence now exists; Gate 15 still lacks its upgrade/reinstall/uninstall and retained-data release evidence. | Retain the verified Windows/Linux artifacts as engineering evidence, then test clean install, manual upgrade/reinstall, uninstall, retained-data choice for the persistent verified model cache, and recovery instructions on both platforms against a newly authorized live product-node route and the published Gate 9 envelopes. Do not mark passed from metadata/unit tests alone. Publisher signing and automatic authenticated update/rollback are post-alpha. | | 16 | Complete the bounded public-alpha safety canary | WAITING | [PR #13](https://github.com/flujo-app/CommunityAI/pull/13) and [PR #14](https://github.com/flujo-app/CommunityAI/pull/14) implemented bounded admission, privacy-safe aggregate health, training-off defaults, rollback procedures, and bounded routine rejection logs; no public canary has run. | After Gates 11–15, run a small monitored canary proving finite admission/timeouts, malformed-peer rejection, health reconstruction, privacy disclosure, route/catalog disable, and clean rollback. Exhaustive hostile-load, Sybil/collusion, partition, and long-soak campaigns are post-alpha. | | 17 | Publish and observe the public alpha | TODO | Owner has authorized a public inference alpha, but preceding mandatory alpha gates are open. | After Gate V and Gates 1–16 pass, publish with explicit best-effort availability, unsigned-package, support, and prompt-privacy limitations; preserve the disable path and monitor real route/worker failures. | @@ -139,7 +143,7 @@ starts a new epoch without rewriting that historical state. | Run | Provider | Purpose | Maximum estimate | Observed cost | Cleanup proof | State | | --- | --- | --- | ---: | ---: | --- | --- | -| gate13-20260901-a | GCP | Automated Gate 13 real-window replay against fresh production packages from `1476d67f3887dfd0de2acfb1305cbcca9975614f`: one bounded L4 route, then sequential ordinary-user Windows/Qwen and Linux/Gemma clients [plan `sha256:6687b9ba098b3f6676f48f4bf03ebb92bdc6a1278bf5bc1c227819b3a3e7cbb0`] | USD 56.00 | — | [Fresh authorization](evidence/gate13-20260901-a-cost-authorization.json); owner reports the comparable real-world run was approximately USD 10; cleanup required before terminal classification. | RESERVED | +| gate13-20260901-a | GCP | Automated Gate 13 real-window replay, finalized against production packages from `e904d36416a4f186c0bec05ff20210df9ca19848`: one bounded L4 route, then sequential ordinary-user Windows/Qwen and Linux/Gemma clients [original plan `sha256:6687b9ba098b3f6676f48f4bf03ebb92bdc6a1278bf5bc1c227819b3a3e7cbb0`] | USD 56.00 maximum, not forecast | — | [Passed automation and cleanup](evidence/gate13-20260901-a-automated-qualification-and-cleanup.json) records both formal attempt-1 passes, exact evidence digests, all instances/disks/firewalls absent, L4 usage zero, and the protected bootstrap running. Provider billing was not yet available; the owner reports comparable real-world use at approximately USD 10. | CLEANED-COMMITTED | | gate13-20260831-i | GCP | Final Gate 13 manual clean-host playthrough: Gate 11 route acceptance first, then sequential ordinary-user Windows/Qwen and Linux/Gemma desktop qualification with literal UI controls and post-restart inference [plan `sha256:8525c3099f273c099aba26de57c1f610a0c74cac65ed2640589d51e874bd0c44`] | USD 56.00 | — | [Passed qualification and cleanup](evidence/gate13-20260831-i-manual-qualification-and-cleanup.json) proves both exact archives, packaged self-tests, real desktop start/share/restart/pause flows, Qwen and Gemma inference, the Windows long-path product fix, all exact resources absent, L4 usage zero, and the protected bootstrap running. | CLEANED-COMMITTED | | gate13-20260831-h | GCP | Final corrected Gate 13 route-first lifecycle with both four-file release-audit bundles pinned and staged, the bounded Windows user-runtime environment, exact archive preflight, and sequential ordinary-user Windows/Qwen then Linux/Gemma clients [plan `sha256:f243254cc5fb65f44d0c9e707be36feb3284fd6e15b15620882843798fb456b1`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-h-failed-attempt-and-cleanup.json) records passed route acceptance and exact Windows archive verification, one Windows failure at `signed_bootstrap/product_readiness`, no Linux create, exact instance/disk/firewall absence, L4 usage zero, and protected-bootstrap health. | CLEANED-COMMITTED | | gate13-20260831-g | GCP | Corrected Gate 13 route-first lifecycle with a bounded standard Windows user-runtime environment, one durable foreground host-adapter execution as each ordinary OS user, exact archive preflight, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:f27f36158f2ad16019578555023cc854cb1e6e3b10ebae8cd3ed24d757b8e032`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-g-failed-attempt-and-cleanup.json) records passed route acceptance, the exact Windows archive, a two-second `package_verification` failure caused by four omitted existing audit inputs, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | diff --git a/docs/evidence/gate13-20260901-a-automated-qualification-and-cleanup.json b/docs/evidence/gate13-20260901-a-automated-qualification-and-cleanup.json new file mode 100644 index 000000000..841d622fd --- /dev/null +++ b/docs/evidence/gate13-20260901-a-automated-qualification-and-cleanup.json @@ -0,0 +1,238 @@ +{ + "schema_version": 1, + "scope": "gate13-automated-paid-cloud-qualification-and-cleanup", + "gate": 13, + "run_id": "gate13-20260901-a", + "result": "passed", + "recorded_at": "2026-09-02T08:54:19Z", + "goal": "Replay the successful manual Gate 13 clean-host desktop procedure automatically on Windows and Linux against the paid public route, without manual UI recovery.", + "authorization": { + "evidence": "gate13-20260901-a-cost-authorization.json", + "owner_reset_recorded": true, + "maximum_lifetime_reservation_usd": "56.00", + "maximum_is_not_a_bill_forecast": true, + "cost_note": "The owner reports the comparable real-world replay cost is approximately USD 10. Final provider billing for this run was not available at cleanup time." + }, + "production_packages": { + "requested_source_commit": "e904d36416a4f186c0bec05ff20210df9ca19848", + "workflow_merge_commit": "f83c19997d6180c784e2a85f8d5d68c4361ad99e", + "workflow_merge_parents": [ + "f64a388a47b098ac7f69d2affc59816376b43bb1", + "e904d36416a4f186c0bec05ff20210df9ca19848" + ], + "workflow_run": 33600715239, + "workflow_result": "success", + "style_run": 33600715224, + "style_result": "success", + "test_run": 33600715198, + "test_result": "success", + "windows": { + "workflow_artifact": "communityai-desktop-install-windows", + "artifact_id": 9835635064, + "wrapper_sha256": "sha256:d2e6a90b881838b6f738924c3dc222cb7056bf121dc9ed91c56ea055760aa329", + "wrapper_bytes": 2695087981, + "archive_sha256": "sha256:965c24c3235dd5e4621961376e0d563bb50e81ed214297e2826c0a2454accfe5", + "archive_bytes": 2695087805, + "audit_artifact_id": 9835635695, + "audit_artifact_sha256": "sha256:2007bca3fd77d7debacf2fec7f2975b6ee3e3a7d02f29e8baa7014915d57b407" + }, + "linux": { + "workflow_artifact": "communityai-desktop-install-linux", + "artifact_id": 9835679452, + "wrapper_sha256": "sha256:2712b6adc33f9b932b359afe97764a4361dc6f286342c52b46d09aa536a7389d", + "wrapper_bytes": 3360754507, + "archive_sha256": "sha256:9f7c8629f3f91f1a1b291e2f3f7e1019d1497440ed399b08c837c887f2b0107a", + "archive_bytes": 3360754329, + "audit_artifact_id": 9835680252, + "audit_artifact_sha256": "sha256:dc47d2406b84fba40811b9d2bec44e2e99c18992e049179712ea6bb76485d543" + } + }, + "manual_findings_translated": [ + { + "source": "FLUJO conversation 264a0383-8cbf-4e0a-9073-1ae6072d19fe narration and tool calls", + "finding": "The first inference can return Model unavailable while the exact selected model is still becoming complete.", + "automation": "Poll /v1/models every five seconds for up to 90 seconds, then replay the exact one-token model:auto request once." + }, + { + "source": "FLUJO manual desktop sequence", + "finding": "Sharing controls are page-scoped and the manual run navigated to Sharing before editing policy or clicking master controls.", + "automation": "Open the Sharing page before every sharing action and retain the accessible-name, toggle, selection, legacy, and focus/Enter control fallbacks." + }, + { + "source": "FLUJO sequence 17517-17532", + "finding": "The manual run saved the policy, toggled Share compute with the selected model, clicked Start sharing, observed it, then clicked Pause sharing.", + "automation": "If policy reconciliation already enabled sharing, click the exact checked per-model control to restore the paused baseline before exercising literal Start and Pause." + } + ], + "automation_source": { + "inference_recovery_commit": "05fbe4fd40e1daa9f33fabfe6ca5fedc9a6798d6", + "sharing_page_commit": "984aef348c2b09e1a8383bb74873525d62065db8", + "automatic_sharing_normalization_commit": "e904d36416a4f186c0bec05ff20210df9ca19848", + "route_service_timeout_commit": "b093b850625235b9fa6a10605d15005511ee15f6", + "route_stale_advertisement_commit": "66f440bc02d7920b0a697b4095c243c0ff17ae78", + "fresh_linux_supervisor_commit": "4c6eaca8b0de8d20885c932e5bdcd1f50fc67947", + "automated_playthrough": { + "path": "scripts/gate13_automated_playthrough.py", + "sha256": "sha256:9ffda923a37ef64631898ad82139457a329393f15b78afd45709611f7f4a087f", + "bytes": 20545 + }, + "lifecycle_validator": { + "path": "scripts/gate13_packaged_lifecycle.py", + "sha256": "sha256:c899ffb162aef49e7dc54c1e62a86505713652e341bd32f77f123e76bde8d1d4", + "bytes": 33278 + }, + "final_host_job_adapter": { + "path": "scripts/gate13_host_job.py", + "sha256": "sha256:79a3220ff06faa1c359feb771903d3d842c004b122244cfef161945403fe91d9", + "bytes": 45781 + }, + "final_route_fence": { + "path": "scripts/gate13_route_fence.py", + "sha256": "sha256:e90f48bbf2e582ef7bae47e71c8e635e69f94dc86c8a7b5197a0d079af1bd5a5", + "bytes": 9907 + } + }, + "prequalification_diagnostics": { + "manual_ui_assistance_accepted_as_qualification": false, + "client_attempts_started_before_final_windows_run": 0, + "client_attempts_started_before_final_linux_run": 0, + "findings": [ + "A Windows diagnostic run proved that clicking master controls while the Sharing page was hidden could wait indefinitely; navigation was added and tested.", + "A later Windows diagnostic showed policy reconciliation can auto-start the selected worker before the automation reaches Start; the exact FLUJO per-model toggle sequence was restored.", + "The first Linux route fence failed closed after an API-side bootstrap startup failure. A later live retry exposed and fixed the bounded service-action and stale-DHT-advertisement cases.", + "The Linux host adapter rejected the fresh host before creating a unit because Ubuntu omits ExecStart for LoadState=not-found; the exact fresh inventory is now accepted while extra fields remain rejected." + ] + }, + "route": { + "instance": "route-20260901-a-node", + "machine_type": "g2-standard-8", + "accelerator": "1 x NVIDIA L4", + "runtime_source_commit": "f64a388a47b098ac7f69d2affc59816376b43bb1", + "windows_fence": { + "result": "passed", + "target": "windows", + "model_id": "Qwen3.5 2B", + "manifest_digest": "sha256:3ba8528cb3c0d85e1ed048e0438a0d64cfbbc298944ed674caa6950d415f8e33", + "covered_blocks": 24, + "total_blocks": 24, + "peer_count_minimum": 1, + "stable_rechecks": 2, + "standby_service_stopped": true + }, + "linux_fence": { + "result": "passed", + "target": "linux", + "model_id": "Gemma 4 E2B IT", + "manifest_digest": "sha256:2f8debbe0fcdf5af8d4c56c982210fa50aa584314968ae2617e2ccc2de9eafdd", + "covered_blocks": 35, + "total_blocks": 35, + "peer_count_minimum": 1, + "stable_rechecks": 2, + "standby_service_stopped": true + } + }, + "windows": { + "result": "passed", + "attempt_ordinal": 1, + "instance": "gate13-20260901-a-win", + "machine_type": "e2-standard-8", + "image": "windows-server-2025-dc-v20260814", + "ordinary_user": "M", + "interactive_console_session": true, + "manual_ui_actions_after_launch": 0, + "evidence_digest": "sha256:764c63b24b0339ded38862d26787b236699ab7c2fc4c15ddaf2717218c4e5adb", + "real_window_sessions": 2, + "localhost_inference_count": 1, + "policy_dialog_saved": true, + "start_clicked": true, + "start_observation_seconds": 25.0, + "pause_control_observed": true, + "pause_clicked": true, + "sharing_intent_paused": true, + "session_duration_seconds": { + "initial": 260.828, + "restart": 66.328 + }, + "qualification_temporaries_removed": true + }, + "linux": { + "result": "passed", + "attempt_ordinal": 1, + "instance": "gate13-20260901-a-linux", + "machine_type": "e2-standard-8", + "image": "ubuntu-2404-noble-amd64-v20260826", + "ordinary_user": "gate13", + "display": "TCP-disabled Xvfb X11 with private D-Bus and native Secret Service", + "manual_ui_actions_after_launch": 0, + "evidence_digest": "sha256:624bcfd01763ac81b1ebfac50aee196988bbbdd30f94610997464faa6dfe637c", + "real_window_sessions": 2, + "localhost_inference_count": 2, + "policy_dialog_saved": true, + "start_clicked": true, + "start_observation_seconds": 20.0, + "restart_resume_observed": true, + "pause_control_observed": true, + "pause_clicked": true, + "sharing_intent_paused": true, + "session_duration_seconds": { + "initial": 229.269578, + "restart": 44.956091 + }, + "qualification_temporaries_removed": true + }, + "validation": { + "desktop_gate13_tests": { + "passed": 215, + "failed": 0 + }, + "route_fence_tests": { + "passed": 5, + "failed": 0 + }, + "host_job_tests": { + "passed": 28, + "failed": 0 + }, + "latest_source_style_run": 33610030286, + "latest_source_style_result": "success", + "latest_source_test_run": 33610030231, + "latest_source_test_result": "success", + "windows_evidence_validator_result": "passed", + "linux_evidence_validator_result": "passed" + }, + "cleanup": { + "exact_instances_absent": [ + "route-20260901-a-node", + "gate13-20260901-a-win", + "gate13-20260901-a-linux" + ], + "exact_disks_absent": [ + "route-20260901-a-node", + "gate13-20260901-a-win", + "gate13-20260901-a-linux" + ], + "exact_firewalls_absent": [ + "route-20260901-a-dht", + "route-20260901-a-iap", + "route-20260901-a-relay" + ], + "regional_l4_quota": { + "metric": "NVIDIA_L4_GPUS", + "limit": 1, + "usage": 0 + }, + "protected_bootstrap": { + "name": "communityai-bootstrap-1", + "status": "RUNNING" + }, + "passed": true + }, + "privacy": { + "prompts_retained": false, + "outputs_retained": false, + "token_identifiers_retained": false, + "credentials_retained": false, + "signed_urls_retained": false, + "provider_endpoints_retained": false + } +} diff --git a/docs/evidence/gate13-20260901-a-cost-authorization.json b/docs/evidence/gate13-20260901-a-cost-authorization.json index 9e2d7f9aa..90318ac33 100644 --- a/docs/evidence/gate13-20260901-a-cost-authorization.json +++ b/docs/evidence/gate13-20260901-a-cost-authorization.json @@ -197,5 +197,24 @@ "retain_credentials": false, "retain_signed_urls": false, "retain_provider_endpoints": false + }, + "completion": { + "result": "passed", + "recorded_at": "2026-09-02T08:54:19Z", + "qualification_evidence": "gate13-20260901-a-automated-qualification-and-cleanup.json", + "package_source_commit": "e904d36416a4f186c0bec05ff20210df9ca19848", + "final_route_fence_commit": "66f440bc02d7920b0a697b4095c243c0ff17ae78", + "final_host_job_adapter_commit": "4c6eaca8b0de8d20885c932e5bdcd1f50fc67947", + "windows_attempt_ordinal": 1, + "linux_attempt_ordinal": 1, + "manual_ui_recovery_accepted": false, + "reservation_state": "cleaned-committed-pending-provider-billing", + "provider_billing_available": false, + "cost_note": "The USD 56 value was a maximum-lifetime fail-safe reservation, not an estimate of the provider bill. The owner reports comparable real-world use at approximately USD 10.", + "exact_run_instances_absent": true, + "exact_run_disks_absent": true, + "exact_run_firewalls_absent": true, + "regional_l4_usage": 0, + "protected_bootstrap_running": true } } From b03ef94d6c60f3cfb86528efcab731fc223a8151 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:56:36 -0500 Subject: [PATCH 54/56] docs: restore machine-readable spend ledger --- docs/RELEASE_READINESS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md index 7108e25c4..86bf1e630 100644 --- a/docs/RELEASE_READINESS.md +++ b/docs/RELEASE_READINESS.md @@ -143,7 +143,7 @@ starts a new epoch without rewriting that historical state. | Run | Provider | Purpose | Maximum estimate | Observed cost | Cleanup proof | State | | --- | --- | --- | ---: | ---: | --- | --- | -| gate13-20260901-a | GCP | Automated Gate 13 real-window replay, finalized against production packages from `e904d36416a4f186c0bec05ff20210df9ca19848`: one bounded L4 route, then sequential ordinary-user Windows/Qwen and Linux/Gemma clients [original plan `sha256:6687b9ba098b3f6676f48f4bf03ebb92bdc6a1278bf5bc1c227819b3a3e7cbb0`] | USD 56.00 maximum, not forecast | — | [Passed automation and cleanup](evidence/gate13-20260901-a-automated-qualification-and-cleanup.json) records both formal attempt-1 passes, exact evidence digests, all instances/disks/firewalls absent, L4 usage zero, and the protected bootstrap running. Provider billing was not yet available; the owner reports comparable real-world use at approximately USD 10. | CLEANED-COMMITTED | +| gate13-20260901-a | GCP | Automated Gate 13 real-window replay, finalized against production packages from `e904d36416a4f186c0bec05ff20210df9ca19848`: one bounded L4 route, then sequential ordinary-user Windows/Qwen and Linux/Gemma clients [original plan `sha256:6687b9ba098b3f6676f48f4bf03ebb92bdc6a1278bf5bc1c227819b3a3e7cbb0`] | USD 56.00 | — | [Passed automation and cleanup](evidence/gate13-20260901-a-automated-qualification-and-cleanup.json) records both formal attempt-1 passes, exact evidence digests, all instances/disks/firewalls absent, L4 usage zero, and the protected bootstrap running. Provider billing was not yet available; the owner reports comparable real-world use at approximately USD 10. | CLEANED-COMMITTED | | gate13-20260831-i | GCP | Final Gate 13 manual clean-host playthrough: Gate 11 route acceptance first, then sequential ordinary-user Windows/Qwen and Linux/Gemma desktop qualification with literal UI controls and post-restart inference [plan `sha256:8525c3099f273c099aba26de57c1f610a0c74cac65ed2640589d51e874bd0c44`] | USD 56.00 | — | [Passed qualification and cleanup](evidence/gate13-20260831-i-manual-qualification-and-cleanup.json) proves both exact archives, packaged self-tests, real desktop start/share/restart/pause flows, Qwen and Gemma inference, the Windows long-path product fix, all exact resources absent, L4 usage zero, and the protected bootstrap running. | CLEANED-COMMITTED | | gate13-20260831-h | GCP | Final corrected Gate 13 route-first lifecycle with both four-file release-audit bundles pinned and staged, the bounded Windows user-runtime environment, exact archive preflight, and sequential ordinary-user Windows/Qwen then Linux/Gemma clients [plan `sha256:f243254cc5fb65f44d0c9e707be36feb3284fd6e15b15620882843798fb456b1`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-h-failed-attempt-and-cleanup.json) records passed route acceptance and exact Windows archive verification, one Windows failure at `signed_bootstrap/product_readiness`, no Linux create, exact instance/disk/firewall absence, L4 usage zero, and protected-bootstrap health. | CLEANED-COMMITTED | | gate13-20260831-g | GCP | Corrected Gate 13 route-first lifecycle with a bounded standard Windows user-runtime environment, one durable foreground host-adapter execution as each ordinary OS user, exact archive preflight, and sequential Windows/Qwen then Linux/Gemma clients [plan `sha256:f27f36158f2ad16019578555023cc854cb1e6e3b10ebae8cd3ed24d757b8e032`] | USD 56.00 | — | [Failed attempt and cleanup](evidence/gate13-20260831-g-failed-attempt-and-cleanup.json) records passed route acceptance, the exact Windows archive, a two-second `package_verification` failure caused by four omitted existing audit inputs, no Linux create, and exact instance/disk/firewall cleanup with L4 usage zero. | CLEANED-COMMITTED | From c0f2342e15aa7e12ca7c2980deca64d613204143 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:08:24 -0500 Subject: [PATCH 55/56] gate14: bind hardware qualification lifecycle --- CHANGELOG.md | 9 + docs/RELEASE_READINESS.md | 2 +- scripts/gate14_hardware_acceptance.py | 861 ++++++++++++++++++++++ scripts/gate14_run_controller.py | 881 +++++++++++++++++++++++ tests/test_gate14_hardware_acceptance.py | 492 +++++++++++++ tests/test_gate14_run_controller.py | 729 +++++++++++++++++++ 6 files changed, 2973 insertions(+), 1 deletion(-) create mode 100644 scripts/gate14_hardware_acceptance.py create mode 100644 scripts/gate14_run_controller.py create mode 100644 tests/test_gate14_hardware_acceptance.py create mode 100644 tests/test_gate14_run_controller.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d23deeae6..d2e6e874c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and qualification evidence remains in `docs/REVIVAL_TEST_RESULTS.md`. ### Added +- Gate 14 hardware acceptance now has a fail-closed, source-bound verifier and durable GCP + lifecycle controller. They pin the exact Gate 13 packaged lifecycle and Windows/Qwen plus + Linux/Gemma Gate 9 envelopes, enforce the USD 100 aggregate ledger ceiling, serialize fresh + L4 hosts, bind host-reported evidence before collection, and require cleanup proof for the + exact authorization bytes, controller source, provider plan, project, zone, resources, + and successful terminal state while excluding the protected bootstrap from cleanup targets. + The focused 49-test suite covers deadline, rollback, orphan/returned-resource inventory, forged-state, + evidence-substitution, cross-platform, hidden/excess spend, and cleanup-binding failures. This is software evidence only: + no cloud resource or Gate 14 hardware pass is claimed yet. - Gate 13's successful manual desktop flow now has a bounded automated replay. The production package can open its real Qt window in a hidden qualification mode, perform localhost inference, save the actual sharing-policy dialog, click Start, exit, relaunch, diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md index 86bf1e630..32e5336fa 100644 --- a/docs/RELEASE_READINESS.md +++ b/docs/RELEASE_READINESS.md @@ -102,7 +102,7 @@ longer consume the new authorization; later billing should still be recorded for | 11 | Operate initial public alpha routes | PASSED | [Product-node run `route-20260830-j`](evidence/gate11node-20260830-a-lifecycle.json) installed the generic CommunityAI wheel on a bounded G2/L4 VM, verified the signed catalog, downloaded both exact manifested models directly from Hugging Face into one persistent shared cache, and used the product node's automatic workers to expose complete Qwen 24/24 primary and Gemma 35/35 standby routes. No model-specific image, cache mirror, or operator-transferred model artifact was used. The privacy-safe acceptance passed one-token primary inference, deliberate primary pause, automatic Gemma selection in 58.073 seconds, standby inference, Qwen restoration in 32.042 seconds, and restored inference. Both workers were stable before the drill. After Gate 13 released the L4, the preserved route was restored without changing its model cache or source, its ephemeral endpoint was rebound, both product-node services became active, and a fresh acceptance reproved Qwen 24/24 primary inference, automatic Gemma 35/35 fallback/inference, Qwen restoration, and restored inference. The protected bootstrap remains running. A corrected 4,800-second provider DELETE backstop was set for `2026-08-31T05:28:16.516Z`, earlier than the original deadline. [Post-backstop cleanup evidence](evidence/gate11route-20260830-j-backstop-cleanup.json) and an independent recheck prove the route instance, named disk, and both exact run-scoped firewall rules absent, all Gate 13 clients/disks absent, zero remaining route availability, and the protected bootstrap still running. The same-host standby is a bounded alpha fallback, not independent infrastructure redundancy; independent redundancy remains post-alpha. | Gate 11 acceptance evidence remains complete, but no product route is live after the corrected DELETE backstop. [Gate 13 run `gate13-20260831-a`](evidence/gate13-20260831-a-cost-authorization.json) now binds refreshed native authentication, fail-closed preflight, and a fresh USD 52 reservation for the replacement route and packaged clients. | | 12 | Create, publish, and bundle the minimal signed alpha catalog/bootstrap | PASSED | [Run `gate12-20260829-a`](evidence/gate12-20260829-alpha-catalog-publication.json) published the deterministic [`communityai-public-alpha-v1` bundle](../public-alpha/catalog-v1/bundle.json) from source `26be579`. Its threshold-one Ed25519 root signs sequence 1 with the exact qualified Qwen primary and Gemma standby manifests, one pinned public HTTPS mirror, one public seed, a one-route best-effort policy, and no unprovisioned route-demand roots. The canonical bundle binds five members and retains `complete_release_qualification=false`. All three public objects returned HTTP 200 with exact sizes, and a fresh empty consumer fetched them remotely, verified the signature/digests, and created the two-model `auto` node configuration. The private signing key remained ignored and uncommitted. The focused publication suite passes 32 tests, the catalog/bootstrap/model/desktop superset passes 92, and the run spent USD 0. | Preserve the branch-scoped mirror until a newly signed catalog sequence and packaged bootstrap migrate it. The Gate 11 acceptance and Gate 9 envelopes exist; [Gate 13 run `gate13-20260831-a`](evidence/gate13-20260831-a-cost-authorization.json) now authorizes the bounded replacement route and fresh packaged clients under the new epoch. Independent threshold holders and interchangeable mirror/seed governance are post-alpha. | | 13 | Pass packaged clean-install inference on Windows and Linux | PASSED | [Manual run `gate13-20260831-i`](evidence/gate13-20260831-i-manual-qualification-and-cleanup.json) found and fixed the Windows legacy-MAX_PATH blocker while proving the literal clean-host flow. [Automated paid-cloud run `gate13-20260901-a`](evidence/gate13-20260901-a-automated-qualification-and-cleanup.json) translates that flow into two real-window sessions per platform from exact production packages. Formal attempt 1 passed unattended on Windows/Qwen and Linux/Gemma: package verification, four self-tests, inference, policy save, per-model normalization, literal Start, observation, full process restart, Pause, final paused intent, and Linux restart-resume plus second inference. The route passed exact 24/24 and 35/35 stable fences. All run instances, disks, and firewalls are absent, L4 usage is zero, and the protected bootstrap is running. | Proceed to Gate 14 without replaying Gate 13 discovery. Gate 15 owns reinstall/uninstall/retained-data release engineering. | -| 14 | Pass automatic-contribution and resource-control hardware checks | READY | [PR #11](https://github.com/flujo-app/CommunityAI/pull/11) and [PR #12](https://github.com/flujo-app/CommunityAI/pull/12) implemented authenticated node-authoritative sharing controls. [Automated Gate 13 run `gate13-20260901-a`](evidence/gate13-20260901-a-automated-qualification-and-cleanup.json) now proves the real packaged Sharing UI starts, survives restart, resumes on Linux, and pauses on both supported platforms without manual UI recovery. | Run the remaining Gate 14 hardware-ceiling, suspension, placement, and recovery checks using the Gate 9 envelopes; do not repeat Gate 13 clean-install work. | +| 14 | Pass automatic-contribution and resource-control hardware checks | IN PROGRESS | [PR #11](https://github.com/flujo-app/CommunityAI/pull/11) and [PR #12](https://github.com/flujo-app/CommunityAI/pull/12) implemented authenticated node-authoritative sharing controls. [Automated Gate 13 run `gate13-20260901-a`](evidence/gate13-20260901-a-automated-qualification-and-cleanup.json) proves the real packaged Sharing UI starts, survives restart, resumes on Linux, and pauses on both supported platforms without manual UI recovery. The new strict Gate 14 verifier pins that lifecycle evidence plus the exact Windows/Qwen and Linux/Gemma Gate 9 envelopes, platform/OS pair, production package, L4 device profile, all five configured resource classes, suspension/resume, automatic block placement, recovery, unsupported CPU power telemetry, privacy, and exact GCP cleanup. Its durable controller recomputes the reset current-epoch spend ledger under the USD 100 ceiling, rejects expired/foreign/overlapping resources, serializes fresh Windows then Linux hosts, binds reported evidence bytes before collection, rejects rolled-back completed jobs, hidden active reservations, orphan planned disks before every fresh start, and resources that return during teardown, and requires cleanup to match the exact authorization file, controller source, plan, project, zone, resources, and successful terminal state while excluding the protected bootstrap. The 49-test focused suite, formatting, import-order, compilation, and diff checks pass locally. No provider resource or Gate 14 pass is claimed by this software evidence. | Push the independently adversarially reviewed source-bound controller/verifier, then reserve at most the remaining USD 44 only after native-auth, quota, exact-inventory, pricing, and cleanup preflight. Run only the remaining Gate 14 checks sequentially on fresh Windows/Linux L4 hosts; reuse the cited Gate 13 lifecycle evidence instead of repeating clean install. | | 15 | Complete minimal alpha release engineering | WAITING | The desktop builder now emits a stable sorted `SHA256SUMS` inventory of exact regular-file bytes and safe relative in-bundle file symlinks, source/build/catalog-bound `provenance.json`, and `release-metadata.json` with explicit unsigned public-alpha, no-publisher-signature, no-authenticated-update, Windows/Linux-only, no-credits, and incomplete-qualification claims. Structural verification binds each safe file symlink to its canonical in-bundle target, digest, and size while rejecting changed, missing, extra, absolute, external, broken, cyclic, directory-linked/junction, special, traversal, or case-colliding payloads plus unsupported or noncanonical metadata. Exact-source builds also reject dirty relevant inputs, and the expected-input fresh-process check rejects rewritten commit/tree, workflow, platform, Python, PyInstaller, or catalog evidence. Production desktop CI is configured to verify and bundle the Gate 12 inputs, bind the exact clean Git commit/tree and workflow, revalidate every expected input separately, and upload all evidence on Windows/Linux. The focused release-input/artifact suite passes 15 tests, including fresh-process CLI, dirty-source, and canonical-rewrite checks, and the broader catalog/bootstrap/model/desktop subset passes 134. Independent verification reproduced all 134, passed 58 desktop unittests with two environment skips, formatting/import-order/YAML/diff checks, an expected Gate 12/workflow fresh-process probe, and real Windows junction rejection; no cloud was used. [The first PR #22 production-desktop run](https://github.com/flujo-app/CommunityAI/actions/runs/33273518744) reached packaging on both hosts and exposed two exact cross-platform defects: PyInstaller's legitimate relative internal Qt file symlink on Ubuntu and CRLF-transformed signed Gate 12 JSON on Windows. The follow-up binds safe internal file symlinks without accepting external or directory links, forces `public-alpha/**` to LF at checkout, and includes `.gitattributes` in the clean-source boundary. [The second run](https://github.com/flujo-app/CommunityAI/actions/runs/33274432423) proved the Ubuntu package and the Windows signed-bundle/provenance path, then exposed a stale desktop contribution-status schema 2 contract when the packaged node emitted schema 3 automatic-placement evidence. Source `fcd1f41` now strictly validates schema 3 placement and rejects stale schema 2 plus missing, extra, secret-bearing, or inconsistent placement data; its 50-test node/client/lifecycle/build focus and all 59 desktop unittests passed with two environment skips. [The final run](https://github.com/flujo-app/CommunityAI/actions/runs/33275216332) bound exact source `fcd1f417d1435557addb2d6cded9dac0827c7d8c` and completed both Windows and Ubuntu package jobs, including bundle build/smoke, independent checksum/provenance verification, the Windows packaged-node/native-credential/public-seed smoke, and artifact uploads; every PR style, test, and package check is green. Source `36d85d2` makes generic release-artifact fixtures select the supported Linux archive explicitly instead of inheriting the CI host platform; the 21-test local artifact suite and [PR #22 test run 33372581439](https://github.com/flujo-app/CommunityAI/actions/runs/33372581439) pass, without expanding the supported platform matrix. Gate 13 manual clean-install evidence now exists; Gate 15 still lacks its upgrade/reinstall/uninstall and retained-data release evidence. | Retain the verified Windows/Linux artifacts as engineering evidence, then test clean install, manual upgrade/reinstall, uninstall, retained-data choice for the persistent verified model cache, and recovery instructions on both platforms against a newly authorized live product-node route and the published Gate 9 envelopes. Do not mark passed from metadata/unit tests alone. Publisher signing and automatic authenticated update/rollback are post-alpha. | | 16 | Complete the bounded public-alpha safety canary | WAITING | [PR #13](https://github.com/flujo-app/CommunityAI/pull/13) and [PR #14](https://github.com/flujo-app/CommunityAI/pull/14) implemented bounded admission, privacy-safe aggregate health, training-off defaults, rollback procedures, and bounded routine rejection logs; no public canary has run. | After Gates 11–15, run a small monitored canary proving finite admission/timeouts, malformed-peer rejection, health reconstruction, privacy disclosure, route/catalog disable, and clean rollback. Exhaustive hostile-load, Sybil/collusion, partition, and long-soak campaigns are post-alpha. | | 17 | Publish and observe the public alpha | TODO | Owner has authorized a public inference alpha, but preceding mandatory alpha gates are open. | After Gate V and Gates 1–16 pass, publish with explicit best-effort availability, unsigned-package, support, and prompt-privacy limitations; preserve the disable path and monitor real route/worker failures. | diff --git a/scripts/gate14_hardware_acceptance.py b/scripts/gate14_hardware_acceptance.py new file mode 100644 index 000000000..9b43a56d9 --- /dev/null +++ b/scripts/gate14_hardware_acceptance.py @@ -0,0 +1,861 @@ +"""Validate privacy-safe Gate 14 packaged hardware evidence. + +The real host probes are deliberately separate from this verifier. They may use private +paths and provider details while running, but only the strict bounded documents accepted +here can enter the evidence archive. The aggregate binds the exact controller source, +production packages, manifests, Gate 9 envelopes, device profiles, and final cleanup. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import re +import stat +from decimal import Decimal, InvalidOperation +from pathlib import Path +from typing import Any, Mapping, Sequence + +SCHEMA_VERSION = 1 +PLATFORM_SCOPE = "gate14-packaged-hardware" +CLEANUP_SCOPE = "gate14-provider-cleanup" +AGGREGATE_SCOPE = "gate14-hardware-acceptance" +MAX_INPUT_BYTES = 262_144 +MAX_DURATION_SECONDS = 300.0 +MAX_BYTES = 1 << 50 +MAX_BLOCKS = 512 +PROTECTED_INSTANCE = "communityai-bootstrap-1" + +_RUN_RE = re.compile(r"[a-z0-9][a-z0-9-]{0,62}") +_COMMIT_RE = re.compile(r"[0-9a-f]{40}") +_DIGEST_RE = re.compile(r"sha256:[0-9a-f]{64}") +_NAME_RE = re.compile(r"[a-z](?:[-a-z0-9]{0,61}[a-z0-9])?") +_PROJECT_RE = re.compile(r"[a-z][a-z0-9-]{4,28}[a-z0-9]") +_ZONE_RE = re.compile(r"[a-z]+(?:-[a-z0-9]+)+-[a-z]") +_OS_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9 ._+()/-]{0,127}") + +MODEL_PROFILES = { + "Qwen3.5 2B": { + "manifest_digest": "sha256:3ba8528cb3c0d85e1ed048e0438a0d64cfbbc298944ed674caa6950d415f8e33", + "revision_commit": "15852e8c16360a2fea060d615a32b45270f8a8fc", + "selected_artifact_count": 8, + "selected_artifact_bytes": 4_571_197_320, + "total_blocks": 24, + }, + "Gemma 4 E2B IT": { + "manifest_digest": "sha256:2f8debbe0fcdf5af8d4c56c982210fa50aa584314968ae2617e2ccc2de9eafdd", + "revision_commit": "3e22461f65e89153144f8adb70e3b8c2cc9845a7", + "selected_artifact_count": 5, + "selected_artifact_bytes": 10_278_818_149, + "total_blocks": 35, + }, +} +EXPECTED_PLATFORM_MODELS = {"windows": "Qwen3.5 2B", "linux": "Gemma 4 E2B IT"} +EXPECTED_PLATFORM_OS = {"windows": "Windows Server 2022", "linux": "Ubuntu 24.04"} +EXPECTED_GATE9_ENVELOPES = { + "windows": "sha256:cd68afb67d9b0f3cb8c82db0d3314ad89b558c20880998ea4d8c4493e9f4bc9f", + "linux": "sha256:2eb0bcf6419ba085665fad34310453a1b9dc2e89d90e9177f41566df012996c8", +} +EXPECTED_GATE13_EVIDENCE_SHA256 = "sha256:ad4f892f4af9a9aee0dd428d74695981d0cca6241f79c0270c9fcea3a229b72e" + +_DOCUMENT_FIELDS = { + "schema_version", + "scope", + "run_id", + "platform", + "result", + "source_commit", + "gate13_evidence_sha256", + "package", + "model", + "hardware", + "cache", + "placement", + "limits", + "suspensions", + "recovery", + "pause", + "restart", + "unsupported_telemetry", + "privacy", + "qualification_temporaries_removed", +} +_PACKAGE_FIELDS = { + "source_commit", + "archive_sha256", + "archive_bytes", + "release_metadata_sha256", +} +_MODEL_FIELDS = { + "id", + "manifest_digest", + "revision_commit", + "gate9_envelope_sha256", + "selected_artifact_count", + "selected_artifact_bytes", + "total_blocks", +} +_HARDWARE_FIELDS = { + "os_name", + "accelerator", + "accelerator_count", + "accelerator_memory_bytes", +} +_CACHE_FIELDS = { + "verified_bytes_before", + "verified_bytes_after", + "transfer_bytes_during_gate", + "digest_mismatch_count", + "forbidden_model_acquired", +} +_PLACEMENT_FIELDS = { + "automatic", + "worker_count", + "block_start", + "block_end", + "intent_published", + "remote_acknowledged", +} +_LIMIT_FIELDS = { + "disk_bytes", + "vram_bytes", + "bandwidth_mbps", + "power_watts", + "schedule_timezone", + "resource_limit_count", + "configured_and_resolved_match", + "low_vram_rejected", +} +_SUSPENSION_FIELDS = { + "kind", + "suspended", + "resumed", + "desired_intent_preserved", + "worker_count_during", + "duration_seconds", +} +_RECOVERY_FIELDS = { + "worker_crash_observed", + "worker_restarted", + "restart_seconds", + "previous_worker_absent", + "manifest_unchanged", + "automatic_block_range_valid", + "desired_intent_preserved", +} +_PAUSE_FIELDS = { + "requested", + "completed", + "duration_seconds", + "worker_count_after", + "descendant_count_after", +} +_RESTART_FIELDS = { + "node_restarted", + "policy_persisted", + "desired_intent_persisted", + "worker_resumed", + "duration_seconds", + "cache_reused", +} +_UNSUPPORTED_FIELDS = { + "device", + "configured_limit", + "start_rejected", + "reason_code", + "private_detail_retained", +} +_PRIVACY_FIELDS = { + "prompt_retained", + "response_retained", + "token_identifiers_retained", + "credentials_retained", + "paths_retained", + "endpoints_retained", + "provider_output_retained", +} +_CLEANUP_FIELDS = { + "schema_version", + "scope", + "run_id", + "result", + "provider", + "controller_source_commit", + "provider_plan_digest", + "project", + "zone", + "deleted_instances", + "deleted_disks", + "controller_terminal_state_sha256", + "native_auth_revalidated", + "expected_instances", + "remaining_instances", + "expected_disks", + "remaining_disks", + "remaining_firewalls", + "l4_usage", + "protected_bootstrap_running", + "product_processes_remaining", + "temporary_credentials_remaining", +} +_TERMINAL_STATE_FIELDS = { + "schema_version", + "run_id", + "authorization_sha256", + "provider_plan_digest", + "revision", + "phase", + "failure_code", + "windows_evidence_digest", + "linux_evidence_digest", + "windows_consumed", + "linux_consumed", + "cleanup_verified", + "next_action", +} +_AUTH_FIELDS = { + "schema_version", + "gate", + "result", + "run_id", + "source_commit", + "provider_plan_digest", + "provider_plan", + "authorization", + "prohibited", +} +_AUTHORIZATION_FIELDS = { + "combined_cloud_ceiling_usd", + "ledger_committed_before_run_usd", + "maximum_estimate_usd", + "remaining_after_run_maximum_usd", + "reservation_recorded", + "native_auth_revalidated", + "provisioning_authorized_after_fail_closed_preflight", +} +_PLAN_FIELDS = {"project", "zone", "clients", "sequencing"} +_CLIENT_PLAN_FIELDS = { + "platform", + "instance", + "disk", + "source_commit", + "termination_unix", + "package_sha256", + "model_id", + "manifest_digest", +} +_SEQUENCING_FIELDS = { + "clients_may_run_concurrently", + "windows_first", + "fresh_host_per_platform", +} + + +class Gate14EvidenceError(ValueError): + """A Gate 14 input was malformed, unsafe, incomplete, or inconsistent.""" + + +def _reject_constant(_value: str) -> None: + raise Gate14EvidenceError("invalid JSON") + + +def _unique_object(pairs: Sequence[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise Gate14EvidenceError("duplicate JSON field") + result[key] = value + return result + + +def _regular_bytes(path: Path) -> bytes: + path = Path(path) + try: + metadata = path.lstat() + except OSError as exc: + raise Gate14EvidenceError("required evidence is unavailable") from exc + reparse = bool(getattr(metadata, "st_file_attributes", 0) & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)) + if ( + reparse + or path.is_symlink() + or not stat.S_ISREG(metadata.st_mode) + or not 1 <= metadata.st_size <= MAX_INPUT_BYTES + ): + raise Gate14EvidenceError("required evidence is unsafe") + try: + return path.read_bytes() + except OSError as exc: + raise Gate14EvidenceError("required evidence is unreadable") from exc + + +def _strict_json(payload: bytes) -> Mapping[str, Any]: + if not isinstance(payload, bytes) or not 1 <= len(payload) <= MAX_INPUT_BYTES: + raise Gate14EvidenceError("JSON size is invalid") + try: + value = json.loads( + payload.decode("utf-8"), + object_pairs_hook=_unique_object, + parse_constant=_reject_constant, + ) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise Gate14EvidenceError("invalid JSON") from exc + if not isinstance(value, dict): + raise Gate14EvidenceError("JSON root is invalid") + return value + + +def _mapping(value: Any, fields: set[str]) -> Mapping[str, Any]: + if not isinstance(value, dict) or set(value) != fields: + raise Gate14EvidenceError("evidence schema is invalid") + return value + + +def _true(value: Any) -> None: + if value is not True: + raise Gate14EvidenceError("required proof is absent") + + +def _false(value: Any) -> None: + if value is not False: + raise Gate14EvidenceError("forbidden retention or result is present") + + +def _integer(value: Any, minimum: int = 0, maximum: int = 2**63 - 1) -> int: + if type(value) is not int or not minimum <= value <= maximum: + raise Gate14EvidenceError("integer evidence is invalid") + return value + + +def _number(value: Any, minimum: float, maximum: float) -> float: + if type(value) not in (int, float): + raise Gate14EvidenceError("numeric evidence is invalid") + rendered = float(value) + if not math.isfinite(rendered) or not minimum <= rendered <= maximum: + raise Gate14EvidenceError("numeric evidence is invalid") + return rendered + + +def _string(value: Any, pattern: re.Pattern[str]) -> str: + if not isinstance(value, str) or pattern.fullmatch(value) is None: + raise Gate14EvidenceError("string evidence is invalid") + return value + + +def _validate_package(value: Any, source_commit: str) -> Mapping[str, Any]: + package = _mapping(value, _PACKAGE_FIELDS) + if package["source_commit"] != source_commit: + raise Gate14EvidenceError("package source is inconsistent") + _string(package["source_commit"], _COMMIT_RE) + _string(package["archive_sha256"], _DIGEST_RE) + _integer(package["archive_bytes"], 1, 8 * 1024**3) + _string(package["release_metadata_sha256"], _DIGEST_RE) + return package + + +def _validate_model(value: Any, platform: str) -> Mapping[str, Any]: + model = _mapping(value, _MODEL_FIELDS) + model_id = model["id"] + if model_id != EXPECTED_PLATFORM_MODELS[platform]: + raise Gate14EvidenceError("platform model is invalid") + profile = MODEL_PROFILES[model_id] + for field in ( + "manifest_digest", + "revision_commit", + "selected_artifact_count", + "selected_artifact_bytes", + "total_blocks", + ): + if model[field] != profile[field]: + raise Gate14EvidenceError("model identity is inconsistent") + if model["gate9_envelope_sha256"] != EXPECTED_GATE9_ENVELOPES[platform]: + raise Gate14EvidenceError("Gate 9 envelope is inconsistent") + return model + + +def _validate_hardware(value: Any, platform: str) -> Mapping[str, Any]: + hardware = _mapping(value, _HARDWARE_FIELDS) + _string(hardware["os_name"], _OS_RE) + if hardware["os_name"] != EXPECTED_PLATFORM_OS[platform]: + raise Gate14EvidenceError("platform operating system is inconsistent") + if hardware["accelerator"] != "NVIDIA L4": + raise Gate14EvidenceError("real L4 hardware is required") + if hardware["accelerator_count"] != 1: + raise Gate14EvidenceError("exactly one accelerator is required") + _integer(hardware["accelerator_memory_bytes"], 20 * 1024**3, 32 * 1024**3) + return hardware + + +def _validate_cache(value: Any, selected_bytes: int) -> None: + cache = _mapping(value, _CACHE_FIELDS) + if ( + cache["verified_bytes_before"] != selected_bytes + or cache["verified_bytes_after"] != selected_bytes + or cache["transfer_bytes_during_gate"] != 0 + or cache["digest_mismatch_count"] != 0 + ): + raise Gate14EvidenceError("verified cache reuse is inconsistent") + _false(cache["forbidden_model_acquired"]) + + +def _validate_placement(value: Any, total_blocks: int) -> tuple[int, int]: + placement = _mapping(value, _PLACEMENT_FIELDS) + _true(placement["automatic"]) + if placement["worker_count"] != 1: + raise Gate14EvidenceError("exactly one automatic worker is required") + start = _integer(placement["block_start"], 0, total_blocks - 1) + end = _integer(placement["block_end"], 1, total_blocks) + if end <= start: + raise Gate14EvidenceError("automatic block range is empty") + _true(placement["intent_published"]) + _true(placement["remote_acknowledged"]) + return start, end + + +def _validate_limits(value: Any, selected_bytes: int, accelerator_memory: int) -> None: + limits = _mapping(value, _LIMIT_FIELDS) + disk = _integer(limits["disk_bytes"], selected_bytes, MAX_BYTES) + vram = _integer(limits["vram_bytes"], 1, accelerator_memory) + if disk < selected_bytes or vram >= accelerator_memory: + raise Gate14EvidenceError("resource ceilings are not bounded") + _number(limits["bandwidth_mbps"], 0.001, 1_000_000.0) + _number(limits["power_watts"], 0.001, 1_000.0) + if limits["schedule_timezone"] != "UTC" or limits["resource_limit_count"] != 5: + raise Gate14EvidenceError("all five resource classes are required") + _true(limits["configured_and_resolved_match"]) + _true(limits["low_vram_rejected"]) + + +def _validate_suspensions(value: Any) -> None: + if not isinstance(value, list) or len(value) != 3: + raise Gate14EvidenceError("three suspension classes are required") + seen: set[str] = set() + for raw in value: + item = _mapping(raw, _SUSPENSION_FIELDS) + kind = item["kind"] + if kind not in {"bandwidth", "power", "schedule"} or kind in seen: + raise Gate14EvidenceError("suspension class is invalid") + seen.add(kind) + _true(item["suspended"]) + _true(item["resumed"]) + _true(item["desired_intent_preserved"]) + if item["worker_count_during"] != 0: + raise Gate14EvidenceError("worker remained active while suspended") + _number(item["duration_seconds"], 0.0, MAX_DURATION_SECONDS) + + +def _validate_recovery(value: Any) -> None: + recovery = _mapping(value, _RECOVERY_FIELDS) + for field in _RECOVERY_FIELDS - {"restart_seconds"}: + _true(recovery[field]) + _number(recovery["restart_seconds"], 0.0, MAX_DURATION_SECONDS) + + +def _validate_pause(value: Any) -> None: + pause = _mapping(value, _PAUSE_FIELDS) + _true(pause["requested"]) + _true(pause["completed"]) + _number(pause["duration_seconds"], 0.0, MAX_DURATION_SECONDS) + if pause["worker_count_after"] != 0 or pause["descendant_count_after"] != 0: + raise Gate14EvidenceError("pause cleanup is incomplete") + + +def _validate_restart(value: Any) -> None: + restart = _mapping(value, _RESTART_FIELDS) + for field in _RESTART_FIELDS - {"duration_seconds"}: + _true(restart[field]) + _number(restart["duration_seconds"], 0.0, MAX_DURATION_SECONDS) + + +def _validate_unsupported(value: Any) -> None: + unsupported = _mapping(value, _UNSUPPORTED_FIELDS) + if ( + unsupported["device"] != "cpu" + or unsupported["configured_limit"] != "power_watts" + or unsupported["reason_code"] != "power-telemetry-unavailable" + ): + raise Gate14EvidenceError("unsupported telemetry classification is invalid") + _true(unsupported["start_rejected"]) + _false(unsupported["private_detail_retained"]) + + +def _validate_privacy(value: Any) -> None: + privacy = _mapping(value, _PRIVACY_FIELDS) + for field in _PRIVACY_FIELDS: + _false(privacy[field]) + + +def validate_platform_document(value: Mapping[str, Any]) -> Mapping[str, Any]: + document = _mapping(value, _DOCUMENT_FIELDS) + if ( + document["schema_version"] != SCHEMA_VERSION + or document["scope"] != PLATFORM_SCOPE + or document["result"] != "passed" + ): + raise Gate14EvidenceError("platform evidence header is invalid") + run_id = _string(document["run_id"], _RUN_RE) + platform = document["platform"] + if platform not in EXPECTED_PLATFORM_MODELS: + raise Gate14EvidenceError("platform is invalid") + source_commit = _string(document["source_commit"], _COMMIT_RE) + gate13_evidence_sha256 = _string(document["gate13_evidence_sha256"], _DIGEST_RE) + if gate13_evidence_sha256 != EXPECTED_GATE13_EVIDENCE_SHA256: + raise Gate14EvidenceError("Gate 13 lifecycle evidence is inconsistent") + package = _validate_package(document["package"], source_commit) + model = _validate_model(document["model"], platform) + hardware = _validate_hardware(document["hardware"], platform) + _validate_cache(document["cache"], model["selected_artifact_bytes"]) + block_start, block_end = _validate_placement(document["placement"], model["total_blocks"]) + _validate_limits( + document["limits"], + model["selected_artifact_bytes"], + hardware["accelerator_memory_bytes"], + ) + _validate_suspensions(document["suspensions"]) + _validate_recovery(document["recovery"]) + _validate_pause(document["pause"]) + _validate_restart(document["restart"]) + _validate_unsupported(document["unsupported_telemetry"]) + _validate_privacy(document["privacy"]) + _true(document["qualification_temporaries_removed"]) + return { + "run_id": run_id, + "platform": platform, + "source_commit": source_commit, + "gate13_evidence_sha256": gate13_evidence_sha256, + "package_sha256": package["archive_sha256"], + "model_id": model["id"], + "manifest_digest": model["manifest_digest"], + "gate9_envelope_sha256": model["gate9_envelope_sha256"], + "accelerator": hardware["accelerator"], + "block_start": block_start, + "block_end": block_end, + } + + +def _digest(payload: bytes) -> str: + return "sha256:" + hashlib.sha256(payload).hexdigest() + + +def _resource_names(value: Sequence[str], field: str) -> tuple[str, str]: + if isinstance(value, (str, bytes)) or not isinstance(value, Sequence) or len(value) != 2: + raise Gate14EvidenceError(f"{field} inventory is invalid") + names = tuple(_string(item, _NAME_RE) for item in value) + if len(set(names)) != 2: + raise Gate14EvidenceError(f"{field} inventory is not unique") + if PROTECTED_INSTANCE in names: + raise Gate14EvidenceError("protected resource is targeted") + return names + + +def validate_authorization_document( + value: Mapping[str, Any], + *, + run_id: str, + source_commit: str, + provider_plan_digest: str, + project: str, + zone: str, + expected_instances: Sequence[str], + expected_disks: Sequence[str], + package_sha256: Mapping[str, str], +) -> Mapping[str, Any]: + authorization = _mapping(value, _AUTH_FIELDS) + if ( + authorization["schema_version"] != SCHEMA_VERSION + or authorization["gate"] != 14 + or authorization["result"] != "authorized" + or authorization["run_id"] != run_id + or authorization["source_commit"] != source_commit + or authorization["provider_plan_digest"] != provider_plan_digest + ): + raise Gate14EvidenceError("authorization binding is invalid") + + provider_plan = _mapping(authorization["provider_plan"], _PLAN_FIELDS) + canonical_plan = json.dumps(provider_plan, sort_keys=True, separators=(",", ":")).encode("utf-8") + if ( + _digest(canonical_plan) != provider_plan_digest + or provider_plan["project"] != project + or provider_plan["zone"] != zone + ): + raise Gate14EvidenceError("authorized provider plan is inconsistent") + sequencing = _mapping(provider_plan["sequencing"], _SEQUENCING_FIELDS) + if ( + sequencing["clients_may_run_concurrently"] is not False + or sequencing["windows_first"] is not True + or sequencing["fresh_host_per_platform"] is not True + ): + raise Gate14EvidenceError("authorized sequencing is inconsistent") + clients = provider_plan["clients"] + if not isinstance(clients, list) or len(clients) != 2: + raise Gate14EvidenceError("authorized client plan is invalid") + by_platform = { + item.get("platform"): item + for item in clients + if isinstance(item, dict) and isinstance(item.get("platform"), str) + } + if set(by_platform) != {"windows", "linux"}: + raise Gate14EvidenceError("authorized platform plan is invalid") + for index, platform in enumerate(("windows", "linux")): + client = _mapping(by_platform[platform], _CLIENT_PLAN_FIELDS) + model_id = EXPECTED_PLATFORM_MODELS[platform] + if ( + client["platform"] != platform + or client["instance"] != expected_instances[index] + or client["disk"] != expected_disks[index] + or client["source_commit"] != source_commit + or client["package_sha256"] != package_sha256[platform] + or client["model_id"] != model_id + or client["manifest_digest"] != MODEL_PROFILES[model_id]["manifest_digest"] + ): + raise Gate14EvidenceError("authorized client binding is inconsistent") + _integer(client["termination_unix"], 1) + + cost = _mapping(authorization["authorization"], _AUTHORIZATION_FIELDS) + try: + ceiling = Decimal(str(cost["combined_cloud_ceiling_usd"])) + before = Decimal(str(cost["ledger_committed_before_run_usd"])) + maximum = Decimal(str(cost["maximum_estimate_usd"])) + remaining = Decimal(str(cost["remaining_after_run_maximum_usd"])) + except (InvalidOperation, TypeError, ValueError) as exc: + raise Gate14EvidenceError("cost authorization is invalid") from exc + if ( + (ceiling, before, maximum, remaining) + != (Decimal("100.00"), Decimal("56.00"), Decimal("44.00"), Decimal("0.00")) + or not all(item.is_finite() for item in (ceiling, before, maximum, remaining)) + or cost["reservation_recorded"] is not True + or cost["native_auth_revalidated"] is not True + or cost["provisioning_authorized_after_fail_closed_preflight"] is not True + ): + raise Gate14EvidenceError("cost authorization is inconsistent") + prohibited = authorization["prohibited"] + if ( + not isinstance(prohibited, dict) + or set(prohibited) != {"credits", "macos", "fly_gpu"} + or any(type(item) is not int or item != 0 for item in prohibited.values()) + ): + raise Gate14EvidenceError("prohibited work is present") + return authorization + + +def validate_terminal_state( + value: Mapping[str, Any], + *, + run_id: str, + authorization_sha256: str, + provider_plan_digest: str, + windows_evidence_sha256: str, + linux_evidence_sha256: str, +) -> Mapping[str, Any]: + state = _mapping(value, _TERMINAL_STATE_FIELDS) + if ( + state["schema_version"] != SCHEMA_VERSION + or state["run_id"] != run_id + or state["authorization_sha256"] != authorization_sha256 + or state["provider_plan_digest"] != provider_plan_digest + or state["phase"] != "CLEANED_PASS" + or state["failure_code"] is not None + or state["windows_evidence_digest"] != windows_evidence_sha256 + or state["linux_evidence_digest"] != linux_evidence_sha256 + or state["windows_consumed"] is not True + or state["linux_consumed"] is not True + or state["cleanup_verified"] is not True + or state["next_action"] != "none" + ): + raise Gate14EvidenceError("controller terminal state is inconsistent") + _integer(state["revision"], 1) + return state + + +def validate_cleanup_document( + value: Mapping[str, Any], + *, + run_id: str, + controller_source_commit: str, + provider_plan_digest: str, + project: str, + zone: str, + expected_instances: Sequence[str], + expected_disks: Sequence[str], + terminal_state_sha256: str, +) -> Mapping[str, Any]: + cleanup = _mapping(value, _CLEANUP_FIELDS) + if ( + cleanup["schema_version"] != SCHEMA_VERSION + or cleanup["scope"] != CLEANUP_SCOPE + or cleanup["run_id"] != run_id + or cleanup["result"] != "passed" + or cleanup["provider"] != "GCP" + or cleanup["controller_source_commit"] != controller_source_commit + or cleanup["provider_plan_digest"] != provider_plan_digest + or cleanup["project"] != project + or cleanup["zone"] != zone + or cleanup["deleted_instances"] != list(expected_instances) + or cleanup["deleted_disks"] != list(expected_disks) + or cleanup["controller_terminal_state_sha256"] != terminal_state_sha256 + ): + raise Gate14EvidenceError("cleanup evidence binding is invalid") + _true(cleanup["native_auth_revalidated"]) + if cleanup["expected_instances"] != 2 or cleanup["expected_disks"] != 2: + raise Gate14EvidenceError("cleanup target count is invalid") + for field in ( + "remaining_instances", + "remaining_disks", + "remaining_firewalls", + "l4_usage", + "product_processes_remaining", + "temporary_credentials_remaining", + ): + if cleanup[field] != 0 or type(cleanup[field]) is not int: + raise Gate14EvidenceError("cleanup is incomplete") + _true(cleanup["protected_bootstrap_running"]) + return cleanup + + +def validate_files( + windows_path: Path, + linux_path: Path, + cleanup_path: Path, + controller_source_commit: str, + *, + provider_plan_digest: str, + project: str, + zone: str, + expected_instances: Sequence[str], + expected_disks: Sequence[str], + terminal_state_path: Path, + authorization_path: Path, +) -> Mapping[str, Any]: + controller_source_commit = _string(controller_source_commit, _COMMIT_RE) + provider_plan_digest = _string(provider_plan_digest, _DIGEST_RE) + project = _string(project, _PROJECT_RE) + zone = _string(zone, _ZONE_RE) + expected_instances = _resource_names(expected_instances, "instance") + expected_disks = _resource_names(expected_disks, "disk") + payloads = { + "windows": _regular_bytes(windows_path), + "linux": _regular_bytes(linux_path), + "cleanup": _regular_bytes(cleanup_path), + "terminal_state": _regular_bytes(terminal_state_path), + "authorization": _regular_bytes(authorization_path), + } + windows = validate_platform_document(_strict_json(payloads["windows"])) + linux = validate_platform_document(_strict_json(payloads["linux"])) + if windows["platform"] != "windows" or linux["platform"] != "linux": + raise Gate14EvidenceError("platform evidence ordering is invalid") + if windows["run_id"] != linux["run_id"]: + raise Gate14EvidenceError("run identity is inconsistent") + if windows["source_commit"] != linux["source_commit"] or windows["source_commit"] != controller_source_commit: + raise Gate14EvidenceError("package source identity is inconsistent") + authorization_sha256 = _digest(payloads["authorization"]) + validate_authorization_document( + _strict_json(payloads["authorization"]), + run_id=windows["run_id"], + source_commit=controller_source_commit, + provider_plan_digest=provider_plan_digest, + project=project, + zone=zone, + expected_instances=expected_instances, + expected_disks=expected_disks, + package_sha256={ + "windows": windows["package_sha256"], + "linux": linux["package_sha256"], + }, + ) + terminal_state_sha256 = _digest(payloads["terminal_state"]) + validate_terminal_state( + _strict_json(payloads["terminal_state"]), + run_id=windows["run_id"], + authorization_sha256=authorization_sha256, + provider_plan_digest=provider_plan_digest, + windows_evidence_sha256=_digest(payloads["windows"]), + linux_evidence_sha256=_digest(payloads["linux"]), + ) + cleanup = validate_cleanup_document( + _strict_json(payloads["cleanup"]), + run_id=windows["run_id"], + controller_source_commit=controller_source_commit, + provider_plan_digest=provider_plan_digest, + project=project, + zone=zone, + expected_instances=expected_instances, + expected_disks=expected_disks, + terminal_state_sha256=terminal_state_sha256, + ) + return { + "schema_version": SCHEMA_VERSION, + "scope": AGGREGATE_SCOPE, + "run_id": windows["run_id"], + "result": "passed", + "controller_source_commit": controller_source_commit, + "package_source_commit": windows["source_commit"], + "provider_plan_digest": provider_plan_digest, + "authorization_sha256": authorization_sha256, + "platforms": [ + { + **windows, + "evidence_sha256": _digest(payloads["windows"]), + }, + { + **linux, + "evidence_sha256": _digest(payloads["linux"]), + }, + ], + "cleanup": { + "evidence_sha256": _digest(payloads["cleanup"]), + "provider": cleanup["provider"], + "project": project, + "zone": zone, + "deleted_instances": list(expected_instances), + "deleted_disks": list(expected_disks), + "terminal_state_sha256": terminal_state_sha256, + "resource_absence_proved": True, + "protected_bootstrap_running": True, + }, + "credits_in_scope": False, + "macos_in_scope": False, + "privacy_safe": True, + } + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--windows", type=Path, required=True) + parser.add_argument("--linux", type=Path, required=True) + parser.add_argument("--cleanup", type=Path, required=True) + parser.add_argument("--controller-state", type=Path, required=True) + parser.add_argument("--authorization", type=Path, required=True) + parser.add_argument("--controller-source-commit", required=True) + parser.add_argument("--provider-plan-digest", required=True) + parser.add_argument("--project", required=True) + parser.add_argument("--zone", required=True) + parser.add_argument("--instances", nargs=2, required=True) + parser.add_argument("--disks", nargs=2, required=True) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = _parser().parse_args(argv) + try: + result = validate_files( + args.windows, + args.linux, + args.cleanup, + args.controller_source_commit, + provider_plan_digest=args.provider_plan_digest, + project=args.project, + zone=args.zone, + expected_instances=args.instances, + expected_disks=args.disks, + terminal_state_path=args.controller_state, + authorization_path=args.authorization, + ) + except Gate14EvidenceError as exc: + raise SystemExit(str(exc)) from exc + print(json.dumps(result, sort_keys=True, separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/gate14_run_controller.py b/scripts/gate14_run_controller.py new file mode 100644 index 000000000..cf6c6f9d0 --- /dev/null +++ b/scripts/gate14_run_controller.py @@ -0,0 +1,881 @@ +"""Durable, source-bound controller for one bounded Gate 14 GCP run. + +The controller never invokes a provider. Every start, status, collect, or cleanup +operation first consumes an exact provider observation, persists its decision, and +returns one allowlisted action. A provider adapter may execute only that action. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import stat +import tempfile +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation +from pathlib import Path +from typing import Any, Mapping, Sequence + +import gate14_hardware_acceptance as acceptance +import qualification_cost_guard as cost_guard + +SCHEMA_VERSION = 1 +STATE_SCHEMA_VERSION = 1 +MAX_JSON_BYTES = 262_144 +PROTECTED_INSTANCE = "communityai-bootstrap-1" +ALLOWED_CEILING_USD = 100.0 +CURRENT_EPOCH_ANCHOR_RUN_ID = "gate13-20260901-a" +CURRENT_EPOCH_ANCHOR_MAXIMUM_USD = Decimal("56.00") + +_RUN_RE = re.compile(r"[a-z0-9][a-z0-9-]{0,62}") +_NAME_RE = re.compile(r"[a-z](?:[-a-z0-9]{0,61}[a-z0-9])?") +_COMMIT_RE = re.compile(r"[0-9a-f]{40}") +_DIGEST_RE = re.compile(r"sha256:[0-9a-f]{64}") + +PHASES = { + "ABSENT", + "WINDOWS_RUNNING", + "WINDOWS_DELETING", + "LINUX_RUNNING", + "LINUX_DELETING", + "CLEANING_FAILED", + "CLEANED_PASS", + "CLEANED_FAILURE", +} +TERMINAL_PHASES = {"CLEANED_PASS", "CLEANED_FAILURE"} +ACTIONS = { + "start_windows", + "collect_windows", + "delete_windows", + "start_linux", + "collect_linux", + "delete_linux", + "cleanup_failure", + "none", +} +JOB_STATES = {"absent", "starting", "running", "passed", "failed", "ambiguous"} + +_AUTH_FIELDS = { + "schema_version", + "gate", + "result", + "run_id", + "source_commit", + "provider_plan_digest", + "provider_plan", + "authorization", + "prohibited", +} +_AUTHORIZATION_FIELDS = { + "combined_cloud_ceiling_usd", + "ledger_committed_before_run_usd", + "maximum_estimate_usd", + "remaining_after_run_maximum_usd", + "reservation_recorded", + "native_auth_revalidated", + "provisioning_authorized_after_fail_closed_preflight", +} +_PLAN_FIELDS = {"project", "zone", "clients", "sequencing"} +_CLIENT_PLAN_FIELDS = { + "platform", + "instance", + "disk", + "source_commit", + "termination_unix", + "package_sha256", + "model_id", + "manifest_digest", +} +_SEQUENCING_FIELDS = { + "clients_may_run_concurrently", + "windows_first", + "fresh_host_per_platform", +} +_STATE_FIELDS = { + "schema_version", + "run_id", + "authorization_sha256", + "provider_plan_digest", + "revision", + "phase", + "failure_code", + "windows_evidence_digest", + "linux_evidence_digest", + "windows_consumed", + "linux_consumed", + "cleanup_verified", + "next_action", +} +_OBSERVATION_FIELDS = { + "schema_version", + "run_id", + "observed_at_unix", + "instances", + "disks", + "clients", + "l4_usage", + "protected_bootstrap_running", +} +_INSTANCE_FIELDS = {"present", "run_id", "source_commit", "termination_unix"} +_CLIENT_FIELDS = {"job_state", "attempt_ordinal", "evidence_digest"} + + +class Gate14ControllerError(ValueError): + """The plan, state, observation, or transition failed closed.""" + + +@dataclass(frozen=True) +class ClientPlan: + platform: str + instance: str + disk: str + source_commit: str + termination_unix: int + package_sha256: str + model_id: str + manifest_digest: str + + +@dataclass(frozen=True) +class RunPlan: + run_id: str + authorization_sha256: str + provider_plan_digest: str + source_commit: str + ledger_state: str + project: str + zone: str + windows: ClientPlan + linux: ClientPlan + + @property + def instances(self) -> tuple[str, str]: + return (self.windows.instance, self.linux.instance) + + @property + def disks(self) -> tuple[str, str]: + return (self.windows.disk, self.linux.disk) + + +def _reject_constant(_value: str) -> None: + raise Gate14ControllerError("invalid JSON") + + +def _unique_object(pairs: Sequence[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise Gate14ControllerError("duplicate JSON field") + result[key] = value + return result + + +def _regular_bytes(path: Path, maximum: int = MAX_JSON_BYTES) -> bytes: + path = Path(path) + try: + metadata = path.lstat() + except OSError as exc: + raise Gate14ControllerError("required file is unavailable") from exc + reparse = bool(getattr(metadata, "st_file_attributes", 0) & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)) + if reparse or path.is_symlink() or not stat.S_ISREG(metadata.st_mode) or not 1 <= metadata.st_size <= maximum: + raise Gate14ControllerError("required file is unsafe") + try: + return path.read_bytes() + except OSError as exc: + raise Gate14ControllerError("required file is unreadable") from exc + + +def _strict_json(payload: bytes) -> Mapping[str, Any]: + if not isinstance(payload, bytes) or not 1 <= len(payload) <= MAX_JSON_BYTES: + raise Gate14ControllerError("JSON size is invalid") + try: + value = json.loads( + payload.decode("utf-8"), + object_pairs_hook=_unique_object, + parse_constant=_reject_constant, + ) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise Gate14ControllerError("invalid JSON") from exc + if not isinstance(value, dict): + raise Gate14ControllerError("JSON root is invalid") + return value + + +def _mapping(value: Any, fields: set[str]) -> Mapping[str, Any]: + if not isinstance(value, dict) or set(value) != fields: + raise Gate14ControllerError("schema is invalid") + return value + + +def _string(value: Any, pattern: re.Pattern[str]) -> str: + if not isinstance(value, str) or pattern.fullmatch(value) is None: + raise Gate14ControllerError("string is invalid") + return value + + +def _integer(value: Any, minimum: int = 0, maximum: int = 2**63 - 1) -> int: + if type(value) is not int or not minimum <= value <= maximum: + raise Gate14ControllerError("integer is invalid") + return value + + +def _canonical_digest(value: Mapping[str, Any]) -> str: + payload = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + return "sha256:" + hashlib.sha256(payload).hexdigest() + + +def _client_plan(value: Any, platform: str, source_commit: str) -> ClientPlan: + raw = _mapping(value, _CLIENT_PLAN_FIELDS) + if raw["platform"] != platform or raw["source_commit"] != source_commit: + raise Gate14ControllerError("client source binding is invalid") + instance = _string(raw["instance"], _NAME_RE) + disk = _string(raw["disk"], _NAME_RE) + if instance == PROTECTED_INSTANCE or disk == PROTECTED_INSTANCE: + raise Gate14ControllerError("protected resource is targeted") + package_sha256 = _string(raw["package_sha256"], _DIGEST_RE) + expected_model = acceptance.EXPECTED_PLATFORM_MODELS[platform] + if raw["model_id"] != expected_model: + raise Gate14ControllerError("client model is invalid") + expected_manifest = acceptance.MODEL_PROFILES[expected_model]["manifest_digest"] + if raw["manifest_digest"] != expected_manifest: + raise Gate14ControllerError("client manifest is invalid") + return ClientPlan( + platform=platform, + instance=instance, + disk=disk, + source_commit=source_commit, + termination_unix=_integer(raw["termination_unix"], 1), + package_sha256=package_sha256, + model_id=expected_model, + manifest_digest=expected_manifest, + ) + + +def load_plan(authorization_path: Path, ledger_path: Path) -> RunPlan: + authorization_payload = _regular_bytes(authorization_path) + raw = _mapping(_strict_json(authorization_payload), _AUTH_FIELDS) + if raw["schema_version"] != SCHEMA_VERSION or raw["gate"] != 14 or raw["result"] != "authorized": + raise Gate14ControllerError("authorization scope is invalid") + run_id = _string(raw["run_id"], _RUN_RE) + source_commit = _string(raw["source_commit"], _COMMIT_RE) + provider_plan = _mapping(raw["provider_plan"], _PLAN_FIELDS) + provider_digest = _canonical_digest(provider_plan) + if raw["provider_plan_digest"] != provider_digest: + raise Gate14ControllerError("provider plan digest changed") + project = _string(provider_plan["project"], re.compile(r"[a-z][a-z0-9-]{4,28}[a-z0-9]")) + zone = _string(provider_plan["zone"], re.compile(r"[a-z]+(?:-[a-z0-9]+)+-[a-z]")) + sequencing = _mapping(provider_plan["sequencing"], _SEQUENCING_FIELDS) + if ( + sequencing["clients_may_run_concurrently"] is not False + or sequencing["windows_first"] is not True + or sequencing["fresh_host_per_platform"] is not True + ): + raise Gate14ControllerError("client sequencing is invalid") + clients = provider_plan["clients"] + if not isinstance(clients, list) or len(clients) != 2: + raise Gate14ControllerError("client plan is invalid") + by_platform = { + item.get("platform"): item + for item in clients + if isinstance(item, dict) and isinstance(item.get("platform"), str) + } + if set(by_platform) != {"windows", "linux"}: + raise Gate14ControllerError("client platform plan is invalid") + windows = _client_plan(by_platform["windows"], "windows", source_commit) + linux = _client_plan(by_platform["linux"], "linux", source_commit) + if windows.instance == linux.instance or windows.disk == linux.disk: + raise Gate14ControllerError("client resources overlap") + + cost = _mapping(raw["authorization"], _AUTHORIZATION_FIELDS) + try: + ceiling = Decimal(str(cost["combined_cloud_ceiling_usd"])) + before = Decimal(str(cost["ledger_committed_before_run_usd"])) + maximum = Decimal(str(cost["maximum_estimate_usd"])) + remaining = Decimal(str(cost["remaining_after_run_maximum_usd"])) + except (InvalidOperation, TypeError, ValueError) as exc: + raise Gate14ControllerError("cost authorization is invalid") from exc + if ( + not all(value.is_finite() for value in (ceiling, before, maximum, remaining)) + or ceiling != Decimal(str(ALLOWED_CEILING_USD)) + or before < 0 + or maximum <= 0 + or before + maximum > ceiling + or ceiling - before - maximum != remaining + or cost["reservation_recorded"] is not True + or cost["native_auth_revalidated"] is not True + or cost["provisioning_authorized_after_fail_closed_preflight"] is not True + ): + raise Gate14ControllerError("cost authorization is inconsistent") + prohibited = raw["prohibited"] + if ( + not isinstance(prohibited, dict) + or set(prohibited) != {"credits", "macos", "fly_gpu"} + or any(type(value) is not int or value != 0 for value in prohibited.values()) + ): + raise Gate14ControllerError("prohibited work is present") + try: + entries = cost_guard.load_spend_ledger(ledger_path) + except cost_guard.CostGuardError as exc: + raise Gate14ControllerError("spend ledger is invalid") from exc + anchors = [entry for entry in entries if entry.run_id == CURRENT_EPOCH_ANCHOR_RUN_ID] + if len(anchors) != 1 or anchors[0].maximum_usd != CURRENT_EPOCH_ANCHOR_MAXIMUM_USD: + raise Gate14ControllerError("current accounting epoch anchor is invalid") + anchor_index = entries.index(anchors[0]) + historical_entries = entries[anchor_index + 1 :] + if any(entry.state not in {"CANCELED", "CLEANED-COMMITTED", "CLEANED-RELEASED"} for entry in historical_entries): + raise Gate14ControllerError("active reservation is hidden below the epoch anchor") + current_epoch_entries = entries[: anchor_index + 1] + matches = [entry for entry in current_epoch_entries if entry.run_id == run_id] + if ( + len(matches) != 1 + or matches[0].provider != "GCP" + or matches[0].maximum_usd != maximum + or matches[0].state != "RESERVED" + or provider_digest not in matches[0].purpose + ): + raise Gate14ControllerError("spend ledger reservation is invalid") + ledger_committed = sum( + (entry.committed_usd for entry in current_epoch_entries), + Decimal("0"), + ) + committed_before = ledger_committed - matches[0].committed_usd + if committed_before != before or ledger_committed > ceiling: + raise Gate14ControllerError("spend ledger exceeds the authorized ceiling") + return RunPlan( + run_id=run_id, + authorization_sha256="sha256:" + hashlib.sha256(authorization_payload).hexdigest(), + provider_plan_digest=provider_digest, + source_commit=source_commit, + ledger_state=matches[0].state, + project=project, + zone=zone, + windows=windows, + linux=linux, + ) + + +def initial_state(plan: RunPlan) -> dict[str, Any]: + return { + "schema_version": STATE_SCHEMA_VERSION, + "run_id": plan.run_id, + "authorization_sha256": plan.authorization_sha256, + "provider_plan_digest": plan.provider_plan_digest, + "revision": 0, + "phase": "ABSENT", + "failure_code": None, + "windows_evidence_digest": None, + "linux_evidence_digest": None, + "windows_consumed": False, + "linux_consumed": False, + "cleanup_verified": False, + "next_action": "none", + } + + +def validate_state(value: Mapping[str, Any], plan: RunPlan) -> dict[str, Any]: + state = dict(_mapping(value, _STATE_FIELDS)) + if ( + state["schema_version"] != STATE_SCHEMA_VERSION + or state["run_id"] != plan.run_id + or state["authorization_sha256"] != plan.authorization_sha256 + or state["provider_plan_digest"] != plan.provider_plan_digest + or state["phase"] not in PHASES + or state["next_action"] not in ACTIONS + ): + raise Gate14ControllerError("state binding is invalid") + _integer(state["revision"]) + for field in ("windows_consumed", "linux_consumed", "cleanup_verified"): + if type(state[field]) is not bool: + raise Gate14ControllerError("state boolean is invalid") + for field in ("windows_evidence_digest", "linux_evidence_digest"): + if state[field] is not None: + _string(state[field], _DIGEST_RE) + if state["failure_code"] is not None: + _string(state["failure_code"], re.compile(r"[a-z0-9][a-z0-9-]{0,63}")) + allowed_actions = { + "ABSENT": {"none", "start_windows"}, + "WINDOWS_RUNNING": {"none", "collect_windows"}, + "WINDOWS_DELETING": {"delete_windows", "start_linux"}, + "LINUX_RUNNING": {"none", "collect_linux"}, + "LINUX_DELETING": {"delete_linux"}, + "CLEANING_FAILED": {"cleanup_failure"}, + "CLEANED_PASS": {"none"}, + "CLEANED_FAILURE": {"none"}, + } + if state["next_action"] not in allowed_actions[state["phase"]]: + raise Gate14ControllerError("state action is inconsistent") + if state["windows_evidence_digest"] is not None and not state["windows_consumed"]: + raise Gate14ControllerError("Windows evidence state is inconsistent") + if state["linux_evidence_digest"] is not None and not state["linux_consumed"]: + raise Gate14ControllerError("Linux evidence state is inconsistent") + phase = state["phase"] + failed_phase = phase in {"CLEANING_FAILED", "CLEANED_FAILURE"} + if failed_phase is (state["failure_code"] is None): + raise Gate14ControllerError("failure state is inconsistent") + if state["cleanup_verified"] is not (phase in TERMINAL_PHASES): + raise Gate14ControllerError("cleanup state is inconsistent") + windows_evidence = state["windows_evidence_digest"] is not None + linux_evidence = state["linux_evidence_digest"] is not None + if phase == "ABSENT" and any( + (state["windows_consumed"], state["linux_consumed"], windows_evidence, linux_evidence) + ): + raise Gate14ControllerError("initial state is inconsistent") + if phase == "WINDOWS_RUNNING" and ( + not state["windows_consumed"] + or state["linux_consumed"] + or linux_evidence + or (state["next_action"] == "collect_windows") is not windows_evidence + ): + raise Gate14ControllerError("Windows running state is inconsistent") + if phase == "WINDOWS_DELETING" and ( + not state["windows_consumed"] or state["linux_consumed"] or not windows_evidence or linux_evidence + ): + raise Gate14ControllerError("Windows deletion state is inconsistent") + if phase == "LINUX_RUNNING" and ( + not state["windows_consumed"] + or not state["linux_consumed"] + or not windows_evidence + or (state["next_action"] == "collect_linux") is not linux_evidence + ): + raise Gate14ControllerError("Linux running state is inconsistent") + if phase in {"LINUX_DELETING", "CLEANED_PASS"} and ( + not state["windows_consumed"] or not state["linux_consumed"] or not windows_evidence or not linux_evidence + ): + raise Gate14ControllerError("completed evidence state is inconsistent") + return state + + +def validate_observation(value: Mapping[str, Any], plan: RunPlan) -> dict[str, Any]: + observation = dict(_mapping(value, _OBSERVATION_FIELDS)) + if observation["schema_version"] != SCHEMA_VERSION or observation["run_id"] != plan.run_id: + raise Gate14ControllerError("observation binding is invalid") + now = _integer(observation["observed_at_unix"], 1) + if observation["protected_bootstrap_running"] is not True: + raise Gate14ControllerError("protected bootstrap is not healthy") + _integer(observation["l4_usage"], 0, 1) + instances = observation["instances"] + disks = observation["disks"] + clients = observation["clients"] + if ( + not isinstance(instances, dict) + or set(instances) != set(plan.instances) + or not isinstance(disks, dict) + or set(disks) != set(plan.disks) + or not isinstance(clients, dict) + or set(clients) != {"windows", "linux"} + ): + raise Gate14ControllerError("provider inventory is not exact") + for client in (plan.windows, plan.linux): + instance = _mapping(instances[client.instance], _INSTANCE_FIELDS) + if type(instance["present"]) is not bool or type(disks[client.disk]) is not bool: + raise Gate14ControllerError("provider inventory type is invalid") + if instance["present"]: + if ( + instance["run_id"] != plan.run_id + or instance["source_commit"] != client.source_commit + or _integer(instance["termination_unix"], 1) != client.termination_unix + or disks[client.disk] is not True + ): + raise Gate14ControllerError("provider resource binding is invalid") + elif any( + value is not None + for value in ( + instance["run_id"], + instance["source_commit"], + instance["termination_unix"], + ) + ): + raise Gate14ControllerError("absent instance metadata is invalid") + job = _mapping(clients[client.platform], _CLIENT_FIELDS) + job_state = job["job_state"] + if job_state not in JOB_STATES: + raise Gate14ControllerError("host job state is invalid") + attempt = _integer(job["attempt_ordinal"], 0, 1) + evidence_digest = job["evidence_digest"] + if job_state == "absent": + if attempt != 0 or evidence_digest is not None: + raise Gate14ControllerError("absent host job evidence is inconsistent") + else: + if attempt != 1: + raise Gate14ControllerError("host job attempt is inconsistent") + if job_state == "passed": + _string(evidence_digest, _DIGEST_RE) + elif evidence_digest is not None: + raise Gate14ControllerError("unfinished host job exposed evidence") + expected_l4_usage = sum(int(instances[client.instance]["present"]) for client in (plan.windows, plan.linux)) + if observation["l4_usage"] != expected_l4_usage: + raise Gate14ControllerError("accelerator inventory is inconsistent") + return observation + + +def _next(state: Mapping[str, Any], **changes: Any) -> dict[str, Any]: + result = dict(state) + result.update(changes) + result["revision"] = int(state["revision"]) + 1 + return result + + +def _resources_absent(observation: Mapping[str, Any], plan: RunPlan) -> bool: + return ( + all(not observation["instances"][name]["present"] for name in plan.instances) + and all(observation["disks"][name] is False for name in plan.disks) + and observation["l4_usage"] == 0 + ) + + +def _observed_evidence_matches(state: Mapping[str, Any], observation: Mapping[str, Any], platform: str) -> bool: + job = observation["clients"][platform] + return job["job_state"] == "passed" and job["evidence_digest"] == state[f"{platform}_evidence_digest"] + + +def reconcile( + state_value: Mapping[str, Any], + observation_value: Mapping[str, Any], + plan: RunPlan, +) -> dict[str, Any]: + state = validate_state(state_value, plan) + observation = validate_observation(observation_value, plan) + phase = state["phase"] + windows_present = observation["instances"][plan.windows.instance]["present"] + linux_present = observation["instances"][plan.linux.instance]["present"] + windows_job = observation["clients"]["windows"]["job_state"] + linux_job = observation["clients"]["linux"]["job_state"] + observed_at = observation["observed_at_unix"] + + if phase in TERMINAL_PHASES: + if not _resources_absent(observation, plan): + raise Gate14ControllerError("resources returned after terminal cleanup") + if phase == "CLEANED_PASS" and not ( + _observed_evidence_matches(state, observation, "windows") + and _observed_evidence_matches(state, observation, "linux") + ): + raise Gate14ControllerError("terminal evidence binding is inconsistent") + return state + if phase != "CLEANING_FAILED": + deadline = ( + plan.windows.termination_unix if phase in {"ABSENT", "WINDOWS_RUNNING"} else plan.linux.termination_unix + ) + if observed_at >= deadline: + if _resources_absent(observation, plan): + return _next( + state, + phase="CLEANED_FAILURE", + failure_code="run-expired", + cleanup_verified=True, + next_action="none", + ) + return _next( + state, + phase="CLEANING_FAILED", + failure_code="run-expired", + next_action="cleanup_failure", + ) + if phase == "CLEANING_FAILED": + if _resources_absent(observation, plan): + return _next( + state, + phase="CLEANED_FAILURE", + cleanup_verified=True, + next_action="none", + ) + return _next(state, next_action="cleanup_failure") + if phase == "ABSENT": + orphan_disk = (observation["disks"][plan.windows.disk] and not windows_present) or ( + observation["disks"][plan.linux.disk] and not linux_present + ) + if orphan_disk: + return _next( + state, + phase="CLEANING_FAILED", + failure_code="orphaned-planned-disk", + next_action="cleanup_failure", + ) + if linux_present or linux_job != "absent": + if _resources_absent(observation, plan): + return _next( + state, + phase="CLEANED_FAILURE", + failure_code="unexpected-linux-state", + cleanup_verified=True, + next_action="none", + ) + return _next( + state, + phase="CLEANING_FAILED", + failure_code="unexpected-linux-state", + next_action="cleanup_failure", + ) + if windows_present: + return _next( + state, + phase="WINDOWS_RUNNING", + windows_consumed=True, + next_action="none", + ) + if windows_job != "absent": + return _next( + state, + phase="CLEANED_FAILURE", + failure_code="stale-windows-job", + cleanup_verified=True, + next_action="none", + ) + return _next(state, next_action="start_windows") + if phase == "WINDOWS_RUNNING": + if linux_present or observation["disks"][plan.linux.disk] or not windows_present: + return _next( + state, + phase="CLEANING_FAILED", + failure_code="windows-inventory-lost", + next_action="cleanup_failure", + ) + if state["next_action"] == "collect_windows" and not _observed_evidence_matches(state, observation, "windows"): + raise Gate14ControllerError("reported Windows evidence changed") + if windows_job in {"starting", "running"}: + return _next(state, next_action="none") + if windows_job == "passed": + return _next( + state, + windows_evidence_digest=observation["clients"]["windows"]["evidence_digest"], + next_action="collect_windows", + ) + return _next( + state, + phase="CLEANING_FAILED", + failure_code="windows-job-failed", + next_action="cleanup_failure", + ) + if phase == "WINDOWS_DELETING": + if not _observed_evidence_matches(state, observation, "windows"): + raise Gate14ControllerError("validated Windows evidence is unavailable") + if windows_present or observation["disks"][plan.windows.disk]: + if linux_present: + return _next( + state, + phase="CLEANING_FAILED", + failure_code="clients-overlapped", + next_action="cleanup_failure", + ) + return _next(state, next_action="delete_windows") + if linux_present: + return _next( + state, + phase="LINUX_RUNNING", + linux_consumed=True, + next_action="none", + ) + if observation["disks"][plan.linux.disk]: + return _next( + state, + phase="CLEANING_FAILED", + failure_code="orphaned-linux-disk", + next_action="cleanup_failure", + ) + if linux_job != "absent": + return _next( + state, + phase="CLEANED_FAILURE", + failure_code="stale-linux-job", + cleanup_verified=True, + next_action="none", + ) + return _next(state, next_action="start_linux") + if phase == "LINUX_RUNNING": + if observation["disks"][plan.windows.disk]: + return _next( + state, + phase="CLEANING_FAILED", + failure_code="orphaned-windows-disk", + next_action="cleanup_failure", + ) + if not _observed_evidence_matches(state, observation, "windows"): + raise Gate14ControllerError("validated Windows evidence is unavailable") + if windows_present or not linux_present: + return _next( + state, + phase="CLEANING_FAILED", + failure_code="linux-inventory-lost", + next_action="cleanup_failure", + ) + if state["next_action"] == "collect_linux" and not _observed_evidence_matches(state, observation, "linux"): + raise Gate14ControllerError("reported Linux evidence changed") + if linux_job in {"starting", "running"}: + return _next(state, next_action="none") + if linux_job == "passed": + return _next( + state, + linux_evidence_digest=observation["clients"]["linux"]["evidence_digest"], + next_action="collect_linux", + ) + return _next( + state, + phase="CLEANING_FAILED", + failure_code="linux-job-failed", + next_action="cleanup_failure", + ) + if phase == "LINUX_DELETING": + if windows_present or observation["disks"][plan.windows.disk]: + return _next( + state, + phase="CLEANING_FAILED", + failure_code="windows-resources-returned", + next_action="cleanup_failure", + ) + if not ( + _observed_evidence_matches(state, observation, "windows") + and _observed_evidence_matches(state, observation, "linux") + ): + raise Gate14ControllerError("validated platform evidence is unavailable") + if not _resources_absent(observation, plan): + return _next(state, next_action="delete_linux") + if state["windows_evidence_digest"] is None or state["linux_evidence_digest"] is None: + return _next( + state, + phase="CLEANED_FAILURE", + failure_code="evidence-missing", + cleanup_verified=True, + next_action="none", + ) + return _next( + state, + phase="CLEANED_PASS", + cleanup_verified=True, + next_action="none", + ) + raise Gate14ControllerError("unhandled lifecycle phase") + + +def collect_platform( + state_value: Mapping[str, Any], + plan: RunPlan, + platform: str, + evidence_path: Path, +) -> dict[str, Any]: + state = validate_state(state_value, plan) + expected_phase = "WINDOWS_RUNNING" if platform == "windows" else "LINUX_RUNNING" + expected_action = f"collect_{platform}" + if state["phase"] != expected_phase or state["next_action"] != expected_action: + raise Gate14ControllerError("collect is out of sequence") + payload = _regular_bytes(evidence_path) + summary = acceptance.validate_platform_document(_strict_json(payload)) + client = plan.windows if platform == "windows" else plan.linux + if ( + summary["run_id"] != plan.run_id + or summary["platform"] != platform + or summary["source_commit"] != client.source_commit + or summary["package_sha256"] != client.package_sha256 + or summary["model_id"] != client.model_id + or summary["manifest_digest"] != client.manifest_digest + ): + raise Gate14ControllerError("platform evidence does not match the plan") + digest = "sha256:" + hashlib.sha256(payload).hexdigest() + if digest != state[f"{platform}_evidence_digest"]: + raise Gate14ControllerError("collected evidence changed after host completion") + return _next( + state, + **{ + f"{platform}_evidence_digest": digest, + "phase": f"{platform.upper()}_DELETING", + "next_action": f"delete_{platform}", + }, + ) + + +def begin_cleanup( + state_value: Mapping[str, Any], + plan: RunPlan, + failure_code: str, +) -> dict[str, Any]: + state = validate_state(state_value, plan) + if state["phase"] in TERMINAL_PHASES: + return state + _string(failure_code, re.compile(r"[a-z0-9][a-z0-9-]{0,63}")) + return _next( + state, + phase="CLEANING_FAILED", + failure_code=failure_code, + next_action="cleanup_failure", + ) + + +def load_state(path: Path, plan: RunPlan) -> dict[str, Any]: + return validate_state(_strict_json(_regular_bytes(path)), plan) + + +def save_state(path: Path, state_value: Mapping[str, Any], plan: RunPlan) -> None: + state = validate_state(state_value, plan) + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + payload = (json.dumps(state, sort_keys=True, separators=(",", ":")) + os.linesep).encode("utf-8") + handle = tempfile.NamedTemporaryFile( + mode="wb", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) + temporary = Path(handle.name) + try: + with handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + except BaseException: + try: + temporary.unlink() + except OSError: + pass + raise + + +def _common_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("operation", choices=("start", "status", "collect", "cleanup")) + parser.add_argument("--authorization", type=Path, required=True) + parser.add_argument("--ledger", type=Path, required=True) + parser.add_argument("--state", type=Path, required=True) + parser.add_argument("--observation", type=Path) + parser.add_argument("--platform", choices=("windows", "linux")) + parser.add_argument("--evidence", type=Path) + parser.add_argument("--failure-code", default="operator-cleanup") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = _common_parser().parse_args(argv) + try: + plan = load_plan(args.authorization, args.ledger) + state = load_state(args.state, plan) if args.state.exists() else initial_state(plan) + if args.operation in {"start", "status"}: + if args.observation is None: + raise Gate14ControllerError("observation is required") + state = reconcile( + state, + _strict_json(_regular_bytes(args.observation)), + plan, + ) + elif args.operation == "collect": + if args.platform is None or args.evidence is None: + raise Gate14ControllerError("platform evidence is required") + state = collect_platform(state, plan, args.platform, args.evidence) + else: + state = begin_cleanup(state, plan, args.failure_code) + if args.observation is not None: + state = reconcile( + state, + _strict_json(_regular_bytes(args.observation)), + plan, + ) + save_state(args.state, state, plan) + except (Gate14ControllerError, acceptance.Gate14EvidenceError) as exc: + raise SystemExit(str(exc)) from exc + print(json.dumps(state, sort_keys=True, separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_gate14_hardware_acceptance.py b/tests/test_gate14_hardware_acceptance.py new file mode 100644 index 000000000..ad20c2a53 --- /dev/null +++ b/tests/test_gate14_hardware_acceptance.py @@ -0,0 +1,492 @@ +from __future__ import annotations + +import hashlib +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import gate14_hardware_acceptance as acceptance # noqa: E402 + +RUN_ID = "gate14-20260902-a" +CONTROLLER_SOURCE = "2" * 40 +SOURCE = CONTROLLER_SOURCE +DIGEST = "sha256:" + "a" * 64 +PROJECT = "community-ai-506321" +ZONE = "us-central1-a" +INSTANCES = ("gate14-20260902-a-windows", "gate14-20260902-a-linux") +DISKS = ("gate14-20260902-a-windows-disk", "gate14-20260902-a-linux-disk") + + +def provider_plan_document() -> dict: + return { + "project": PROJECT, + "zone": ZONE, + "clients": [ + { + "platform": platform, + "instance": INSTANCES[index], + "disk": DISKS[index], + "source_commit": SOURCE, + "termination_unix": 2_000_010_000 + index, + "package_sha256": DIGEST, + "model_id": acceptance.EXPECTED_PLATFORM_MODELS[platform], + "manifest_digest": acceptance.MODEL_PROFILES[acceptance.EXPECTED_PLATFORM_MODELS[platform]][ + "manifest_digest" + ], + } + for index, platform in enumerate(("windows", "linux")) + ], + "sequencing": { + "clients_may_run_concurrently": False, + "windows_first": True, + "fresh_host_per_platform": True, + }, + } + + +PROVIDER_PLAN = provider_plan_document() +PLAN_DIGEST = ( + "sha256:" + + hashlib.sha256(json.dumps(PROVIDER_PLAN, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest() +) + + +def authorization_document() -> dict: + return { + "schema_version": 1, + "gate": 14, + "result": "authorized", + "run_id": RUN_ID, + "source_commit": CONTROLLER_SOURCE, + "provider_plan_digest": PLAN_DIGEST, + "provider_plan": PROVIDER_PLAN, + "authorization": { + "combined_cloud_ceiling_usd": "100.00", + "ledger_committed_before_run_usd": "56.00", + "maximum_estimate_usd": "44.00", + "remaining_after_run_maximum_usd": "0.00", + "reservation_recorded": True, + "native_auth_revalidated": True, + "provisioning_authorized_after_fail_closed_preflight": True, + }, + "prohibited": {"credits": 0, "macos": 0, "fly_gpu": 0}, + } + + +def platform_document(platform: str) -> dict: + model_id = acceptance.EXPECTED_PLATFORM_MODELS[platform] + profile = acceptance.MODEL_PROFILES[model_id] + return { + "schema_version": 1, + "scope": acceptance.PLATFORM_SCOPE, + "run_id": RUN_ID, + "platform": platform, + "result": "passed", + "source_commit": SOURCE, + "gate13_evidence_sha256": acceptance.EXPECTED_GATE13_EVIDENCE_SHA256, + "package": { + "source_commit": SOURCE, + "archive_sha256": DIGEST, + "archive_bytes": 1024, + "release_metadata_sha256": "sha256:" + "b" * 64, + }, + "model": { + "id": model_id, + "manifest_digest": profile["manifest_digest"], + "revision_commit": profile["revision_commit"], + "gate9_envelope_sha256": acceptance.EXPECTED_GATE9_ENVELOPES[platform], + "selected_artifact_count": profile["selected_artifact_count"], + "selected_artifact_bytes": profile["selected_artifact_bytes"], + "total_blocks": profile["total_blocks"], + }, + "hardware": { + "os_name": "Windows Server 2022" if platform == "windows" else "Ubuntu 24.04", + "accelerator": "NVIDIA L4", + "accelerator_count": 1, + "accelerator_memory_bytes": 24 * 1024**3, + }, + "cache": { + "verified_bytes_before": profile["selected_artifact_bytes"], + "verified_bytes_after": profile["selected_artifact_bytes"], + "transfer_bytes_during_gate": 0, + "digest_mismatch_count": 0, + "forbidden_model_acquired": False, + }, + "placement": { + "automatic": True, + "worker_count": 1, + "block_start": 0, + "block_end": min(4, profile["total_blocks"]), + "intent_published": True, + "remote_acknowledged": True, + }, + "limits": { + "disk_bytes": 16 * 1024**3, + "vram_bytes": 20 * 1024**3, + "bandwidth_mbps": 100.0, + "power_watts": 250.0, + "schedule_timezone": "UTC", + "resource_limit_count": 5, + "configured_and_resolved_match": True, + "low_vram_rejected": True, + }, + "suspensions": [ + { + "kind": kind, + "suspended": True, + "resumed": True, + "desired_intent_preserved": True, + "worker_count_during": 0, + "duration_seconds": 2.5, + } + for kind in ("bandwidth", "power", "schedule") + ], + "recovery": { + "worker_crash_observed": True, + "worker_restarted": True, + "restart_seconds": 4.5, + "previous_worker_absent": True, + "manifest_unchanged": True, + "automatic_block_range_valid": True, + "desired_intent_preserved": True, + }, + "pause": { + "requested": True, + "completed": True, + "duration_seconds": 1.5, + "worker_count_after": 0, + "descendant_count_after": 0, + }, + "restart": { + "node_restarted": True, + "policy_persisted": True, + "desired_intent_persisted": True, + "worker_resumed": True, + "duration_seconds": 8.0, + "cache_reused": True, + }, + "unsupported_telemetry": { + "device": "cpu", + "configured_limit": "power_watts", + "start_rejected": True, + "reason_code": "power-telemetry-unavailable", + "private_detail_retained": False, + }, + "privacy": { + "prompt_retained": False, + "response_retained": False, + "token_identifiers_retained": False, + "credentials_retained": False, + "paths_retained": False, + "endpoints_retained": False, + "provider_output_retained": False, + }, + "qualification_temporaries_removed": True, + } + + +def cleanup_document(terminal_state_sha256: str) -> dict: + return { + "schema_version": 1, + "scope": acceptance.CLEANUP_SCOPE, + "run_id": RUN_ID, + "result": "passed", + "provider": "GCP", + "controller_source_commit": CONTROLLER_SOURCE, + "provider_plan_digest": PLAN_DIGEST, + "project": PROJECT, + "zone": ZONE, + "deleted_instances": list(INSTANCES), + "deleted_disks": list(DISKS), + "controller_terminal_state_sha256": terminal_state_sha256, + "native_auth_revalidated": True, + "expected_instances": 2, + "remaining_instances": 0, + "expected_disks": 2, + "remaining_disks": 0, + "remaining_firewalls": 0, + "l4_usage": 0, + "protected_bootstrap_running": True, + "product_processes_remaining": 0, + "temporary_credentials_remaining": 0, + } + + +def write_documents(tmp_path: Path) -> tuple[Path, Path, Path, Path, Path]: + windows_path = tmp_path / "windows.json" + linux_path = tmp_path / "linux.json" + cleanup_path = tmp_path / "cleanup.json" + terminal_state_path = tmp_path / "state.json" + authorization_path = tmp_path / "authorization.json" + windows_path.write_text(json.dumps(platform_document("windows")), encoding="utf-8") + linux_path.write_text(json.dumps(platform_document("linux")), encoding="utf-8") + authorization_path.write_text(json.dumps(authorization_document()), encoding="utf-8") + windows_digest = "sha256:" + hashlib.sha256(windows_path.read_bytes()).hexdigest() + linux_digest = "sha256:" + hashlib.sha256(linux_path.read_bytes()).hexdigest() + authorization_digest = "sha256:" + hashlib.sha256(authorization_path.read_bytes()).hexdigest() + terminal_state = { + "schema_version": 1, + "run_id": RUN_ID, + "authorization_sha256": authorization_digest, + "provider_plan_digest": PLAN_DIGEST, + "revision": 10, + "phase": "CLEANED_PASS", + "failure_code": None, + "windows_evidence_digest": windows_digest, + "linux_evidence_digest": linux_digest, + "windows_consumed": True, + "linux_consumed": True, + "cleanup_verified": True, + "next_action": "none", + } + terminal_state_path.write_text(json.dumps(terminal_state), encoding="utf-8") + terminal_digest = "sha256:" + hashlib.sha256(terminal_state_path.read_bytes()).hexdigest() + cleanup_path.write_text( + json.dumps(cleanup_document(terminal_digest)), + encoding="utf-8", + ) + return windows_path, linux_path, cleanup_path, terminal_state_path, authorization_path + + +def validate_documents(paths: tuple[Path, Path, Path, Path, Path]) -> dict: + windows, linux, cleanup, terminal_state, authorization = paths + return acceptance.validate_files( + windows, + linux, + cleanup, + CONTROLLER_SOURCE, + provider_plan_digest=PLAN_DIGEST, + project=PROJECT, + zone=ZONE, + expected_instances=INSTANCES, + expected_disks=DISKS, + terminal_state_path=terminal_state, + authorization_path=authorization, + ) + + +def test_validate_platform_documents_cover_both_models_and_hardware_contract(): + windows = acceptance.validate_platform_document(platform_document("windows")) + linux = acceptance.validate_platform_document(platform_document("linux")) + + assert windows["model_id"] == "Qwen3.5 2B" + assert linux["model_id"] == "Gemma 4 E2B IT" + assert windows["accelerator"] == linux["accelerator"] == "NVIDIA L4" + assert windows["block_start"] == 0 + assert windows["block_end"] == 4 + + +def test_validate_files_emits_digest_bound_privacy_safe_aggregate(tmp_path): + paths = write_documents(tmp_path) + + result = validate_documents(paths) + + assert result["scope"] == acceptance.AGGREGATE_SCOPE + assert result["result"] == "passed" + assert result["controller_source_commit"] == CONTROLLER_SOURCE + assert result["package_source_commit"] == SOURCE + assert [item["platform"] for item in result["platforms"]] == ["windows", "linux"] + assert all(item["evidence_sha256"].startswith("sha256:") for item in result["platforms"]) + assert result["cleanup"]["resource_absence_proved"] is True + assert result["credits_in_scope"] is False + assert result["macos_in_scope"] is False + assert result["privacy_safe"] is True + + +@pytest.mark.parametrize( + "mutator", + [ + lambda value: value.update(gate13_evidence_sha256="sha256:" + "0" * 64), + lambda value: value["model"].update(gate9_envelope_sha256="sha256:" + "0" * 64), + lambda value: value["hardware"].update(os_name="Ubuntu 24.04"), + lambda value: value["cache"].update(transfer_bytes_during_gate=1), + lambda value: value["placement"].update(remote_acknowledged=False), + lambda value: value["limits"].update(low_vram_rejected=False), + lambda value: value["limits"].update(power_watts=None), + lambda value: value["suspensions"].pop(), + lambda value: value["suspensions"][0].update(resumed=False), + lambda value: value["recovery"].update(worker_restarted=False), + lambda value: value["pause"].update(descendant_count_after=1), + lambda value: value["restart"].update(policy_persisted=False), + lambda value: value["unsupported_telemetry"].update(start_rejected=False), + lambda value: value["privacy"].update(paths_retained=True), + lambda value: value.update(qualification_temporaries_removed=False), + ], +) +def test_platform_evidence_fails_closed(mutator): + value = platform_document("windows") + mutator(value) + + with pytest.raises(acceptance.Gate14EvidenceError): + acceptance.validate_platform_document(value) + + +def test_wrong_model_and_unsafe_block_range_fail_closed(): + value = platform_document("windows") + value["model"] = platform_document("linux")["model"] + with pytest.raises(acceptance.Gate14EvidenceError): + acceptance.validate_platform_document(value) + + value = platform_document("windows") + value["placement"]["block_end"] = value["placement"]["block_start"] + with pytest.raises(acceptance.Gate14EvidenceError): + acceptance.validate_platform_document(value) + + +def test_aggregate_rejects_mismatched_run_source_and_incomplete_cleanup(tmp_path): + paths = write_documents(tmp_path) + linux = paths[1] + linux_value = json.loads(linux.read_text(encoding="utf-8")) + linux_value["run_id"] = "gate14-20260902-b" + linux.write_text(json.dumps(linux_value), encoding="utf-8") + with pytest.raises(acceptance.Gate14EvidenceError): + validate_documents(paths) + + paths = write_documents(tmp_path) + cleanup = paths[2] + cleanup_value = json.loads(cleanup.read_text(encoding="utf-8")) + cleanup_value["remaining_disks"] = 1 + cleanup.write_text(json.dumps(cleanup_value), encoding="utf-8") + with pytest.raises(acceptance.Gate14EvidenceError): + validate_documents(paths) + + +@pytest.mark.parametrize( + ("field", "replacement"), + [ + ("controller_source_commit", "3" * 40), + ("provider_plan_digest", "sha256:" + "3" * 64), + ("project", "different-project"), + ("zone", "us-east1-b"), + ("deleted_instances", list(reversed(INSTANCES))), + ("deleted_disks", list(reversed(DISKS))), + ("controller_terminal_state_sha256", "sha256:" + "3" * 64), + ], +) +def test_cleanup_must_bind_exact_plan_resources_and_terminal_state( + tmp_path, + field, + replacement, +): + paths = write_documents(tmp_path) + cleanup = paths[2] + value = json.loads(cleanup.read_text(encoding="utf-8")) + value[field] = replacement + cleanup.write_text(json.dumps(value), encoding="utf-8") + + with pytest.raises(acceptance.Gate14EvidenceError): + validate_documents(paths) + + +def test_terminal_state_must_be_a_real_digest_bound_pass(tmp_path): + paths = write_documents(tmp_path) + cleanup = paths[2] + terminal_state = paths[3] + state_value = json.loads(terminal_state.read_text(encoding="utf-8")) + state_value["cleanup_verified"] = False + terminal_state.write_text(json.dumps(state_value), encoding="utf-8") + terminal_digest = "sha256:" + hashlib.sha256(terminal_state.read_bytes()).hexdigest() + cleanup_value = json.loads(cleanup.read_text(encoding="utf-8")) + cleanup_value["controller_terminal_state_sha256"] = terminal_digest + cleanup.write_text(json.dumps(cleanup_value), encoding="utf-8") + + with pytest.raises(acceptance.Gate14EvidenceError): + validate_documents(paths) + + +def test_protected_bootstrap_cannot_enter_cleanup_inventory(tmp_path): + windows, linux, cleanup, terminal_state, authorization = write_documents(tmp_path) + + with pytest.raises(acceptance.Gate14EvidenceError): + acceptance.validate_files( + windows, + linux, + cleanup, + CONTROLLER_SOURCE, + provider_plan_digest=PLAN_DIGEST, + project=PROJECT, + zone=ZONE, + expected_instances=(acceptance.PROTECTED_INSTANCE, INSTANCES[1]), + expected_disks=DISKS, + terminal_state_path=terminal_state, + authorization_path=authorization, + ) + + +def test_terminal_state_binds_exact_semantic_authorization_file(tmp_path): + paths = write_documents(tmp_path) + cleanup = paths[2] + terminal_state = paths[3] + authorization = paths[4] + authorization_value = json.loads(authorization.read_text(encoding="utf-8")) + authorization_value["source_commit"] = "3" * 40 + authorization.write_text(json.dumps(authorization_value), encoding="utf-8") + authorization_digest = "sha256:" + hashlib.sha256(authorization.read_bytes()).hexdigest() + + state_value = json.loads(terminal_state.read_text(encoding="utf-8")) + state_value["authorization_sha256"] = authorization_digest + terminal_state.write_text(json.dumps(state_value), encoding="utf-8") + terminal_digest = "sha256:" + hashlib.sha256(terminal_state.read_bytes()).hexdigest() + cleanup_value = json.loads(cleanup.read_text(encoding="utf-8")) + cleanup_value["controller_terminal_state_sha256"] = terminal_digest + cleanup.write_text(json.dumps(cleanup_value), encoding="utf-8") + + with pytest.raises(acceptance.Gate14EvidenceError): + validate_documents(paths) + + +def test_duplicate_and_non_finite_json_fail_closed(): + with pytest.raises(acceptance.Gate14EvidenceError): + acceptance._strict_json(b'{"schema_version":1,"schema_version":1}') + with pytest.raises(acceptance.Gate14EvidenceError): + acceptance._strict_json(b'{"value":NaN}') + + +def test_cli_prints_canonical_aggregate(tmp_path): + windows, linux, cleanup, terminal_state, authorization = write_documents(tmp_path) + + completed = subprocess.run( + [ + sys.executable, + str(ROOT / "scripts" / "gate14_hardware_acceptance.py"), + "--windows", + str(windows), + "--linux", + str(linux), + "--cleanup", + str(cleanup), + "--controller-state", + str(terminal_state), + "--authorization", + str(authorization), + "--controller-source-commit", + CONTROLLER_SOURCE, + "--provider-plan-digest", + PLAN_DIGEST, + "--project", + PROJECT, + "--zone", + ZONE, + "--instances", + *INSTANCES, + "--disks", + *DISKS, + ], + check=True, + capture_output=True, + text=True, + ) + + result = json.loads(completed.stdout) + assert result["result"] == "passed" + assert completed.stdout.strip() == json.dumps( + result, + sort_keys=True, + separators=(",", ":"), + ) diff --git a/tests/test_gate14_run_controller.py b/tests/test_gate14_run_controller.py new file mode 100644 index 000000000..7ff2fb13c --- /dev/null +++ b/tests/test_gate14_run_controller.py @@ -0,0 +1,729 @@ +from __future__ import annotations + +import hashlib +import json +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import gate14_hardware_acceptance as acceptance # noqa: E402 +import gate14_run_controller as controller # noqa: E402 + +RUN_ID = "gate14-20260902-a" +SOURCE = "1" * 40 +NOW = 2_000_000_000 +PACKAGE_DIGESTS = { + "windows": "sha256:" + "a" * 64, + "linux": "sha256:" + "b" * 64, +} + + +def provider_plan() -> dict: + clients = [] + for index, platform in enumerate(("windows", "linux"), start=1): + model_id = acceptance.EXPECTED_PLATFORM_MODELS[platform] + clients.append( + { + "platform": platform, + "instance": f"gate14-20260902-a-{platform}", + "disk": f"gate14-20260902-a-{platform}-disk", + "source_commit": SOURCE, + "termination_unix": NOW + index * 10_000, + "package_sha256": PACKAGE_DIGESTS[platform], + "model_id": model_id, + "manifest_digest": acceptance.MODEL_PROFILES[model_id]["manifest_digest"], + } + ) + return { + "project": "community-ai-506321", + "zone": "us-central1-a", + "clients": clients, + "sequencing": { + "clients_may_run_concurrently": False, + "windows_first": True, + "fresh_host_per_platform": True, + }, + } + + +def write_plan( + tmp_path: Path, + *, + additional_current_maximum: str | None = None, + hide_additional_below_anchor: bool = False, +) -> controller.RunPlan: + provider = provider_plan() + digest = controller._canonical_digest(provider) + authorization = { + "schema_version": 1, + "gate": 14, + "result": "authorized", + "run_id": RUN_ID, + "source_commit": SOURCE, + "provider_plan_digest": digest, + "provider_plan": provider, + "authorization": { + "combined_cloud_ceiling_usd": "100.00", + "ledger_committed_before_run_usd": "56.00", + "maximum_estimate_usd": "44.00", + "remaining_after_run_maximum_usd": "0.00", + "reservation_recorded": True, + "native_auth_revalidated": True, + "provisioning_authorized_after_fail_closed_preflight": True, + }, + "prohibited": {"credits": 0, "macos": 0, "fly_gpu": 0}, + } + authorization_path = tmp_path / "authorization.json" + authorization_path.write_text(json.dumps(authorization), encoding="utf-8") + ledger_path = tmp_path / "ledger.md" + ledger_lines = [ + "## Cloud authorization and spend ledger", + "", + "| Run | Provider | Purpose | Maximum estimate | Observed cost | Cleanup proof | State |", + "| --- | --- | --- | ---: | ---: | --- | --- |", + f"| {RUN_ID} | GCP | Gate 14 packaged hardware [plan {digest}] | USD 44.00 | — | — | RESERVED |", + ] + additional_row = ( + "| gate14-prior-run | GCP | Unexpected same-epoch reservation | " + f"USD {additional_current_maximum} | — | — | RESERVED |" + if additional_current_maximum is not None + else None + ) + if additional_row is not None and not hide_additional_below_anchor: + ledger_lines.append(additional_row) + ledger_lines.append( + "| gate13-20260901-a | GCP | Current epoch anchor | " + "USD 56.00 | — | Existing cleanup proof | CLEANED-COMMITTED |" + ) + if additional_row is not None and hide_additional_below_anchor: + ledger_lines.append(additional_row) + ledger_lines.append("") + ledger_path.write_text("\n".join(ledger_lines), encoding="utf-8") + return controller.load_plan(authorization_path, ledger_path) + + +def observation( + plan: controller.RunPlan, + *, + windows: bool = False, + linux: bool = False, + windows_job: str = "absent", + linux_job: str = "absent", + windows_digest: str | None = None, + linux_digest: str | None = None, + windows_disk: bool | None = None, + linux_disk: bool | None = None, +) -> dict: + present = {"windows": windows, "linux": linux} + jobs = {"windows": windows_job, "linux": linux_job} + digests = {"windows": windows_digest, "linux": linux_digest} + disks = { + "windows": windows if windows_disk is None else windows_disk, + "linux": linux if linux_disk is None else linux_disk, + } + return { + "schema_version": 1, + "run_id": plan.run_id, + "observed_at_unix": NOW, + "instances": { + client.instance: { + "present": present[client.platform], + "run_id": plan.run_id if present[client.platform] else None, + "source_commit": client.source_commit if present[client.platform] else None, + "termination_unix": client.termination_unix if present[client.platform] else None, + } + for client in (plan.windows, plan.linux) + }, + "disks": {client.disk: disks[client.platform] for client in (plan.windows, plan.linux)}, + "clients": { + platform: { + "job_state": jobs[platform], + "attempt_ordinal": 1 if jobs[platform] != "absent" else 0, + "evidence_digest": digests[platform] if jobs[platform] == "passed" else None, + } + for platform in ("windows", "linux") + }, + "l4_usage": int(windows or linux), + "protected_bootstrap_running": True, + } + + +def platform_evidence(platform: str) -> dict: + model_id = acceptance.EXPECTED_PLATFORM_MODELS[platform] + profile = acceptance.MODEL_PROFILES[model_id] + selected = profile["selected_artifact_bytes"] + return { + "schema_version": 1, + "scope": acceptance.PLATFORM_SCOPE, + "run_id": RUN_ID, + "platform": platform, + "result": "passed", + "source_commit": SOURCE, + "gate13_evidence_sha256": acceptance.EXPECTED_GATE13_EVIDENCE_SHA256, + "package": { + "source_commit": SOURCE, + "archive_sha256": PACKAGE_DIGESTS[platform], + "archive_bytes": 1024, + "release_metadata_sha256": "sha256:" + "e" * 64, + }, + "model": { + "id": model_id, + "manifest_digest": profile["manifest_digest"], + "revision_commit": profile["revision_commit"], + "gate9_envelope_sha256": acceptance.EXPECTED_GATE9_ENVELOPES[platform], + "selected_artifact_count": profile["selected_artifact_count"], + "selected_artifact_bytes": selected, + "total_blocks": profile["total_blocks"], + }, + "hardware": { + "os_name": "Windows Server 2022" if platform == "windows" else "Ubuntu 24.04", + "accelerator": "NVIDIA L4", + "accelerator_count": 1, + "accelerator_memory_bytes": 24 * 1024**3, + }, + "cache": { + "verified_bytes_before": selected, + "verified_bytes_after": selected, + "transfer_bytes_during_gate": 0, + "digest_mismatch_count": 0, + "forbidden_model_acquired": False, + }, + "placement": { + "automatic": True, + "worker_count": 1, + "block_start": 0, + "block_end": 4, + "intent_published": True, + "remote_acknowledged": True, + }, + "limits": { + "disk_bytes": 16 * 1024**3, + "vram_bytes": 20 * 1024**3, + "bandwidth_mbps": 100.0, + "power_watts": 250.0, + "schedule_timezone": "UTC", + "resource_limit_count": 5, + "configured_and_resolved_match": True, + "low_vram_rejected": True, + }, + "suspensions": [ + { + "kind": kind, + "suspended": True, + "resumed": True, + "desired_intent_preserved": True, + "worker_count_during": 0, + "duration_seconds": 3.0, + } + for kind in ("bandwidth", "power", "schedule") + ], + "recovery": { + "worker_crash_observed": True, + "worker_restarted": True, + "restart_seconds": 3.0, + "previous_worker_absent": True, + "manifest_unchanged": True, + "automatic_block_range_valid": True, + "desired_intent_preserved": True, + }, + "pause": { + "requested": True, + "completed": True, + "duration_seconds": 3.0, + "worker_count_after": 0, + "descendant_count_after": 0, + }, + "restart": { + "node_restarted": True, + "policy_persisted": True, + "desired_intent_persisted": True, + "worker_resumed": True, + "duration_seconds": 3.0, + "cache_reused": True, + }, + "unsupported_telemetry": { + "device": "cpu", + "configured_limit": "power_watts", + "start_rejected": True, + "reason_code": "power-telemetry-unavailable", + "private_detail_retained": False, + }, + "privacy": { + "prompt_retained": False, + "response_retained": False, + "token_identifiers_retained": False, + "credentials_retained": False, + "paths_retained": False, + "endpoints_retained": False, + "provider_output_retained": False, + }, + "qualification_temporaries_removed": True, + } + + +@pytest.fixture +def plan(tmp_path): + return write_plan(tmp_path) + + +def test_load_plan_binds_budget_sequence_models_and_exact_resources(plan): + assert plan.run_id == RUN_ID + assert plan.ledger_state == "RESERVED" + assert plan.instances == ( + "gate14-20260902-a-windows", + "gate14-20260902-a-linux", + ) + assert plan.windows.model_id == "Qwen3.5 2B" + assert plan.linux.model_id == "Gemma 4 E2B IT" + assert controller.PROTECTED_INSTANCE not in plan.instances + + +def test_load_plan_rejects_spend_above_remaining_ceiling(tmp_path): + provider = provider_plan() + digest = controller._canonical_digest(provider) + authorization = { + "schema_version": 1, + "gate": 14, + "result": "authorized", + "run_id": RUN_ID, + "source_commit": SOURCE, + "provider_plan_digest": digest, + "provider_plan": provider, + "authorization": { + "combined_cloud_ceiling_usd": "100.00", + "ledger_committed_before_run_usd": "56.00", + "maximum_estimate_usd": "45.00", + "remaining_after_run_maximum_usd": "-1.00", + "reservation_recorded": True, + "native_auth_revalidated": True, + "provisioning_authorized_after_fail_closed_preflight": True, + }, + "prohibited": {"credits": 0, "macos": 0, "fly_gpu": 0}, + } + authorization_path = tmp_path / "bad.json" + authorization_path.write_text(json.dumps(authorization), encoding="utf-8") + ledger_path = tmp_path / "ledger.md" + ledger_path.write_text( + "\n".join( + ( + "## Cloud authorization and spend ledger", + "| Run | Provider | Purpose | Maximum estimate | Observed cost | Cleanup proof | State |", + "| --- | --- | --- | ---: | ---: | --- | --- |", + f"| {RUN_ID} | GCP | Gate 14 [plan {digest}] | USD 45.00 | — | — | RESERVED |", + ) + ), + encoding="utf-8", + ) + with pytest.raises(controller.Gate14ControllerError): + controller.load_plan(authorization_path, ledger_path) + + +def test_lifecycle_reattaches_collects_serially_and_cleanup_passes( + tmp_path, + plan, +): + state = controller.initial_state(plan) + assert state["next_action"] == "none" + + state = controller.reconcile(state, observation(plan), plan) + assert state["phase"] == "ABSENT" + assert state["next_action"] == "start_windows" + + state = controller.reconcile( + state, + observation(plan, windows=True, windows_job="running"), + plan, + ) + assert state["phase"] == "WINDOWS_RUNNING" + assert state["windows_consumed"] is True + assert state["next_action"] == "none" + + windows_path = tmp_path / "windows.json" + windows_path.write_text( + json.dumps(platform_evidence("windows")), + encoding="utf-8", + ) + windows_digest = "sha256:" + hashlib.sha256(windows_path.read_bytes()).hexdigest() + state = controller.reconcile( + state, + observation( + plan, + windows=True, + windows_job="passed", + windows_digest=windows_digest, + ), + plan, + ) + assert state["next_action"] == "collect_windows" + state = controller.collect_platform(state, plan, "windows", windows_path) + assert state["phase"] == "WINDOWS_DELETING" + assert state["next_action"] == "delete_windows" + + state = controller.reconcile( + state, + observation( + plan, + windows_job="passed", + windows_digest=windows_digest, + ), + plan, + ) + assert state["phase"] == "WINDOWS_DELETING" + assert state["next_action"] == "start_linux" + + state = controller.reconcile( + state, + observation( + plan, + linux=True, + windows_job="passed", + windows_digest=windows_digest, + linux_job="running", + ), + plan, + ) + assert state["phase"] == "LINUX_RUNNING" + assert state["linux_consumed"] is True + + linux_path = tmp_path / "linux.json" + linux_path.write_text( + json.dumps(platform_evidence("linux")), + encoding="utf-8", + ) + linux_digest = "sha256:" + hashlib.sha256(linux_path.read_bytes()).hexdigest() + state = controller.reconcile( + state, + observation( + plan, + linux=True, + windows_job="passed", + windows_digest=windows_digest, + linux_job="passed", + linux_digest=linux_digest, + ), + plan, + ) + assert state["next_action"] == "collect_linux" + state = controller.collect_platform(state, plan, "linux", linux_path) + assert state["phase"] == "LINUX_DELETING" + assert state["next_action"] == "delete_linux" + + state = controller.reconcile( + state, + observation( + plan, + windows_job="passed", + windows_digest=windows_digest, + linux_job="passed", + linux_digest=linux_digest, + ), + plan, + ) + assert state["phase"] == "CLEANED_PASS" + assert state["cleanup_verified"] is True + assert state["next_action"] == "none" + + +def test_failed_job_goes_directly_to_exact_cleanup(plan): + state = controller.initial_state(plan) + state = controller.reconcile( + state, + observation(plan, windows=True, windows_job="running"), + plan, + ) + state = controller.reconcile( + state, + observation(plan, windows=True, windows_job="failed"), + plan, + ) + assert state["phase"] == "CLEANING_FAILED" + assert state["next_action"] == "cleanup_failure" + + state = controller.reconcile(state, observation(plan), plan) + assert state["phase"] == "CLEANED_FAILURE" + assert state["cleanup_verified"] is True + + +def test_foreign_or_overlapping_resource_observations_fail_closed(plan): + state = controller.initial_state(plan) + value = observation(plan, windows=True, windows_job="running") + value["instances"][plan.windows.instance]["source_commit"] = "9" * 40 + with pytest.raises(controller.Gate14ControllerError): + controller.reconcile(state, value, plan) + + value = observation( + plan, + windows=True, + linux=True, + windows_job="running", + linux_job="running", + ) + state = controller.reconcile( + state, + observation(plan, windows=True, windows_job="running"), + plan, + ) + state = { + **state, + "phase": "WINDOWS_DELETING", + "next_action": "delete_windows", + } + with pytest.raises(controller.Gate14ControllerError): + controller.validate_observation( + { + **value, + "l4_usage": 2, + }, + plan, + ) + + +def test_collect_rejects_wrong_package_and_save_round_trips(tmp_path, plan): + state = controller.initial_state(plan) + state = controller.reconcile( + state, + observation(plan, windows=True, windows_job="running"), + plan, + ) + evidence = platform_evidence("windows") + evidence["package"]["archive_sha256"] = "sha256:" + "9" * 64 + evidence_path = tmp_path / "wrong.json" + evidence_path.write_text(json.dumps(evidence), encoding="utf-8") + evidence_digest = "sha256:" + hashlib.sha256(evidence_path.read_bytes()).hexdigest() + state = controller.reconcile( + state, + observation( + plan, + windows=True, + windows_job="passed", + windows_digest=evidence_digest, + ), + plan, + ) + with pytest.raises(controller.Gate14ControllerError): + controller.collect_platform(state, plan, "windows", evidence_path) + + state_path = tmp_path / "state.json" + controller.save_state(state_path, state, plan) + assert controller.load_state(state_path, plan) == state + + +def test_begin_cleanup_is_idempotent_after_terminal_state(plan): + state = controller.initial_state(plan) + state = controller.begin_cleanup(state, plan, "manual-stop") + assert state["phase"] == "CLEANING_FAILED" + state = controller.reconcile(state, observation(plan), plan) + assert state["phase"] == "CLEANED_FAILURE" + assert controller.begin_cleanup(state, plan, "manual-stop") == state + + +def test_forged_success_and_deletion_states_fail_closed(plan): + initial = controller.initial_state(plan) + forged_pass = { + **initial, + "phase": "CLEANED_PASS", + "cleanup_verified": True, + "next_action": "none", + } + with pytest.raises(controller.Gate14ControllerError): + controller.validate_state(forged_pass, plan) + + forged_windows_deleting = { + **initial, + "phase": "WINDOWS_DELETING", + "windows_consumed": True, + "next_action": "start_linux", + } + with pytest.raises(controller.Gate14ControllerError): + controller.validate_state(forged_windows_deleting, plan) + + forged_linux_deleting = { + **initial, + "phase": "LINUX_DELETING", + "windows_consumed": True, + "linux_consumed": True, + "windows_evidence_digest": "sha256:" + "c" * 64, + "linux_evidence_digest": "sha256:" + "d" * 64, + "next_action": "delete_linux", + } + with pytest.raises(controller.Gate14ControllerError): + controller.reconcile(forged_linux_deleting, observation(plan), plan) + + +def test_expired_run_never_returns_a_start_action(plan): + value = observation(plan) + value["observed_at_unix"] = plan.windows.termination_unix + + state = controller.reconcile(controller.initial_state(plan), value, plan) + + assert state["phase"] == "CLEANED_FAILURE" + assert state["failure_code"] == "run-expired" + assert state["cleanup_verified"] is True + assert state["next_action"] == "none" + + +def test_passed_job_requires_and_binds_exact_evidence_digest(tmp_path, plan): + missing = observation(plan, windows=True, windows_job="passed") + with pytest.raises(controller.Gate14ControllerError): + controller.validate_observation(missing, plan) + + stale = controller.reconcile( + controller.initial_state(plan), + observation( + plan, + windows_job="passed", + windows_digest="sha256:" + "c" * 64, + ), + plan, + ) + assert stale["phase"] == "CLEANED_FAILURE" + assert stale["failure_code"] == "stale-windows-job" + assert stale["next_action"] == "none" + + state = controller.reconcile( + controller.initial_state(plan), + observation(plan, windows=True, windows_job="running"), + plan, + ) + reported_digest = "sha256:" + "c" * 64 + state = controller.reconcile( + state, + observation( + plan, + windows=True, + windows_job="passed", + windows_digest=reported_digest, + ), + plan, + ) + evidence_path = tmp_path / "different.json" + evidence_path.write_text( + json.dumps(platform_evidence("windows")), + encoding="utf-8", + ) + assert "sha256:" + hashlib.sha256(evidence_path.read_bytes()).hexdigest() != reported_digest + with pytest.raises(controller.Gate14ControllerError): + controller.collect_platform(state, plan, "windows", evidence_path) + + +@pytest.mark.parametrize("platform", ["windows", "linux"]) +def test_initial_state_requires_all_planned_disks_absent_before_start(plan, platform): + value = observation( + plan, + **{f"{platform}_disk": True}, + ) + + state = controller.reconcile(controller.initial_state(plan), value, plan) + + assert state["phase"] == "CLEANING_FAILED" + assert state["failure_code"] == "orphaned-planned-disk" + assert state["next_action"] == "cleanup_failure" + + +def test_stale_passed_job_with_orphan_disk_cannot_claim_terminal_cleanup(plan): + state = controller.reconcile( + controller.initial_state(plan), + observation( + plan, + windows_job="passed", + windows_digest="sha256:" + "c" * 64, + windows_disk=True, + ), + plan, + ) + + assert state["phase"] == "CLEANING_FAILED" + assert state["cleanup_verified"] is False + assert state["next_action"] == "cleanup_failure" + + +def test_linux_start_requires_absent_disk_and_absent_stale_job(plan): + windows_digest = "sha256:" + "c" * 64 + state = { + **controller.initial_state(plan), + "revision": 2, + "phase": "WINDOWS_DELETING", + "windows_evidence_digest": windows_digest, + "windows_consumed": True, + "next_action": "delete_windows", + } + orphan = controller.reconcile( + state, + observation( + plan, + windows_job="passed", + windows_digest=windows_digest, + linux_disk=True, + ), + plan, + ) + assert orphan["phase"] == "CLEANING_FAILED" + assert orphan["failure_code"] == "orphaned-linux-disk" + assert orphan["next_action"] == "cleanup_failure" + + stale_job = controller.reconcile( + state, + observation( + plan, + windows_job="passed", + windows_digest=windows_digest, + linux_job="passed", + linux_digest="sha256:" + "d" * 64, + ), + plan, + ) + assert stale_job["phase"] == "CLEANED_FAILURE" + assert stale_job["failure_code"] == "stale-linux-job" + assert stale_job["cleanup_verified"] is True + assert stale_job["next_action"] == "none" + + +@pytest.mark.parametrize("returned_resource", [{"windows": True}, {"windows_disk": True}]) +def test_linux_deletion_escalates_returned_windows_resources(plan, returned_resource): + windows_digest = "sha256:" + "c" * 64 + linux_digest = "sha256:" + "d" * 64 + state = { + **controller.initial_state(plan), + "revision": 5, + "phase": "LINUX_DELETING", + "windows_evidence_digest": windows_digest, + "linux_evidence_digest": linux_digest, + "windows_consumed": True, + "linux_consumed": True, + "next_action": "delete_linux", + } + + state = controller.reconcile( + state, + observation( + plan, + windows_job="passed", + windows_digest=windows_digest, + linux_job="passed", + linux_digest=linux_digest, + **returned_resource, + ), + plan, + ) + + assert state["phase"] == "CLEANING_FAILED" + assert state["failure_code"] == "windows-resources-returned" + assert state["next_action"] == "cleanup_failure" + + +def test_load_plan_recomputes_total_ledger_commitment(tmp_path): + with pytest.raises(controller.Gate14ControllerError): + write_plan(tmp_path, additional_current_maximum="99.00") + + +def test_load_plan_rejects_hidden_active_reservation_below_epoch_anchor(tmp_path): + with pytest.raises(controller.Gate14ControllerError): + write_plan( + tmp_path, + additional_current_maximum="1.00", + hide_additional_below_anchor=True, + ) From a813ef100d9b5e027a45c334d99d4be04ee72893 Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:31:47 -0500 Subject: [PATCH 56/56] docs: record Gate 14 software preflight --- CHANGELOG.md | 8 ++++++-- docs/RELEASE_READINESS.md | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2e6e874c..aeda0bcc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,12 @@ and qualification evidence remains in `docs/REVIVAL_TEST_RESULTS.md`. exact authorization bytes, controller source, provider plan, project, zone, resources, and successful terminal state while excluding the protected bootstrap from cleanup targets. The focused 49-test suite covers deadline, rollback, orphan/returned-resource inventory, forged-state, - evidence-substitution, cross-platform, hidden/excess spend, and cleanup-binding failures. This is software evidence only: - no cloud resource or Gate 14 hardware pass is claimed yet. + evidence-substitution, cross-platform, hidden/excess spend, and cleanup-binding failures. Exact source + `c0f2342e15aa7e12ca7c2980deca64d613204143` passed independent adversarial review, CodeQL, + style, Linux/Windows tests, and production-package provenance verification. A native read-only GCP + preflight stopped before inventory or quota inspection because the configured account requires + interactive reauthentication; no reservation or resource was created, so the current-epoch USD 44 + remainder is intact. This is software and package evidence only: no Gate 14 hardware pass is claimed yet. - Gate 13's successful manual desktop flow now has a bounded automated replay. The production package can open its real Qt window in a hidden qualification mode, perform localhost inference, save the actual sharing-policy dialog, click Start, exit, relaunch, diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md index 32e5336fa..a3c6103f5 100644 --- a/docs/RELEASE_READINESS.md +++ b/docs/RELEASE_READINESS.md @@ -102,7 +102,7 @@ longer consume the new authorization; later billing should still be recorded for | 11 | Operate initial public alpha routes | PASSED | [Product-node run `route-20260830-j`](evidence/gate11node-20260830-a-lifecycle.json) installed the generic CommunityAI wheel on a bounded G2/L4 VM, verified the signed catalog, downloaded both exact manifested models directly from Hugging Face into one persistent shared cache, and used the product node's automatic workers to expose complete Qwen 24/24 primary and Gemma 35/35 standby routes. No model-specific image, cache mirror, or operator-transferred model artifact was used. The privacy-safe acceptance passed one-token primary inference, deliberate primary pause, automatic Gemma selection in 58.073 seconds, standby inference, Qwen restoration in 32.042 seconds, and restored inference. Both workers were stable before the drill. After Gate 13 released the L4, the preserved route was restored without changing its model cache or source, its ephemeral endpoint was rebound, both product-node services became active, and a fresh acceptance reproved Qwen 24/24 primary inference, automatic Gemma 35/35 fallback/inference, Qwen restoration, and restored inference. The protected bootstrap remains running. A corrected 4,800-second provider DELETE backstop was set for `2026-08-31T05:28:16.516Z`, earlier than the original deadline. [Post-backstop cleanup evidence](evidence/gate11route-20260830-j-backstop-cleanup.json) and an independent recheck prove the route instance, named disk, and both exact run-scoped firewall rules absent, all Gate 13 clients/disks absent, zero remaining route availability, and the protected bootstrap still running. The same-host standby is a bounded alpha fallback, not independent infrastructure redundancy; independent redundancy remains post-alpha. | Gate 11 acceptance evidence remains complete, but no product route is live after the corrected DELETE backstop. [Gate 13 run `gate13-20260831-a`](evidence/gate13-20260831-a-cost-authorization.json) now binds refreshed native authentication, fail-closed preflight, and a fresh USD 52 reservation for the replacement route and packaged clients. | | 12 | Create, publish, and bundle the minimal signed alpha catalog/bootstrap | PASSED | [Run `gate12-20260829-a`](evidence/gate12-20260829-alpha-catalog-publication.json) published the deterministic [`communityai-public-alpha-v1` bundle](../public-alpha/catalog-v1/bundle.json) from source `26be579`. Its threshold-one Ed25519 root signs sequence 1 with the exact qualified Qwen primary and Gemma standby manifests, one pinned public HTTPS mirror, one public seed, a one-route best-effort policy, and no unprovisioned route-demand roots. The canonical bundle binds five members and retains `complete_release_qualification=false`. All three public objects returned HTTP 200 with exact sizes, and a fresh empty consumer fetched them remotely, verified the signature/digests, and created the two-model `auto` node configuration. The private signing key remained ignored and uncommitted. The focused publication suite passes 32 tests, the catalog/bootstrap/model/desktop superset passes 92, and the run spent USD 0. | Preserve the branch-scoped mirror until a newly signed catalog sequence and packaged bootstrap migrate it. The Gate 11 acceptance and Gate 9 envelopes exist; [Gate 13 run `gate13-20260831-a`](evidence/gate13-20260831-a-cost-authorization.json) now authorizes the bounded replacement route and fresh packaged clients under the new epoch. Independent threshold holders and interchangeable mirror/seed governance are post-alpha. | | 13 | Pass packaged clean-install inference on Windows and Linux | PASSED | [Manual run `gate13-20260831-i`](evidence/gate13-20260831-i-manual-qualification-and-cleanup.json) found and fixed the Windows legacy-MAX_PATH blocker while proving the literal clean-host flow. [Automated paid-cloud run `gate13-20260901-a`](evidence/gate13-20260901-a-automated-qualification-and-cleanup.json) translates that flow into two real-window sessions per platform from exact production packages. Formal attempt 1 passed unattended on Windows/Qwen and Linux/Gemma: package verification, four self-tests, inference, policy save, per-model normalization, literal Start, observation, full process restart, Pause, final paused intent, and Linux restart-resume plus second inference. The route passed exact 24/24 and 35/35 stable fences. All run instances, disks, and firewalls are absent, L4 usage is zero, and the protected bootstrap is running. | Proceed to Gate 14 without replaying Gate 13 discovery. Gate 15 owns reinstall/uninstall/retained-data release engineering. | -| 14 | Pass automatic-contribution and resource-control hardware checks | IN PROGRESS | [PR #11](https://github.com/flujo-app/CommunityAI/pull/11) and [PR #12](https://github.com/flujo-app/CommunityAI/pull/12) implemented authenticated node-authoritative sharing controls. [Automated Gate 13 run `gate13-20260901-a`](evidence/gate13-20260901-a-automated-qualification-and-cleanup.json) proves the real packaged Sharing UI starts, survives restart, resumes on Linux, and pauses on both supported platforms without manual UI recovery. The new strict Gate 14 verifier pins that lifecycle evidence plus the exact Windows/Qwen and Linux/Gemma Gate 9 envelopes, platform/OS pair, production package, L4 device profile, all five configured resource classes, suspension/resume, automatic block placement, recovery, unsupported CPU power telemetry, privacy, and exact GCP cleanup. Its durable controller recomputes the reset current-epoch spend ledger under the USD 100 ceiling, rejects expired/foreign/overlapping resources, serializes fresh Windows then Linux hosts, binds reported evidence bytes before collection, rejects rolled-back completed jobs, hidden active reservations, orphan planned disks before every fresh start, and resources that return during teardown, and requires cleanup to match the exact authorization file, controller source, plan, project, zone, resources, and successful terminal state while excluding the protected bootstrap. The 49-test focused suite, formatting, import-order, compilation, and diff checks pass locally. No provider resource or Gate 14 pass is claimed by this software evidence. | Push the independently adversarially reviewed source-bound controller/verifier, then reserve at most the remaining USD 44 only after native-auth, quota, exact-inventory, pricing, and cleanup preflight. Run only the remaining Gate 14 checks sequentially on fresh Windows/Linux L4 hosts; reuse the cited Gate 13 lifecycle evidence instead of repeating clean install. | +| 14 | Pass automatic-contribution and resource-control hardware checks | IN PROGRESS | [PR #11](https://github.com/flujo-app/CommunityAI/pull/11) and [PR #12](https://github.com/flujo-app/CommunityAI/pull/12) implemented authenticated node-authoritative sharing controls. [Automated Gate 13 run `gate13-20260901-a`](evidence/gate13-20260901-a-automated-qualification-and-cleanup.json) proves the real packaged Sharing UI starts, survives restart, resumes on Linux, and pauses on both supported platforms without manual UI recovery. The new strict Gate 14 verifier pins that lifecycle evidence plus the exact Windows/Qwen and Linux/Gemma Gate 9 envelopes, platform/OS pair, production package, L4 device profile, all five configured resource classes, suspension/resume, automatic block placement, recovery, unsupported CPU power telemetry, privacy, and exact GCP cleanup. Its durable controller recomputes the reset current-epoch spend ledger under the USD 100 ceiling, rejects expired/foreign/overlapping resources, serializes fresh Windows then Linux hosts, binds reported evidence bytes before collection, rejects rolled-back completed jobs, hidden active reservations, orphan planned disks before every fresh start, and resources that return during teardown, and requires cleanup to match the exact authorization file, controller source, plan, project, zone, resources, and successful terminal state while excluding the protected bootstrap. Exact source [`c0f2342e15aa7e12ca7c2980deca64d613204143`](https://github.com/flujo-app/CommunityAI/commit/c0f2342e15aa7e12ca7c2980deca64d613204143) passed independent adversarial review, the 49-test focused suite, formatting, import-order, compilation, and diff checks; [CodeQL](https://github.com/flujo-app/CommunityAI/actions/runs/33659218953), [style](https://github.com/flujo-app/CommunityAI/actions/runs/33659223681), and [Linux/Windows tests](https://github.com/flujo-app/CommunityAI/actions/runs/33659223645) are green. Its [production desktop run](https://github.com/flujo-app/CommunityAI/actions/runs/33659223622) passed exact-source build, smoke, independent checksum/provenance verification, packaged-node/native-credential/public-seed exercise on Windows, and all four uploads. The retained artifact digests are Windows install `sha256:44ab9faa5dae4537bae60e005ac790ce2b4f3636a2f5ad5327de713302a37b2c` (2,695,084,318 bytes), Windows audit `sha256:55e924e4a36f6deeb0e37bd64979f20debc36227503616f71c1f40622241ccc7` (468,605 bytes), Linux install `sha256:1494cb0bb4c37de1c8825c20d5400e76b4bbc62eefbd4010a161427a85f164c8` (3,360,751,567 bytes), and Linux audit `sha256:5ac2ce726235ef6c1ae3f1dea6faf89c9e42d61a2d0d7cd0f905ef5fe92deb91` (514,731 bytes), expiring 2026-09-09. A 2026-09-02 native read-only GCP preflight stopped before inventory or quota inspection because the configured account requires interactive reauthentication. No reservation or provider resource was created, and the current-epoch USD 44 remainder is intact. No Gate 14 hardware pass is claimed by this evidence. | Implement the thin source-bound Windows/Linux host probes and GCP action executor, including calibrated physical power and bandwidth triggers. Then refresh interactive GCP authentication, repeat exact inventory/quota/pricing/cleanup preflight, reserve at most USD 44, and run the remaining Gate 14 checks sequentially on fresh Windows/Linux L4 hosts while reusing the cited Gate 13 lifecycle evidence. | | 15 | Complete minimal alpha release engineering | WAITING | The desktop builder now emits a stable sorted `SHA256SUMS` inventory of exact regular-file bytes and safe relative in-bundle file symlinks, source/build/catalog-bound `provenance.json`, and `release-metadata.json` with explicit unsigned public-alpha, no-publisher-signature, no-authenticated-update, Windows/Linux-only, no-credits, and incomplete-qualification claims. Structural verification binds each safe file symlink to its canonical in-bundle target, digest, and size while rejecting changed, missing, extra, absolute, external, broken, cyclic, directory-linked/junction, special, traversal, or case-colliding payloads plus unsupported or noncanonical metadata. Exact-source builds also reject dirty relevant inputs, and the expected-input fresh-process check rejects rewritten commit/tree, workflow, platform, Python, PyInstaller, or catalog evidence. Production desktop CI is configured to verify and bundle the Gate 12 inputs, bind the exact clean Git commit/tree and workflow, revalidate every expected input separately, and upload all evidence on Windows/Linux. The focused release-input/artifact suite passes 15 tests, including fresh-process CLI, dirty-source, and canonical-rewrite checks, and the broader catalog/bootstrap/model/desktop subset passes 134. Independent verification reproduced all 134, passed 58 desktop unittests with two environment skips, formatting/import-order/YAML/diff checks, an expected Gate 12/workflow fresh-process probe, and real Windows junction rejection; no cloud was used. [The first PR #22 production-desktop run](https://github.com/flujo-app/CommunityAI/actions/runs/33273518744) reached packaging on both hosts and exposed two exact cross-platform defects: PyInstaller's legitimate relative internal Qt file symlink on Ubuntu and CRLF-transformed signed Gate 12 JSON on Windows. The follow-up binds safe internal file symlinks without accepting external or directory links, forces `public-alpha/**` to LF at checkout, and includes `.gitattributes` in the clean-source boundary. [The second run](https://github.com/flujo-app/CommunityAI/actions/runs/33274432423) proved the Ubuntu package and the Windows signed-bundle/provenance path, then exposed a stale desktop contribution-status schema 2 contract when the packaged node emitted schema 3 automatic-placement evidence. Source `fcd1f41` now strictly validates schema 3 placement and rejects stale schema 2 plus missing, extra, secret-bearing, or inconsistent placement data; its 50-test node/client/lifecycle/build focus and all 59 desktop unittests passed with two environment skips. [The final run](https://github.com/flujo-app/CommunityAI/actions/runs/33275216332) bound exact source `fcd1f417d1435557addb2d6cded9dac0827c7d8c` and completed both Windows and Ubuntu package jobs, including bundle build/smoke, independent checksum/provenance verification, the Windows packaged-node/native-credential/public-seed smoke, and artifact uploads; every PR style, test, and package check is green. Source `36d85d2` makes generic release-artifact fixtures select the supported Linux archive explicitly instead of inheriting the CI host platform; the 21-test local artifact suite and [PR #22 test run 33372581439](https://github.com/flujo-app/CommunityAI/actions/runs/33372581439) pass, without expanding the supported platform matrix. Gate 13 manual clean-install evidence now exists; Gate 15 still lacks its upgrade/reinstall/uninstall and retained-data release evidence. | Retain the verified Windows/Linux artifacts as engineering evidence, then test clean install, manual upgrade/reinstall, uninstall, retained-data choice for the persistent verified model cache, and recovery instructions on both platforms against a newly authorized live product-node route and the published Gate 9 envelopes. Do not mark passed from metadata/unit tests alone. Publisher signing and automatic authenticated update/rollback are post-alpha. | | 16 | Complete the bounded public-alpha safety canary | WAITING | [PR #13](https://github.com/flujo-app/CommunityAI/pull/13) and [PR #14](https://github.com/flujo-app/CommunityAI/pull/14) implemented bounded admission, privacy-safe aggregate health, training-off defaults, rollback procedures, and bounded routine rejection logs; no public canary has run. | After Gates 11–15, run a small monitored canary proving finite admission/timeouts, malformed-peer rejection, health reconstruction, privacy disclosure, route/catalog disable, and clean rollback. Exhaustive hostile-load, Sybil/collusion, partition, and long-soak campaigns are post-alpha. | | 17 | Publish and observe the public alpha | TODO | Owner has authorized a public inference alpha, but preceding mandatory alpha gates are open. | After Gate V and Gates 1–16 pass, publish with explicit best-effort availability, unsigned-package, support, and prompt-privacy limitations; preserve the disable path and monitor real route/worker failures. |