diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6757b71..08213a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -155,16 +155,22 @@ jobs: python -m pytest tests/test_wasmer_parity.py -q test -f test-results/wasmer-parity/parity.json - # Mobile Wasmer device matrix: explicit NOT_RUN (no device farm in CI). - # Desktop packaged shell + browser headless covered elsewhere (wasmer-browser.yml / desktop/). + # Mobile hosts: sources + status file. Runtime grade is iOS PARTIAL / Android + # NOT_RUN until an emulator or device log exists (see wasmer-mobile.yml). mobile: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Assert mobile NOT_RUN docs present + - name: Assert mobile host trees and status ADR run: | + set -euo pipefail test -f wasmer/mobile/NOT_RUN.md + test -f wasmer/mobile/shared/host.js + test -d wasmer/mobile/ios/Sources + test -d wasmer/mobile/android/app/src/main/java test -f wasmer/desktop/run-decide.sh - echo "mobile Wasmer target: NOT_RUN — see wasmer/mobile/NOT_RUN.md" - echo "Desktop shell: wasmer/desktop/run-decide.sh (packaged beyond raw wasm bytes)" + echo "status ADR: wasmer/mobile/NOT_RUN.md" + echo "iOS simulator / Android emulator: workflow wasmer-mobile.yml" + echo "Desktop shell: wasmer/desktop/run-decide.sh" echo "Browser headless: workflow wasmer-browser.yml" + diff --git a/.github/workflows/wasmer-mobile.yml b/.github/workflows/wasmer-mobile.yml new file mode 100644 index 0000000..7f1b3a1 --- /dev/null +++ b/.github/workflows/wasmer-mobile.yml @@ -0,0 +1,92 @@ +name: Wasmer mobile hosts + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + glue-and-android-sources: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Size budget + shared glue + run: | + set -euo pipefail + python scripts/wasmer_size_budget.py + chmod +x wasmer/mobile/sync-assets.sh + ./wasmer/mobile/sync-assets.sh + node scripts/wasmer_mobile_glue_check.mjs + - name: Assert host trees + run: | + set -euo pipefail + test -f wasmer/mobile/NOT_RUN.md + test -d wasmer/mobile/ios/Sources + test -d wasmer/mobile/android/app/src/main/java + test -f wasmer/mobile/shared/host.js + - name: Android emulator (honest skip on ubuntu without device farm) + run: | + set +e + ./wasmer/mobile/android/run-emulator.sh + RC=$? + set -e + if [ "$RC" -eq 0 ]; then + echo "[PASS] unexpected emulator success on ubuntu — keep evidence" + elif [ "$RC" -eq 2 ]; then + echo "[NOT_RUN] Android emulator not available on this runner (expected unless SDK is provisioned)" + else + echo "[FAIL] Android runner exit $RC" + exit "$RC" + fi + - name: Upload mobile glue evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: s-desktop-mobile-glue + path: | + test-results/s-desktop-mobile/mobile-glue-check.json + test-results/s-desktop-mobile/mobile-glue-check-*.log.txt + test-results/s-desktop-mobile/mobile-android.json + test-results/s-desktop-mobile/mobile-android-*.log.txt + if-no-files-found: warn + + ios-simulator: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install xcodegen + run: brew install xcodegen + - name: iOS Simulator decide parity + run: | + set +e + chmod +x wasmer/mobile/ios/run-simulator.sh wasmer/mobile/sync-assets.sh + ./wasmer/mobile/ios/run-simulator.sh + RC=$? + set -e + if [ "$RC" -eq 0 ]; then + echo "[PASS] iOS simulator" + elif [ "$RC" -eq 2 ]; then + echo "[NOT_RUN] iOS simulator unavailable on this runner — not faked" + else + echo "[FAIL] iOS simulator tests" + exit "$RC" + fi + - name: Upload iOS evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: s-desktop-mobile-ios + path: | + test-results/s-desktop-mobile/mobile-ios-simulator.json + test-results/s-desktop-mobile/mobile-ios-simulator-*.log.txt + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index 5cd8ad7..d07845e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,10 @@ build/ *.log test-results/**/*.log.txt !test-results/**/.gitkeep + +# Wasmer package build output (reproducible from wasmer.toml; digest recorded +# in wasmer/artifacts/PACKAGE-DIGESTS.json) +*.webc .DS_Store # Rust / Wasmer crate @@ -21,7 +25,25 @@ wasmer/crate/target/ # Track J browser smoke wasmer/browser/node_modules/ +# Copied from node_modules/@wasmer/sdk by wasmer/browser/vendor-sdk.mjs (postinstall) +wasmer/browser/vendor/ # Local Node microservices (agy-bridge, etc.) node_modules/ services/**/node_modules/ + +# Mobile host build products and copied wasm (sync-assets.sh) +wasmer/mobile/shared/compass_core_bg.wasm +wasmer/mobile/shared/snapshot_min.json +wasmer/mobile/ios/Resources/* +!wasmer/mobile/ios/Resources/.gitkeep +wasmer/mobile/ios/DerivedData/ +wasmer/mobile/ios/build/ +wasmer/mobile/ios/*.xcuserdatad +wasmer/mobile/ios/**/xcuserdata/ +wasmer/mobile/android/.gradle/ +wasmer/mobile/android/app/build/ +wasmer/mobile/android/build/ +wasmer/mobile/android/local.properties +wasmer/mobile/android/app/src/main/assets/* +!wasmer/mobile/android/app/src/main/assets/.gitkeep diff --git a/CHANGELOG.md b/CHANGELOG.md index 54b5b80..dd6144a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ Versioning follows [SemVer](https://semver.org/) as described in [`docs/RELEASE. ## [Unreleased] +- Wasmer mobile hosts — iOS WKWebView **PARTIAL** on Simulator (`compass_decide_json` parity vs Python); Android WebView tree **NOT_RUN** (no SDK). Evidence `test-results/s-desktop-mobile/mobile-*.json`. +- Docs consolidation — `docs/FRAMEWORK.md` is now the canonical framework document (ADR 0001–0007 stated as current, supersession ledger in Appendix B); `PROTOTYPE.md` demoted to historical origin brief; "Ground truth" pointers repointed. +- Schema single-source — `src/compass/schema/model-graph.v1.json` is canonical (the only copy in the sdist/wheel); `schema/` and `docs/schema/` are generated mirrors written by `scripts/sync_schema.py`; `tests/test_schema.py` fails on checksum drift. Evidence `test-results/p-consolidation/`. - Track N — paid pillars test-ready: `compass.sync` (paid automate / free manual), `compass.fleet` stub (opt-in), `compass.serve.governance` hooks; evidence `test-results/n-paid-pillars/`. - Phase 2 **test-ready stack exit** flipped 2026-09-05 (PT) — F–N evidence present; **not** production-ready / SLA. diff --git a/PLANS.md b/PLANS.md index b76798b..cfc3bb6 100644 --- a/PLANS.md +++ b/PLANS.md @@ -1,7 +1,8 @@ # comPASS program plans **Date:** 2026-09-07 -**Ground truth:** [`/Users/rosario/work/comPASS/PROTOTYPE.md`](/Users/rosario/work/comPASS/PROTOTYPE.md) +**Ground truth:** [`docs/FRAMEWORK.md`](docs/FRAMEWORK.md) — canonical framework document +**Origin brief (historical):** [`PROTOTYPE.md`](PROTOTYPE.md) — 2026-09-03, superseded in part by ADR 0005/0006/0007 **Summary:** [`/Users/rosario/work/comPASS/SUMMARY/2026-09-03-comPASS-prototype-session.md`](/Users/rosario/work/comPASS/SUMMARY/2026-09-03-comPASS-prototype-session.md) **Canonical compressor:** `git@github.com:soltrinox/comPREssOR.git` at [`/Users/rosario/work/comPREssOR`](/Users/rosario/work/comPREssOR) (engine 0.2.0, `main` @ `44460ba`, CC-1..CC-10) **Public sibling:** [`https://github.com/soltrinox/comPASS`](https://github.com/soltrinox/comPASS) @ `16e22ec` diff --git a/PROTOTYPE.md b/PROTOTYPE.md index 1b053e0..c6f6986 100644 --- a/PROTOTYPE.md +++ b/PROTOTYPE.md @@ -1,6 +1,29 @@ # comPASS — Capability-Routed Model Selection with Portable Session State -**Status:** Prototype specification (pre-implementation) +> **HISTORICAL — origin brief, 2026-09-03. Superseded in part; not the current architecture.** +> +> This document is the **provenance record** for comPASS: the original prototype specification, kept +> unedited in its body so the reasoning that produced the product remains readable. It is **no longer +> ground truth.** +> +> **Canonical document:** [`docs/FRAMEWORK.md`](docs/FRAMEWORK.md) — read that first. +> +> Superseded here, by Accepted ADRs: +> +> | Section | Superseded by | +> |---|---| +> | §9 three-plane **process layout** (Probe daemon sidecar, IDE hook, local proxy) — the plane *boundaries* still hold, the process topology does not | [ADR 0005](docs/adr/0005-eni6ma-gated-browser-agent.md) — browser-only Wasmer appliance | +> | §13.1 "advisory, inside Cursor" as Tier 2's primary enforcement surface | [ADR 0005](docs/adr/0005-eni6ma-gated-browser-agent.md) — no Cursor/IDE product path; [ADR 0006](docs/adr/0006-generic-llm-adapter.md) — the generic LLM adapter is the enforcement target | +> | §13.1 OpenAI-compatible proxy as three separate enforcement targets | [ADR 0006](docs/adr/0006-generic-llm-adapter.md) — one ingress, three selection modes | +> | Appendix A.1 open naming decision | [ADR 0001](docs/adr/0001-product-name.md) — **comPASS** / `compass-router`, accepted | +> | §17.1 working-copy disposition | [ADR 0002](docs/adr/0002-working-copy-disposition.md) / [ADR 0003](docs/adr/0003-archive-disposition.md) | +> | §12.4 reward attribution left open | [ADR 0004](docs/adr/0004-reward-attribution.md) — recording mechanism decided; credit assignment still **not** claimed solved | +> +> Product science (§4 capability curvature, §10 schema rationale, §12 statistical discipline, §16 +> equivalence band) and the non-claims are **unchanged and still current**. The full conflict list is +> the supersession ledger in [`docs/FRAMEWORK.md`](docs/FRAMEWORK.md) Appendix B. + +**Status:** Prototype specification (pre-implementation) — **historical**, see banner above **Date:** 2026-09-03 **Working name:** `comPASS` (placeholder — see Appendix A) **Sibling engine:** `comPREssOR` — `git@github.com:soltrinox/comPREssOR.git`, engine version `0.2.0` diff --git a/README.md b/README.md index 38dd904..03cb210 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ Contracts and architecture live under [`docs/`](docs/). | Doc | Purpose | | --- | --- | -| [`PROTOTYPE.md`](PROTOTYPE.md) | Ground-truth product prototype (§9–§17) | +| [`docs/FRAMEWORK.md`](docs/FRAMEWORK.md) | **Canonical framework** — start here; ADR 0001–0007 as current | +| [`PROTOTYPE.md`](PROTOTYPE.md) | Historical origin brief (2026-09-03); superseded in part | | [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | Three planes, four tiers, credential boundary | | [`docs/API.md`](docs/API.md) | Route plane API, fail-open, advisory contract | | [`docs/STACK.md`](docs/STACK.md) | Stack + Wasmer boundary (Track D contract) | diff --git a/SUMMARY/2026-09-07-b2-browser-sdk-boot.md b/SUMMARY/2026-09-07-b2-browser-sdk-boot.md new file mode 100644 index 0000000..7ba3738 --- /dev/null +++ b/SUMMARY/2026-09-07-b2-browser-sdk-boot.md @@ -0,0 +1,17 @@ +# Session summary — B2 browser @wasmer/sdk boot (2026-09-07 PT) + +**Branch:** `feat/consolidation-and-wasmer` +**Scope:** Plan todos `b2-csp`, `b2-sdk`, `b2-zonea`. +**Grade:** PARTIAL (local SDK + compass guest green; registry `compass/decide` NOT_RUN). + +## What landed + +- nginx CSP in `services/browser-client/nginx.conf`: `worker-src 'self' blob: 'wasm-unsafe-eval' 'unsafe-eval'`; `blob:` and `'unsafe-eval'` on `script-src`; `connect-src` adds `https://registry.wasmer.io` and `https://cdn.wasmer.io`. COOP/COEP unchanged. +- `@wasmer/sdk` **0.11.0** in `wasmer/browser/package.json`. Dynamic import of the `/browser` entry file (`vendor/@wasmer/sdk/dist/index.js`). Guard on `window.crossOriginIsolated`. +- Zone A: local `compass-decide` `.webc` + host `compass_decide_json`; pinned `python/python@=3.13.18` from the registry (stdout `zone-a-python-ok`). +- Fail-open: existing `sandbox.js` raw instantiate when the page is not isolated. Track J smoke stayed FULL. +- Evidence: `test-results/r-browser-sdk/`. + +## NOT_RUN + +Registry `compass/decide` — unpublished, not attempted. diff --git a/SUMMARY/2026-09-07-b3-desktop-reconcile.md b/SUMMARY/2026-09-07-b3-desktop-reconcile.md new file mode 100644 index 0000000..2bb5494 --- /dev/null +++ b/SUMMARY/2026-09-07-b3-desktop-reconcile.md @@ -0,0 +1,33 @@ +# Session summary — B3 desktop reconcile (2026-09-07 PT) + +**Branch:** `feat/consolidation-and-wasmer` +**Scope:** Plan todo `b3-desktop` only. Did not edit the plan file. +**Evidence:** [`test-results/s-desktop-mobile/`](../test-results/s-desktop-mobile/README.md) (desktop-only files; no mobile coverage) + +## What changed + +`wasmer/desktop/run-decide.sh` now runs in this order: + +1. **Registry-by-name** — only if `COMPASS_WASMER_USE_REGISTRY=1` or `--registry`. Tries `wasmer run compass/decide@0.1.0`, logs an honest failure, falls through. +2. **Local `.webc` (default)** — `wasmer run --offline compass-decide-0.1.0.webc`, building with `wasmer package build` if missing. +3. **Air-gap wasm** — `wasmer/artifacts/compass-decide.wasm` with `--volume "$PWD/wasmer:/wasmer"`. + +`scripts/wasmer_parity.py` still shells `wasmer run` on the loose artifact (Python vs wasm). Packaged-vs-loose is `scripts/wasmer_desktop_packaged.py` / `tests/test_wasmer_desktop_packaged.py`. + +## Results + +| Check | Result | +|---|---| +| Packaged vs loose envelopes (`fixture_min`, `snapshot_missing`) | IDENTICAL | +| Registry-by-name | PARTIAL — code complete, runtime NOT_RUN, not faked | +| Registry then fall through to webc | envelope still selects `urn:mg:model:cheap` | +| `scripts/wasmer_parity.py` | green | +| `scripts/wasmer_size_budget.py` | green | +| Targeted pytest (3 modules, 4 tests) | 4 passed | + +## Left for later + +- **B4 mobile** — `wasmer/mobile/NOT_RUN.md`; this stage wrote desktop-only files under `test-results/s-desktop-mobile/` on purpose. +- Human `wasmer login` + claim namespace `compass` + `wasmer publish .` before the registry hop can be graded FULL. +- Stale `wasmer.toml` path references in `docs/WASMER.md` and `docs/WASMER-DEPLOYMENT.md` — owned by the docs consolidation agent. +- Full pytest and the cross-cutting proof report — out of scope for B3. diff --git a/SUMMARY/2026-09-07-b4-mobile-hosts.md b/SUMMARY/2026-09-07-b4-mobile-hosts.md new file mode 100644 index 0000000..2247233 --- /dev/null +++ b/SUMMARY/2026-09-07-b4-mobile-hosts.md @@ -0,0 +1,39 @@ +# Session summary — B4 mobile hosts (2026-09-07 PT) + +**Branch:** `feat/consolidation-and-wasmer` +**Scope:** Plan todo `b4-mobile` only. Did not edit the plan file. +**Grade:** **PARTIAL** (iOS Simulator). Android **NOT_RUN**. Not FULL (no physical-device log). + +## Host code + +| Path | Role | +|---|---| +| `wasmer/mobile/shared/host.js` | Digest check, empty-import instantiate, `compass_decide_json` | +| `wasmer/mobile/ios/` | WKWebView app + `run-simulator.sh` | +| `wasmer/mobile/android/` | System WebView app + `run-emulator.sh` | +| `scripts/validate-wasmer-mobile.sh` | Discoverable runner | + +## Five steps (`NOT_RUN.md`) + +1. Host pick: iOS WKWebView + Android WebView (same JS ABI as `sandbox.js`). +2. Trees added; wasm loaded by `SHA256SUMS` digest; keyless snapshot; `compass_decide_json`. +3. CI workflow `.github/workflows/wasmer-mobile.yml` (honest Android skip). +4. Status field flipped to PARTIAL; `docs/WASMER.md` matrix updated. Filename `NOT_RUN.md` retained for CI `test -f`. +5. Size budget green (103980 ≤ 150000); same hash as `SHA256SUMS`. + +## Runtime + +- **iOS Simulator:** iPhone 16 Pro / iOS 18.6 via `simctl` (Xcode 26.2 had no scheme simulator destinations). fixture_min → `urn:mg:model:cheap`; missing → `snapshot_missing`. +- **Android:** SDK absent — compile/emulator **NOT_RUN**. +- **Node glue:** same reason codes; not a device run. + +## Evidence + +`test-results/s-desktop-mobile/mobile-ios-simulator.json`, `mobile-android.json`, `mobile-glue-check.json`, `mobile-size-budget.json`. Desktop B3 files left in place. + +## Human blockers + +- Android SDK + licenses + API 34 emulator/device. +- iOS 26.2 Simulator runtime (for `xcodebuild -destination` tests). +- Development team / signing for a physical device (FULL). +- A connected iPad was ineligible (`iOS 26.2 is not installed` device support). diff --git a/SUMMARY/2026-09-07-consolidation-part-a-session.md b/SUMMARY/2026-09-07-consolidation-part-a-session.md new file mode 100644 index 0000000..c9689d9 --- /dev/null +++ b/SUMMARY/2026-09-07-consolidation-part-a-session.md @@ -0,0 +1,37 @@ +# Session summary — consolidation Part A (2026-09-07 PT) + +**Branch:** `feat/consolidation-and-wasmer` (stacked on `feat/docker-browser-challenge`) +**Scope:** Part A of the consolidation plan — `a1-framework`, `a2-demote`, `a3-schema`. +**Out of scope:** Part B (`wasmer/`, `services/`) — owned by a concurrent agent this session. +**Evidence:** [`test-results/p-consolidation/`](../test-results/p-consolidation/README.md) + +## What changed + +| Todo | Result | +|---|---| +| `a1-framework` | [`docs/FRAMEWORK.md`](../docs/FRAMEWORK.md) created — canonical framework document; ADR 0001–0007 stated as current; historical Phase 1–2 layout in Appendix A; supersession ledger in Appendix B | +| `a2-demote` | [`PROTOTYPE.md`](../PROTOTYPE.md) banner-demoted to historical origin brief with its body byte-intact; "Ground truth" repointed in `PLANS.md`, `docs/README.md`, `docs/CHARTER.md`; canonical pointers added to `docs/ARCHITECTURE.md`, `docs/STACK.md`, root `README.md` | +| `a3-schema` | `src/compass/schema/model-graph.v1.json` confirmed canonical and left in place; [`scripts/sync_schema.py`](../scripts/sync_schema.py) generates the two mirrors (`--check` for read-only drift); `tests/test_schema.py` gains a sha256 drift guard and a packaged-path guard | + +## Decisions taken + +**Canonical schema stays put.** Packaging evidence settled it: `src/compass/schema/model-graph.v1.json` +is the only copy inside the built sdist and wheel (via `[tool.setuptools.package-data]`), and +`loader.py` reads it at runtime. `MANIFEST.in` needed no change and nothing had to move. + +**Byte-level guard, not semantic.** The pre-existing test compared parsed JSON, which a reformat +passes while the digests diverge. Proven with a negative control: the reformat failed the new +checksum test while the old semantic test still passed. + +**Documentation drift resolved rather than annotated.** Two lines in `docs/CHARTER.md` (the Tier 2 +row and non-claim 4) described in-IDE advisory as Tier 2's surface, contradicting ADR 0005 where +they stood. The mechanical fact was kept; the product claim now matches the ADR. + +## Left undone, deliberately + +- Part B Wasmer work — concurrent owner. +- Cross-cutting proof report and the full e2e validation suite — later dedicated pass. +- `tests/test_paid_sync.py::test_manual_compass_bundle_api_stays_free` fails in the sibling + comPREssOR engine's `bundle.py:117`. Pre-existing (reproduced with this branch's edits stashed) + and outside this repo's edit surface per ADR 0002/0003. Graded, not papered over. +- Absolute plan-registry paths elsewhere in `PLANS.md` — pre-existing, out of scope. diff --git a/SUMMARY/2026-09-07-push-consolidation-wasmer-pr.md b/SUMMARY/2026-09-07-push-consolidation-wasmer-pr.md new file mode 100644 index 0000000..1adc3e5 --- /dev/null +++ b/SUMMARY/2026-09-07-push-consolidation-wasmer-pr.md @@ -0,0 +1,41 @@ +# Session: push remotes and stacked PR + +**Date:** 2026-09-07 PT (commits/registry UTC 2026-09-08) +**Repo:** `/Users/rosario/work/comPASS` +**Branch:** `feat/consolidation-and-wasmer` (stacked on `feat/docker-browser-challenge`) + +## Remote + +- `origin` fetch/push: `git@github.com:soltrinox/comPASS.git` +- GitHub: https://github.com/soltrinox/comPASS +- Verified existing repo `soltrinox/comPASS` (not invented; sibling compressor historically `soltrinox/comPREssOR`). + +## Push + +- `git push -u origin feat/consolidation-and-wasmer` — **success** (new upstream). +- HEAD: `08beb26` `test: log-backed proof for consolidation and Wasmer validation` +- No force-push. Hooks not skipped. `git config` not changed. + +## Pull requests + +| PR | Base | Head | State | URL | +|---|---|---|---|---| +| #2 | `main` | `feat/docker-browser-challenge` | OPEN | https://github.com/soltrinox/comPASS/pull/2 | +| #3 | `feat/docker-browser-challenge` | `feat/consolidation-and-wasmer` | OPEN | https://github.com/soltrinox/comPASS/pull/3 | + +PR #3 base is **PR #2’s branch** because #2 is still open/unmerged. Diff is Part A (FRAMEWORK, demote PROTOTYPE, schema checksum) + Part B (wasmer.toml, hosts). Registry publish and Android are **NOT_RUN**. + +Proof on branch: `test-results/PROOF-consolidation-wasmer-20260907.md` + +## Not pushed / not committed + +- `scripts/APPLY_HANDOFF.sh` +- `scripts/push_dockerignore.sh` +- `scripts/ship_challenge_pr.sh` +- `scripts/ship_handoff.sh` +- Dirty leftover: `test-results/h-session-polish/{README.md,evidence.json,session-harness.txt}` +- Untracked leftover: `test-results/s-desktop-mobile/mobile-ios-sim-pick.err.txt`, `test-results/t-validation/TS.txt` + +## Registry + +Updated `ENI6MA-REGISTRY/projects/infra/compass.md` changelog and `git_last_commit` to `08beb26`. Registry repo was **not** pushed (this session authorized comPASS only). diff --git a/SUMMARY/2026-09-07-validation-consolidation-wasmer.md b/SUMMARY/2026-09-07-validation-consolidation-wasmer.md new file mode 100644 index 0000000..8386715 --- /dev/null +++ b/SUMMARY/2026-09-07-validation-consolidation-wasmer.md @@ -0,0 +1,49 @@ +# Session summary — Validation (consolidation + Wasmer) + +**Date:** 2026-09-07 PT +**Branch:** `feat/consolidation-and-wasmer` +**Scope:** Plan todo `validation` only. Did not edit the plan file. Did not push or open PRs. Did not amend. + +**Proof:** [`test-results/PROOF-consolidation-wasmer-20260907.md`](../test-results/PROOF-consolidation-wasmer-20260907.md) +**Capture twins:** [`test-results/t-validation/`](../test-results/t-validation/README.md) + +## What was re-run + +| Guard | Result | +|---|---| +| `python -m pytest tests/test_schema.py` | 10 passed | +| `python scripts/sync_schema.py --check` | exit 0; digest `7fe7ea117c…` × 3 | +| `python scripts/wasmer_size_budget.py` | ok; cdylib **103980 ≤ 150000**; SHA256SUMS MATCH | +| `python scripts/wasmer_parity.py` | ok, including fail-open | +| `python -m pytest` (comPASS `.venv`) | **185 passed, 0 failed** | +| `./wasmer/desktop/run-decide.sh` | local `.webc` → `urn:mg:model:cheap` | +| Playwright / iOS Simulator | **not** re-run; cited prior artifacts | + +## paid_sync (honest) + +Default venv: 5 passed, because `chat_compressor` does not import (missing `safetensors`). That is a local fallback, not a compressor fix. + +Under `../comPREssOR/engine/.venv` the known failure still reproduces: + +`AttributeError: 'str' object has no attribute 'lineage'` at `engine/src/chat_compressor/bundle.py:117`. + +Graded **NOT_FIXED** / out of scope (ADR 0002/0003). `tests/test_paid_sync.py` was not changed. + +## Grades + +| Stage | Grade | +|---|---| +| A1–A3 | FULL | +| B1 publish | NOT_RUN | +| B2 browser | PARTIAL (prior SDK 0.11.0 local FULL; registry NOT_RUN) | +| B3 desktop | PARTIAL (local `.webc` FULL; registry-by-name NOT_RUN) | +| B4 mobile | PARTIAL (iOS Simulator prior; Android NOT_RUN) | +| Overall | PARTIAL program / **CONVERGED** offline-local | + +## Fixes + +None. Stale `wasmer/desktop/wasmer.toml` docs already point at repo-root `wasmer.toml`. Leftover untracked ship/handoff scripts left untracked. + +## Invariants still hold + +No `CURSOR_API_KEY` on the hook path; fail-open; digest-as-trust-root (module SHA256SUMS); size budget; no fake green. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 40257df..55666a6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -3,7 +3,8 @@ **Product:** comPASS (sister to comPREssOR) **Package:** `compass-router` **Runtime (Phase 3):** sovereign **browser-only** Wasmer agent, ENI6MA-gated -**Ground truth (historical product brief):** [`../PROTOTYPE.md`](../PROTOTYPE.md) §9–§13 +**Canonical framework:** [`FRAMEWORK.md`](FRAMEWORK.md) +**Origin brief (historical):** [`../PROTOTYPE.md`](../PROTOTYPE.md) §9–§13 **Charter:** [`CHARTER.md`](CHARTER.md) **Decision:** [`adr/0005-eni6ma-gated-browser-agent.md`](adr/0005-eni6ma-gated-browser-agent.md) (Accepted 2026-09-06) diff --git a/docs/CHARTER.md b/docs/CHARTER.md index 97b02ac..d62d4b7 100644 --- a/docs/CHARTER.md +++ b/docs/CHARTER.md @@ -1,7 +1,8 @@ # comPASS Charter **Product placeholder:** comPASS (sister to comPREssOR) -**Ground truth:** [`../PROTOTYPE.md`](../PROTOTYPE.md) +**Ground truth:** [`FRAMEWORK.md`](FRAMEWORK.md) — canonical framework document +**Origin brief (historical):** [`../PROTOTYPE.md`](../PROTOTYPE.md) **Architecture:** [`ARCHITECTURE.md`](ARCHITECTURE.md) **Canonical compressor:** `soltrinox/comPREssOR` @ engine **0.2.0** — never implement against `CHAT-COMPRESSOR` (0.1.3) @@ -46,7 +47,7 @@ Four capability tiers, each **independently shippable**, each strictly harder th | Tier | Name | What it does | |---|---|---| | **1** | **Observatory** | Live catalog: endpoints, price, latency p50/p95, context window, rate limits, availability, licence/data posture; canary drift on fixed ids. Useful with no routing. | -| **2** | **Advisor** | Task classification + surfaced (not enforced) recommendation with measured scores and cost. Only tier expressible inside Cursor Agent Chat (hooks have no model field). | +| **2** | **Advisor** | Task classification + surfaced (not enforced) recommendation with measured scores and cost. Surface is the in-tab agent; the IDE advisory path is **not** a product surface ([ADR 0005](adr/0005-eni6ma-gated-browser-agent.md)). | | **3** | **Router** | Real enforcement at owned call sites: SDK wrapper, OpenAI-compatible proxy, budget envelopes, policy constraints, escalation ladders. | | **4** | **Session orchestrator** | Per-turn routing inside one continuous session (hop + `hop_legal`, capability-aware payload shaping). Differentiated; requires compressor CC-1–CC-10. | @@ -93,7 +94,7 @@ Stated so marketing and docs cannot overstate: 1. **Not identical output across models.** Equivalence is an **outcome-equivalence band** on oracle-bearing task classes. Never identical or near-identical text. 2. **Not solved cross-hop credit assignment.** Persist `RouteDecision` + recipient lineage for later re-attribution; do not claim solved credit. 3. **Not a replacement for OpenRouter / LiteLLM.** Consume them as catalog/execution substrate and gateway plumbing. -4. **In-Cursor Agent Chat is advisory only.** Hook return shapes have no model field; Tier 2 advice only inside Agent Chat. +4. **No IDE enforcement, and no IDE product path.** Hook return shapes have no model field, so that surface could only ever advise; [ADR 0005](adr/0005-eni6ma-gated-browser-agent.md) removed it from the product entirely. Enforcement is the in-tab agent plus the generic adapter ([ADR 0006](adr/0006-generic-llm-adapter.md)). Additional posture: do not publish aggregate leaderboards from private probes; every capability figure carries `n` and `ci95`. @@ -131,8 +132,9 @@ Additional product metrics: ## References +- [`FRAMEWORK.md`](FRAMEWORK.md) — canonical framework; resolves conflicts between these documents - [`ARCHITECTURE.md`](ARCHITECTURE.md) — planes, tiers, identity normalization -- [`../PROTOTYPE.md`](../PROTOTYPE.md) — full product and integration specification +- [`../PROTOTYPE.md`](../PROTOTYPE.md) — historical origin brief (2026-09-03) - [`API.md`](API.md) — Route plane + advisory contracts - [`INTEGRATION.md`](INTEGRATION.md) — compressor CC-* touchpoints (Track B owns code) - [`RISKS.md`](RISKS.md) — risk register diff --git a/docs/FRAMEWORK.md b/docs/FRAMEWORK.md new file mode 100644 index 0000000..42b87c0 --- /dev/null +++ b/docs/FRAMEWORK.md @@ -0,0 +1,445 @@ +# comPASS Framework (canonical) + +**Status:** Canonical. This document is the single source of truth for what comPASS *is* and *how it runs today*. +**Date:** 2026-09-07 (PT) +**Product:** comPASS — sibling engine to comPREssOR +**Python package:** `compass-router` (`requires-python >= 3.11`) +**Runtime:** browser-only Wasmer appliance, ENI6MA-gated — one tab = one agent +**Voice:** exposition — mechanism, observable outcome, scope boundary. Unmeasured claims are marked. + +This document is assembled from [`CHARTER.md`](CHARTER.md), [`ARCHITECTURE.md`](ARCHITECTURE.md), [`STACK.md`](STACK.md), [`AUDIT-GOALS-VS-BROWSER-STACK.md`](AUDIT-GOALS-VS-BROWSER-STACK.md), and ADR 0001–0007. Where those documents disagree, the ADR decision wins and is stated here as **current**, not as a delta the reader has to apply. Every supersession is recorded in [Appendix B](#appendix-b--supersession-ledger). + +[`../PROTOTYPE.md`](../PROTOTYPE.md) is the **origin brief** (2026-09-03) and remains the provenance record. It is not the current architecture. + +--- + +## 0. Decisions in force (ADR 0001–0007) + +All seven ADRs are **Accepted**. Their decisions are the current state of the product, restated here in the present tense. + +| ADR | Decision in force today | +|---|---| +| [0001](adr/0001-product-name.md) | Product name is **comPASS**; Python package is **`compass-router`**; artifact prefix follows the package name. Public remote authorized (2026-09-05). | +| [0002](adr/0002-working-copy-disposition.md) | The **only** compressor implementation target is the canonical `comPREssOR` checkout (engine **0.2.0**, `main`). Agents **refuse** edits to the sibling `CHAT-COMPRESSOR` working copy. 0.1.3 personal identifiers and machine-specific absolute paths are **never** merged forward. | +| [0003](adr/0003-archive-disposition.md) | The archived `*.archived-0.1.3` compressor tree is **kept** with a loud README. Agents **refuse** all edits under it. | +| [0004](adr/0004-reward-attribution.md) | `RouteDecision` carries additive join fields (`trajectory_id`, `hop_index`, `episode_id`). Delayed rewards join post-hoc by bitemporal **supersede**, never by mutating `decide()`. Records always carry `credit_assignment_solved: false`. Bandit update from attribution is gated behind `COMPASS_ATTRIBUTION_BANDIT_UPDATE` (default off). | +| [0005](adr/0005-eni6ma-gated-browser-agent.md) | The product runtime is a **browser-only Wasmer agent** (zone A) with host JS as glue (zone B). Policies and world-changing acts pass an **ENI6MA ceremony**. Egress is a deny-by-default JS bridge. **There is no Cursor/IDE product path**, no native-sidecar default deploy, and no Wasmer Edge + managed Postgres control plane. | +| [0006](adr/0006-generic-llm-adapter.md) | One OpenAI-compatible ingress (`POST /v1/chat/completions`) with a `compass` extension object. Selection priority: **proxy override → catalog pin → decide**. `selection_mode` is persisted on `RouteDecision`. The adapter — not an IDE hook — is the primary enforcement target. | +| [0007](adr/0007-agy-behind-eni6ma-gate.md) | The local `agy-bridge` runs the ENI6MA circuit Gate **before** spawning `agy`: digest-pinned circuit load, HTTP 403 and no spawn on digest mismatch. | + +--- + +## 1. Problem and wedge + +Endpoint price spans roughly **two orders of magnitude** per token, and quality does not track price monotonically per task. A mid-tier model often matches a frontier model on constrained code edits, structured extraction, and short summarization, and fails badly at multi-step planning or long-context synthesis. The optimal choice is a function of **task type**, not a global ranking — yet almost every user makes one global choice and lives with it. + +The cost of mismatch is asymmetric. Overpaying on an easy task produces a slightly larger invoice. Under-provisioning a hard task wastes a turn, propagates a wrong assumption, and burns human recovery time. The second cost dominates and is never measured. + +Public leaderboards are weak inputs to a routing decision: their task mix is not the user's task mix, their contents leak into training corpora so scores drift upward independently of capability, and they report a scalar where the decision needs a **vector**. What a routing decision requires is a **posterior over the user's own task distribution**, produced by measuring endpoints against work drawn from the user's history. + +Endpoint behavior also changes underneath a stable identifier (quantization, serving stack, safety layer, silent version rolls). Any scoring system that treats a model id as a stable entity averages observations across a behavior break. Detecting the break and partitioning evidence is a hard requirement. + +**Wedge:** personal ground truth (probes from the user's own history) **plus** portable memory. The comPREssOR forward channel is unconditionally discrete text (`SampledPayload(kind="text", ...)`), so session state is a bounded, model-agnostic digest. Switching models mid-session costs the **forward budget** (default 1024 tokens via `CHAT_COMPRESSOR_FORWARD_BUDGET`), not transcript length. That is the structural reason comPASS is a sibling of comPREssOR rather than a standalone router. + +Flywheel: more sessions → more graph → better-calibrated routing → better outcomes per dollar → more reason to keep the compressor running. + +--- + +## 2. What ships — tiers 1–4 + +Four capability tiers, each independently shippable, each strictly harder than the last. + +| Tier | Name | What it does | Current surface (ADR 0005/0006) | +|---|---|---|---| +| **1** | **Observatory** | Live catalog: endpoints, price, latency p50/p95, context window, rate limits, availability, licence/data posture; canary drift on fixed ids. Useful with no routing. | In-tab agent; offline fixtures by default, live catalog only through Gate + JS bridge | +| **2** | **Advisor** | Task classification plus a surfaced (not enforced) recommendation with measured scores and cost. | In-tab agent UI. **Not** Cursor Agent Chat — see below | +| **3** | **Router** | Real enforcement at owned call sites: budget envelopes, policy constraints, escalation ladders. | The generic LLM adapter (ADR 0006) and the SDK wrapper / local proxy for library users | +| **4** | **Session orchestrator** | Per-turn routing inside one continuous session (hop + `hop_legal`, capability-aware payload shaping). Requires compressor CC-1–CC-10. | In-sandbox session/hop orchestrator | + +**Tier 2 correction (current).** The origin brief presented "advisory, inside Cursor" as Tier 2's primary enforcement target, on the mechanical grounds that Cursor hook return shapes carry no model field. That mechanical fact is still true, and it is still true that a hook surface can only advise. What changed is the product decision: ADR 0005 removes the IDE from the product path entirely, so Tier 2's surface is the in-tab agent. The CC-9 advisory file handoff remains a real, tested library capability and a compressor-side integration seam — it is not a shipping product surface. + +Scoring: `score(m, c) = E[quality(m, c)] − λ · E[cost(m, c)]`. Bandits (Thompson / UCB) over `(TaskClass, ModelVersion)` arms. + +--- + +## 3. Three planes + +The plane separation is a **latency, credential, and failure boundary**, and it is current. The Phase 1–2 *process layout* that once implemented it (Probe daemon sidecar + IDE hook + local proxy) is **historical** — see [Appendix A](#appendix-a--historical-phase-12-process-layout). + +| Plane | Role today | Credentials | Failure mode | +|---|---|---|---| +| **Route** | Classify → filter → score → budget → persist `RouteDecision`, inside the sandbox | **No** provider keys | **Fail-open** to configured default on any error | +| **Graph** | Bitemporal capability store + bandit posterior on guest SQLite / memory | **No** provider keys | Stale read is acceptable | +| **Probe** | Offline fixtures by default; live catalog and provider calls only via Gate + JS bridge | Short-lived tokens injected at ceremony — **never** ambient keys in the static bundle | Must not block the agent loop; fails soft to fixtures | + +**comPREssOR** runs as an in-process Python library in the same sandbox, not as a sidecar. The session / hop orchestrator lives in-sandbox. + +**Credential rule, stated twice.** (1) The static page and the guest image never ship long-lived provider secrets. (2) Live egress is Gate-checked and mediated by the host JS bridge, or optionally by WISP when the guest needs raw TCP. + +--- + +## 4. Runtime architecture — browser appliance + +One tab is one appliance. Product logic runs inside an in-page Wasmer (WASIX) sandbox. Full lifecycle and stack map: [`WASMER-DEPLOYMENT.md`](WASMER-DEPLOYMENT.md). + +```mermaid +flowchart TB + subgraph B[Zone B — Browser host JS] + UI[SPA / ceremony UI] + SDK["@wasmer/sdk/browser"] + SW[Service Worker / ports.expose] + Bridge[Egress JS bridge] + Trig[Triggers: manual cron event poll] + GateClient[ENI6MA Gate client] + end + subgraph A[Zone A — Wasmer sandbox WASIX] + Route[Route decide/advise] + Graph[Graph SQLite/memory] + Comp[comPREssOR in-process] + Loop[Session / hop / agent loop] + Extract[extractCode fence then JSON] + Runner[python main.py runner] + end + subgraph Nest[Nested sandbox] + Code[Untrusted model-suggested code] + end + subgraph C[Zone C — Optional egress] + WISP[WISP proxy] + APIs[LLM / HTTP endpoints] + end + UI --> SDK + SDK --> A + Trig --> Loop + GateClient --> Bridge + GateClient --> Runner + Loop --> Extract --> Runner + Runner --> Nest + Bridge --> APIs + A -.->|guest TCP only if needed| WISP + SW -->|expose guest HTTP| UI +``` + +| Zone | What | Owns product logic? | +|---|---|---| +| **A — Wasmer sandbox** | Pinned `python/python`, compass + comPREssOR, guest FS | **Yes** | +| **B — Browser host** | Shell, COOP/COEP, SDK, ceremony UX, triggers, egress bridge, `ports.expose` | Glue only | +| **C — Outside browser** | WISP and/or provider HTTP | Egress only — not our control plane | + +### 4.1 ENI6MA authority (hard rule) + +Policies and agent rules mutate **only** through the cryptographic interface. + +1. **Foundry** mints twin-circuit binaries (prover / verifier). +2. Resolve the circuit **local cache first**, else cloud/GitHub URL; recompute **SHA-256** plus byte length. Mismatch → **fail closed**. The digest is the trust root, not the CDN host. A client-supplied digest is never trusted without a pinned authority. +3. The user or agent requests a **challenge**, which returns a **proof** against that exact binary. +4. **Control** burns the nonce **before** validate, so a proof cannot be replayed. +5. **Gate** wraps every world-changing act: `policy.update`, `agent.schedule`, `run_python`, LLM call, tool or egress — bind → burn → validate. + +Start triggers may **run** an agent only under an already ceremony-bound policy. Start is not authorization to change a policy. + +### 4.2 Triggers + +| Trigger | Mechanism (zone B) | +|---|---| +| Manual | Run control in the page | +| Cron | In-tab scheduler / Service Worker alarm within policy windows | +| Event | Payload into exposed guest HTTP (`ports.expose`) | +| Poll | Status/payload watch via JS bridge when net is allowed | + +All four share one Gate-checked policy blob installed by ceremony. + +### 4.3 LLM → extract → execute loop + +1. The agent requests an LLM call → **Gate** → host **JS bridge** (deny-by-default). +2. On reply, extract Python: **first** markdown fences (`python` / `py` / bare fence when the body looks like Python), closing only on a matching fence and never executing an open fence; **fallback** when empty or unusable to `tool_calls` / `run_python({code})` / JSON `{ "code": "..." }`. +3. Gate `run_python` with the code hash in the envelope → write `main.py` on the guest FS → `python /workspace/main.py` → capture stdout/stderr → feed the observation back into the loop. +4. Prefer a file write over `python -c`. Use a nested sandbox when policy demands stronger isolation. +5. The host page must be cross-origin isolated (`COOP` / `COEP`, `window.crossOriginIsolated === true`). + +### 4.4 Persistence + +| Store | Verdict | +|---|---| +| Guest SQLite / files on WASIX FS | **Primary** Graph + bandit + sessions | +| In-memory Python | Demos only (lost on refresh) | +| IndexedDB / OPFS ↔ `sandbox.fs` | Optional durable bridge across reloads | +| Wasmer Edge managed Postgres | **Out of scope** for the product runtime until Postgres-in-browser exists | + +### 4.5 Egress + +| Path | How | +|---|---| +| UI ↔ agent API | Guest HTTP listener + `sandbox.ports.expose` | +| Agent ↔ providers / poll targets | **JS bridge** (preferred), Gate + policy allowlist | +| Guest raw TCP | Optional `network: { mode: 'wisp', url: 'wss://…' }` | +| No bridge, no WISP | Offline / mocked Probe only | + +### 4.6 Air-gap and page recall + +At recall (or first hydrate then cache) the page delivers: app shell with SRI, `@wasmer/sdk` plus workers, pinned `python/python@=…`, compass and comPREssOR guest packages, twin-circuit binaries, and an artifact **manifest** carrying `{ sha256, byteLength, source }` per file. After a first successful hydrate, the OPFS/IndexedDB cache supports offline operation. Sovereign mode is a local LLM plus a local Control ledger. + +--- + +## 5. Generic LLM adapter (ADR 0006) + +A single OpenAI-compatible ingress, `POST /v1/chat/completions`, carrying a `compass` extension object. Normative contract: [`API.md`](API.md) §6. + +| Mode | Trigger | Behavior | +|---|---|---| +| **proxy override** | Explicit host / IP / port / path in the request | Forward to that endpoint; deny-by-default allowlist applies in the browser | +| **catalog pin** | A linked catalog model is named | Route to that pinned `ModelVersion` | +| **decide** | Neither of the above | Weighted Graph selection | + +Priority is **proxy override → catalog pin → decide**. `selection_mode ∈ {decide, catalog, proxy_override}` is persisted on the `RouteDecision`. comPREssOR supplies hop-safe forward injection whenever the target model changes; shared KV across models is never assumed. The `compass` extension is stripped before forwarding upstream. + +--- + +## 6. Local `agy` bridge behind the Gate (ADR 0007) + +The loopback OpenAI-shaped `agy-bridge` spawns the Google Antigravity CLI with no provider keys in-process. Because any local process could otherwise drive it, the bridge runs the Gate first: + +1. Ingress reads `body.compass.circuit` (top-level `circuit` also accepted). +2. Circuits cache under `COMPASS_CIRCUIT_CACHE` (default `~/.compass/circuits/`), named by SHA-256 hex, with `url-index.json` mapping URL → digest. +3. On cache miss, fetch over HTTPS from `raw.githubusercontent.com` or `github.com` only; blob URLs normalize to raw; an optional `url + .sha256` sidecar is honored. +4. Recompute SHA-256. Sidecar or client pin mismatch → **HTTP 403, `agy` is never spawned**. +5. Validate by `WebAssembly.compile`/`instantiate` plus the `eni6maValidate` seam. A missing proof is allowed only in dev (`AGY_GATE_DEV=1` or `AGY_FAIL_OPEN`) as mode `digest_only`. +6. Responses report `compass.gate { cached, sha256, source, validated, mode }`. No circuit means pass-through as mode `no_circuit` unless `AGY_GATE_REQUIRED=1`. + +The real ENI6MA ABI replaces the `eni6maValidate` stub without changing this HTTP contract. + +--- + +## 7. Capability science and the Route hot path + +Unchanged product science: + +- Capability is a **vector**: language, code generation, planning, tool use, long context, structured output, multimodal, safety, latency p50/p95, and so on. +- Model cards are priors; probes are posteriors. **Every capability figure carries `n` and `ci95`.** +- `score(m, c) = E[quality] − λ · E[cost]`; Thompson / UCB over `(TaskClass, ModelVersion)`. +- Identity is `ModelVersion = (provider, served_id)`, with bitemporal **supersede** on a fingerprint break rather than a score overwrite. +- Route steps: classify → filter by hard constraints → score → check budget envelope → persist `RouteDecision` → **fail-open on any error**. +- Escalation ladders apply only to task classes that have a reliable failure oracle. Applying one to an unverifiable task ships bad output silently. +- Budget envelopes attach at session, project, and organization scope; the Route plane raises λ as consumption approaches the limit so degradation is gradual. + +Enforcement is the in-tab agent plus Gate, and the adapter for library callers. It is not an IDE hook. + +--- + +## 8. Schema surface — one canonical file + +The capability graph is `model-graph/v1`. It is a **sibling** of the compressor's `ctx-graph/v1`; widening the compressor enums is forbidden, because their node kinds and relations are fixed by both JSON Schema `enum` and a runtime `ValueError`. + +| Path | Role | +|---|---| +| [`../src/compass/schema/model-graph.v1.json`](../src/compass/schema/model-graph.v1.json) | **CANONICAL.** Read at runtime by `compass.schema.loader` and the only copy that ships in the sdist and wheel (`[tool.setuptools.package-data]`). | +| [`../schema/model-graph.v1.json`](../schema/model-graph.v1.json) | Generated mirror — machine-facing repo copy | +| [`schema/model-graph.v1.json`](schema/model-graph.v1.json) | Generated mirror — docs copy | + +Mirrors are regenerated, never hand-edited: + +```bash +python scripts/sync_schema.py # rewrite both mirrors from canonical +python scripts/sync_schema.py --check # non-zero exit on drift; no writes +``` + +`tests/test_schema.py` fails when the three SHA-256 digests diverge, so drift cannot reach `main` silently. + +Bitemporal contract: `valid_start`, `valid_end`, `status ∈ {active, superseded, deprecated}`. Node kinds: `Provider`, `Model`, `ModelVersion`, `TaskClass`, `CapabilityAxis`, `Probe`, `Observation`, `PriceQuote`, `Policy`, `RouteDecision`. Edge kinds: `serves`, `version_of`, `measures`, `observed_on`, `evidences`, `priced_by`, `supersedes`, `derived_from`, `constrains`, `selected`. + +--- + +## 9. Stack and artifacts + +| Layer | Choice | +|---|---| +| Language | **Python ≥ 3.11** | +| Metadata store | **SQLite** | +| Graph documents | **JSON** (`model-graph/v1`) | +| Tensor payloads | **safetensors** | +| Scoring math | **NumPy** | +| Route plane deps | No mandatory heavyweight dependency | + +Optional dependency groups mirror the compressor layout: `dev` (tests, lint, typecheck), `hf` (Hugging Face ingestion), `sdk` (client wrappers for owned call sites). The Probe plane may take additional dependencies because it is not latency-bound and is never on the prompt path. + +**Wasmer artifacts** (build-once, digest-pinned; details in [`WASMER.md`](WASMER.md)): + +| Artifact | Target | +|---|---| +| `wasmer/artifacts/compass_core_bg.wasm` | Browser sandbox cdylib — size budget **≤ 150000 bytes** | +| `wasmer/artifacts/compass-decide.wasm` | Desktop Wasmer CLI (WASI) | +| Same `compass_core_bg.wasm` bytes | Mobile hosts when one exists — currently **NOT_RUN** | + +Host ABI: `COMPASS_HOST_ABI = 1.0.0`; module `ABI_MIN=1.0.0` / `ABI_MAX=1.999.0`; contract in [`abi/host-abi.v1.md`](abi/host-abi.v1.md). Browser exports are `compass_alloc`, `compass_free`, `compass_decide_json`, `compass_last_len`, `memory` — and **no `fetch` import**. WASM artifacts are not a wheel extra; the published sdist and wheel are pure Python. + +Fail-open reason codes are identical on the native core and in WASM: `snapshot_missing`, `snapshot_corrupt`, `module_trap`, `no_candidates`, `abi_incompatible`. Proof: `python scripts/wasmer_parity.py`. + +--- + +## 10. Locked invariants + +- **Planes:** Probe (credentials, **never on the prompt path**) / Graph (bitemporal + bandit) / Route (hot path, **fail-open**). +- **Tiers:** 1 Observatory, 2 Advisor, 3 Router, 4 Session orchestrator. +- **Scoring:** `quality − λ·cost`; Thompson / UCB over `(TaskClass, ModelVersion)`. +- **Bitemporal:** `valid_start`, `valid_end`, `status ∈ {active, superseded, deprecated}`; behavior breaks supersede, they do not overwrite. +- **Equivalence:** **outcome-equivalence band**, never identical text. +- **Runtime:** browser-only Wasmer agent plus ENI6MA Gate ([ADR 0005](adr/0005-eni6ma-gated-browser-agent.md)); **no Cursor/IDE product path**. +- **Authority:** digest is the trust root; fail closed on mismatch; burn the nonce before validate. +- **Egress:** host JS bridge deny-by-default; optional WISP; **no ambient provider keys in the static page**. +- **Schema:** one canonical `model-graph.v1.json` under `src/compass/schema/`; mirrors generated; never widen `ctx-graph.v1`. +- **Compressor:** canonical `comPREssOR` engine **0.2.0** only; refuse edits to the `CHAT-COMPRESSOR` working copy or any `*.archived-0.1.3` tree. +- **Sanitization:** no machine-specific absolute paths — no hardcoded user home directories — in package source, docs content, module glue, or WASI preopens. Hosts supply workspace-relative roots. +- **Evidence:** honest `NOT_RUN` over fake green; every capability figure carries `n` and `ci95`. + +--- + +## 11. Free versus paid + +The free tier must be genuinely useful and must **never withhold correctness**. Accuracy is never paywalled. + +**Free (local engine, open source):** full Observatory for reachable endpoints; local task classification and local capability graph; advisory recommendations; local routing for owned call sites (SDK wrapper plus local proxy); local probes on the user's own keys and budget; the full portable-state-bundle **format** plus manual export/import; single machine, single user, local persistence. + +**Paid — five pillars:** + +1. **Cross-machine sync** of compressed session state (graph + quantized tensor index + lineage), encrypted end to end. +2. **Multi-model insertion** — carried context plus a bare prompt yields an **outcome-equivalence band** across substituted endpoints, never identical text. +3. **Managed capability graph** — aggregated, anonymized, opt-in fleet probe data. +4. **Enterprise governance** — enforced budget envelopes, policy routing, audit, residency, SSO/RBAC. +5. **Team shared memory** — project-scoped shared context graphs (depends on pillar 1). + +Individual pricing is positioned against **realized savings versus a single-model baseline**, which the Observatory computes. Enterprise is per-seat with governance and support. + +--- + +## 12. Non-claims and non-goals + +**Non-claims** (so docs and marketing cannot overstate): + +1. **Not identical output across models.** Equivalence is an outcome-equivalence band on oracle-bearing task classes. +2. **Not solved cross-hop credit assignment.** Lineage is persisted for later re-attribution; `credit_assignment_solved` is always recorded `false`. +3. **Not a replacement for OpenRouter / LiteLLM.** Those are consumed as catalog and execution substrate. +4. **No IDE enforcement, and no IDE product path.** Hook return shapes have no model field, and ADR 0005 removed the IDE surface from the product. +5. Aggregate leaderboards are never published from private probes. + +**Non-goals:** + +- Cursor plugin / IDE advisory as a product path. +- Native Probe / proxy / comPREssOR **sidecars** as the default deploy. +- Wasmer Edge app plus managed Postgres as the agent control plane. +- Long-lived multi-tenant server — each tab is one instance. +- Trusting a client-supplied digest without a pin authority. +- Exhaustive probing of every endpoint (bandit pruning is mandatory). +- Automatic tensor-branch merge on sync conflict — the first release presents divergence as a user choice. +- Implementing against the archived compressor 0.1.3 line. +- Widening `ctx-graph.v1`. + +--- + +## 13. Success metrics (falsifiable) + +| Milestone | Exit criterion | +|---|---| +| **M0** | Recipient identity round-trips through `StateNode.meta` and survives lineage reload; no machine-specific absolute paths introduced in compressor code. | +| **M1** | A scripted hop at turn 20 delivers a full, unsuppressed, full-budget payload; a no-hop session matches 0.2.0 token accounting. | +| **M2** | Catalog populated with priced, versioned entries; an induced fingerprint change triggers supersession, not a score overwrite. | +| **M3** | Recommendations appear in session context; a corrupt, stale, or missing advisory **provably does not block** the consuming session (fail-open). | +| **M4** | Enforced routing with a persisted `RouteDecision`; a bundle round-trips across two machines with unchanged `hot_set` / `typed_projection` on fixtures. | + +Additional product metrics: realized savings versus a single-model baseline, hop-turn payload correctness, and an advisory fail-open proof. + +--- + +## 14. Current status + +Graded from [`AUDIT-GOALS-VS-BROWSER-STACK.md`](AUDIT-GOALS-VS-BROWSER-STACK.md) §B (2026-09-07). **Done** = shipped and testable · **Partial** = library or lab only · **Not done** = designed or blocked. + +| Goal | Status | Notes | +|---|---|---| +| Task-type routing | **Done (lib)** | Route decide + tests | +| Personal posterior / probes | **Partial** | Offline fixtures; live Probe **NOT_RUN** (no keys) | +| Bitemporal supersede | **Done** | Graph store + offline Observatory | +| Portable hop memory (comPREssOR) | **Done (sibling)** | CC-1–CC-10 on comPREssOR; browser always-on inject partial | +| Free-tier correctness | **Done (policy)** | Free/paid docs + spikes | +| T1 Observatory | **Partial** | Offline catalog yes; live smoke no | +| T2 Advisor | **Done → surface changed** | CC-9 exists; IDE path dropped per ADR 0005 | +| T3 Router / adapter | **Done (engine)** | Track O adapter + proxy; browser UX thin | +| T4 Session hops | **Partial** | Orchestrator + hop legality in the library; full in-tab loop incomplete | +| ENI6MA ceremony UX (six-color) | **Not done** | No interactive UI | +| Digest-pinned circuit load | **Done** | `circuitLoader` + bridge Gate | +| Cryptographic verify / burn ledger | **Not done** | Prove-only; Gate is digest + ABI probe | +| Fence → Wasmer exec product loop | **Not done** | Designed, not Gate-wrapped as product | +| `agy-bridge` + Docker | **Done (lab)** | `:8791` healthy; default `fake-agy` | +| Live Gemini via `agy` | **Partial** | Needs live-agy profile + host auth | +| Air-gap deny egress | **Done (testable)** | Empty allowlist / dry-run | +| Paid pillars | **Spike / test-ready** | Not production SaaS | +| Mobile Wasmer host | **NOT_RUN** | Documentation only | + +**Bottom line.** Against the original charter (tiers 1–4 offline) the libraries and tests are mostly there; live Probe, production publish, and mobile remain open. Against the Phase 3 browser sovereign agent, the scaffold and the Gate digest path are roughly half built; the interactive ceremony, real verify/burn, and full agent boot are **not ready to ship**. What is testable today is the Docker bridge, the digest/proof smoke, and the adapter/proxy dry-run — a lab, not a production agent. + +--- + +## 15. Document map + +| Doc | Role | Normative for | +|---|---|---| +| **`FRAMEWORK.md`** (this file) | **Canonical** — what comPASS is and how it runs | Ground truth; conflict resolution | +| [`adr/`](adr/) 0001–0007 | Accepted decisions | Anything this document summarizes; the ADR text wins on detail | +| [`API.md`](API.md) | Route API, advisory contract, adapter §6 | Client wire contracts | +| [`ARCHITECTURE.md`](ARCHITECTURE.md) | Zone-level runtime detail | Zone diagrams, extract→exec loop | +| [`WASMER-DEPLOYMENT.md`](WASMER-DEPLOYMENT.md) | Deploy lifecycle, zones A–D, ports, operator cheat sheet | Operations | +| [`WASMER.md`](WASMER.md) | Artifacts, ABI, size budget, parity codes | Artifact facts | +| [`CHARTER.md`](CHARTER.md) | Problem, wedge, tiers, free vs paid, non-claims | Product framing | +| [`STACK.md`](STACK.md) | Language and dependency contract | Library stack; **its §2 process layout is historical** | +| [`AUDIT-GOALS-VS-BROWSER-STACK.md`](AUDIT-GOALS-VS-BROWSER-STACK.md) | Goal → status map | Status claims | +| [`INTEGRATION.md`](INTEGRATION.md) | Compressor CC-1–CC-10 touchpoints | Compressor seams | +| [`RISKS.md`](RISKS.md) | Risk register R1–R12 | Risk language | +| [`RELEASE.md`](RELEASE.md) | Version scheme, tags, publish | Release process | +| [`../PROTOTYPE.md`](../PROTOTYPE.md) | **Historical** origin brief (2026-09-03) | Provenance only | + +--- + +## Appendix A — Historical Phase 1–2 process layout + +Retained because Phase 1–2 CI, the offline `compass-router` library, and the existing test suite were built against it. **It is not the shipping appliance topology** (ADR 0005). + +``` +[ IDE hook / Agent Chat ] --fail-open advisory file--> (no keys) +[ SDK wrapper / local proxy ] --decide--> Route --read--> Graph +[ Probe daemon ] --write observations--> Graph + ^ holds provider credentials (native only) +``` + +- **Route** — hot path; fail-open to configured default; target p95 < 50 ms. +- **Graph** — bitemporal store + bandit posterior; read path shared with Route. +- **Probe** — native sidecar / daemon; holds credentials; outbound HTTP; catalog fetch; canary execution. + +The Track D WASM cut that accompanied it (Route + Graph **read** in WASM, Probe native-only) is likewise historical as a *deploy story*. Its **security requirements survive unchanged and are still enforced**: no provider keys in the WASM module; host ABI imports limited to storage read, clock, config, and log; `keys.*` and unrestricted outbound HTTP forbidden; browser builds omit `fetch`; module ABI semver paired with `model-graph/v1`. + +Logical persistence layout for library users (paths operator-configurable): + +``` +compass-data/ + graph/ + model-graph.json # model-graph/v1 document(s) + meta.sqlite # indexes, envelopes, bandit state pointers + tensors/ # optional safetensors + advisory/ # CC-9 handoff (service side) + latest.json +``` + +--- + +## Appendix B — Supersession ledger + +Every conflict this document resolves, and the authority that resolves it. + +| # | Prior statement | Where it appears | Current state | Authority | +|---|---|---|---|---| +| 1 | `PROTOTYPE.md` is ground truth | `PLANS.md`, `docs/README.md`, `CHARTER.md`, root `README.md` | **This document** is ground truth; the origin brief is provenance | This document; ADR 0005 | +| 2 | Advisory inside an IDE is Tier 2's primary enforcement target | Origin brief §13.1 | Tier 2's surface is the in-tab agent; the adapter is the enforcement target. CC-9 remains a library seam | ADR 0005, ADR 0006 | +| 3 | Three planes imply a Probe daemon + IDE hook + local proxy process layout | Origin brief §9, `STACK.md` §2 | Plane *boundaries* current; *process layout* historical (Appendix A). All three planes run in the sandbox; Probe egress is Gate-mediated | ADR 0005 | +| 4 | Route + Graph read in WASM with a native Probe sidecar is the deploy target | `STACK.md` §3, `WASMER.md` Track D table | Historical deploy story; its security requirements still hold | ADR 0005 | +| 5 | Wasmer Edge FastAPI + managed Postgres as the appliance | ADR 0005 option A | Out of scope for the product runtime; guest SQLite is primary | ADR 0005 §6 | +| 6 | Three byte-identical schema copies, no enforcement | `schema/`, `docs/schema/`, `src/compass/schema/` | One canonical file under `src/compass/schema/`; two generated mirrors; checksum guard in `tests/test_schema.py` | This document §8 | +| 7 | Product name and package still open for a rename | ADR 0001 body reads "Decision (proposed)" | Settled: **comPASS** / **`compass-router`**, accepted 2026-09-05 in that ADR's Acceptance section | ADR 0001 Acceptance | +| 8 | Cross-hop credit assignment may be described as solved | risk R3 | Never claimed; records carry `credit_assignment_solved: false` | ADR 0004 | +| 9 | The `CHAT-COMPRESSOR` tree is an implementation target | historical Phase 1 notes | Refused. Canonical `comPREssOR` engine 0.2.0 only | ADR 0002, ADR 0003 | diff --git a/docs/README.md b/docs/README.md index 45faeb8..151197d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,13 +3,15 @@ Track A deliverables. Implementation is Tracks B–D. Product/GTM is Track E. **Do not edit compressor source from this track.** **Product placeholder:** comPASS (sister to comPREssOR) -**Ground truth:** [`../PROTOTYPE.md`](../PROTOTYPE.md) +**Ground truth:** [`FRAMEWORK.md`](FRAMEWORK.md) — canonical framework document +**Origin brief (historical):** [`../PROTOTYPE.md`](../PROTOTYPE.md) — superseded in part by ADR 0005/0006/0007 **Canonical compressor:** `soltrinox/comPREssOR` @ **0.2.0** — never `CHAT-COMPRESSOR` ## Index | Doc | Purpose | |---|---| +| [`FRAMEWORK.md`](FRAMEWORK.md) | **Canonical:** single framework document — ADR 0001–0007 as current, supersession ledger, status | | [`CHARTER.md`](CHARTER.md) | Problem, wedge, tiers 1–4, free vs paid, non-claims, success metrics | | [`ARCHITECTURE.md`](ARCHITECTURE.md) | **Phase 3:** browser Wasmer agent zones; ENI6MA Gate; planes remapped; extract→exec loop | | [`adr/0005-eni6ma-gated-browser-agent.md`](adr/0005-eni6ma-gated-browser-agent.md) | **Accepted:** browser-only + ENI6MA ceremony; supersedes sidecar/Cursor runtime | @@ -28,10 +30,12 @@ Track A deliverables. Implementation is Tracks B–D. Product/GTM is Track E. ** | [`schema/model-graph.v1.json`](schema/model-graph.v1.json) | Sibling capability-graph JSON Schema (`model-graph/v1`) — do not widen `ctx-graph.v1` | | [`schema/statenode-meta.v1.md`](schema/statenode-meta.v1.md) | CC-1 recipient fields on `StateNode.meta` | -Machine-facing mirrors (byte-identical schema + bundle stub): +Schema copies — canonical plus generated mirrors (see [`FRAMEWORK.md`](FRAMEWORK.md) §8): -- `/Users/rosario/work/comPASS/schema/model-graph.v1.json` -- `/Users/rosario/work/comPASS/schema/bundle.v1.json` +- [`../src/compass/schema/model-graph.v1.json`](../src/compass/schema/model-graph.v1.json) — **canonical**, loaded at runtime, ships in the wheel +- [`../schema/model-graph.v1.json`](../schema/model-graph.v1.json) — generated mirror (`python scripts/sync_schema.py`) +- [`schema/model-graph.v1.json`](schema/model-graph.v1.json) — generated mirror +- [`../schema/bundle.v1.json`](../schema/bundle.v1.json) — portable-bundle stub (single copy) ## Locked invariants (quick) @@ -42,4 +46,7 @@ Machine-facing mirrors (byte-identical schema + bundle stub): - Equivalence: **outcome-equivalence band**, never identical text - **Phase 3 runtime:** browser-only Wasmer agent + ENI6MA Gate ([ADR 0005](adr/0005-eni6ma-gated-browser-agent.md)); no Cursor/IDE product path - Egress: host JS bridge deny-by-default; optional WISP; no ambient provider keys in static page +- Schema: one canonical `model-graph.v1.json` under `src/compass/schema/`; mirrors generated, drift-guarded in `tests/test_schema.py` - Historical Track D: Route+Graph WASM read + Probe sidecar — superseded for product deploy + +Full invariant list and the supersession ledger: [`FRAMEWORK.md`](FRAMEWORK.md) §10 and Appendix B. diff --git a/docs/STACK.md b/docs/STACK.md index 7f10ebd..7acb36d 100644 --- a/docs/STACK.md +++ b/docs/STACK.md @@ -1,6 +1,6 @@ # comPASS Stack & Wasmer runtime -> **Superseded for product runtime (ADR 0005, 2026-09-06):** Phase 3 is the **browser-only ENI6MA-gated Wasmer agent** — see [`ARCHITECTURE.md`](ARCHITECTURE.md). The Cursor-hook / Probe-sidecar process layout below remains the Phase 1–2 engineering contract for offline `compass-router` libraries and CI; it is **not** the shipping appliance topology. +> **Superseded for product runtime (ADR 0005, 2026-09-06):** Phase 3 is the **browser-only ENI6MA-gated Wasmer agent** — see [`FRAMEWORK.md`](FRAMEWORK.md) (canonical) and [`ARCHITECTURE.md`](ARCHITECTURE.md). The IDE-hook / Probe-sidecar process layout in §2–§3 below remains the Phase 1–2 engineering contract for offline `compass-router` libraries and CI; it is **not** the shipping appliance topology. Its §3 security requirements are still enforced. **Product:** comPASS (sister to comPREssOR) diff --git a/docs/WASMER-DEPLOYMENT.md b/docs/WASMER-DEPLOYMENT.md index c0296bb..ab0b283 100644 --- a/docs/WASMER-DEPLOYMENT.md +++ b/docs/WASMER-DEPLOYMENT.md @@ -169,9 +169,9 @@ Schema: `model-graph/v1` (sibling to compressor `ctx-graph`; do not widen compre | `artifacts/eni6ma/demo-wasm/v1/` | Path-B DEMO-MINT circuit + `pkg/` glue | | `artifacts/pins.json` | Authority digests | | `browser/` | `index.html`, `circuitLoader.js`, `wasmerRunner.js`, `bridge.js`, `agent.*`, `sandbox.js` | -| `desktop/` | `run-decide.sh`, `wasmer.toml` | +| `desktop/` | `run-decide.sh` (root [`wasmer.toml`](../wasmer.toml) is the package manifest) | | `crate/` | Rust → wasm build | -| `mobile/` | **NOT_RUN** device farm | +| `mobile/` | **PARTIAL** iOS Simulator; Android **NOT_RUN** | ### 3.3 `services/agy-bridge` diff --git a/docs/WASMER.md b/docs/WASMER.md index c05c3da..f34fe84 100644 --- a/docs/WASMER.md +++ b/docs/WASMER.md @@ -17,7 +17,7 @@ |---|---|---| | `wasmer/artifacts/compass_core_bg.wasm` | browser sandbox | **BUILT** — 103980 bytes; SHA-256 `9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db` | | `wasmer/artifacts/compass-decide.wasm` | desktop Wasmer CLI | **BUILT** — 135419 bytes; SHA-256 `e77301bed6f3bcdf8541ba7256cb6a4e58e1da62d7a98edb52fa27bdc1fee553` | -| same `compass_core_bg.wasm` bytes | mobile (when host exists) | **NOT_RUN** on device; module is build-once | +| same `compass_core_bg.wasm` bytes | mobile (iOS Simulator host) | **PARTIAL** on iOS Simulator; Android host tree **NOT_RUN** (no SDK); same digest as browser cdylib | | Python `compass.core` | native CI / fail-open parity | available | Module size budget (browser cdylib): **≤ 150000 bytes** (~146 KiB). Guard: `python scripts/wasmer_size_budget.py` (also CI). Track regressions in `SHA256SUMS`. @@ -41,6 +41,7 @@ Module size budget (browser cdylib): **≤ 150000 bytes** (~146 KiB). Guard: `py - No `eval` of host secrets into the module. - Do not expose `fetch` import on the browser build (verified: empty import table). - Chrome requires `script-src 'self' 'wasm-unsafe-eval'` for `WebAssembly.instantiate` (set in `wasmer/browser/index.html`). +- `@wasmer/sdk/browser` additionally needs `worker-src 'self' blob: 'wasm-unsafe-eval' 'unsafe-eval'`, `blob:` on `script-src`, `'unsafe-eval'` (SDK wasm-bindgen `new Function` in WASIX workers; `'wasm-unsafe-eval'` is not enough), and `connect-src` to `https://registry.wasmer.io` (GraphQL) plus `https://cdn.wasmer.io` (package bytes; verified from `python/python@=3.13.18` `distribution.downloadUrl`). COOP `same-origin` / COEP `require-corp` stay as-is. - Fail-open if instantiate/decide throws → configured default + `module_trap`. - Sandbox page: `wasmer/browser/` (serve `wasmer/` over HTTP). - Headless hooks: `window.__COMPASS_SMOKE__`, `?smoke=1`, `data-smoke-ready`. @@ -60,8 +61,8 @@ Workflow: `.github/workflows/wasmer-browser.yml` (no provider keys; uploads `tes ## Desktop / mobile packaging -- **Desktop (FULL packaging path):** `./wasmer/desktop/run-decide.sh` — volume map, defaults, fail-open demos; see `wasmer/desktop/README.md` + `wasmer.toml`. -- **Mobile:** **NOT_RUN** — no CI device farm. Exact next steps: [`wasmer/mobile/NOT_RUN.md`](../wasmer/mobile/NOT_RUN.md). Reuse `compass_core_bg.wasm` when a Wasmer-capable mobile host exists. +- **Desktop (FULL packaging path):** `./wasmer/desktop/run-decide.sh` — volume map, defaults, fail-open demos; see `wasmer/desktop/README.md`. Package manifest: repo-root [`wasmer.toml`](../wasmer.toml) (not `wasmer/desktop/wasmer.toml`). +- **Mobile:** **PARTIAL** (iOS Simulator) — [`wasmer/mobile/NOT_RUN.md`](../wasmer/mobile/NOT_RUN.md) (status field; filename retained). Android SDK/emulator **NOT_RUN**. Same `compass_core_bg.wasm` digest as `SHA256SUMS`. ## Fail-open parity @@ -86,7 +87,7 @@ Browser path must match the same defaults on corrupt/missing snapshot (smoke ass | Browser headless smoke | FULL when smoke green | `test-results/j-wasmer-packaging/browser-smoke.json` | | Desktop Wasmer shell | FULL when script + parity green | `wasmer/desktop/run-decide.sh` | | Artifact size / SHA256 | FULL when budget script green | `test-results/j-wasmer-packaging/size-budget.json` | -| Mobile device farm | NOT_RUN | `wasmer/mobile/NOT_RUN.md` | +| Mobile device farm | PARTIAL (iOS Simulator); Android NOT_RUN | `wasmer/mobile/NOT_RUN.md` + `test-results/s-desktop-mobile/mobile-ios-simulator.json` | ## CI matrix @@ -97,7 +98,7 @@ Browser path must match the same defaults on corrupt/missing snapshot (smoke ass | `fail-open-parity` | native vs wasm | identical defaulting | | `browser-smoke` | Playwright chromium | headless sandbox decide + fail-open (`wasmer-browser.yml`) | | `size-budget` | committed artifacts | SHA256SUMS + ≤150000 browser bytes | -| `mobile` | placeholder | explicit NOT_RUN docs present | +| `mobile` | iOS Simulator / Android emulator | iOS PARTIAL when simulator log green; Android honest NOT_RUN without SDK | No live provider keys in CI. diff --git a/scripts/APPLY_HANDOFF.sh b/scripts/APPLY_HANDOFF.sh new file mode 100644 index 0000000..f19af71 --- /dev/null +++ b/scripts/APPLY_HANDOFF.sh @@ -0,0 +1,119 @@ +#!/bin/bash +set -euo pipefail +ROOT="${1:-/Users/rosario/work/comPASS}" +PACK="$(cd "$(dirname "$0")" && pwd)" + +mkdir -p "$ROOT/.cursor/plans" "$ROOT/docs" +cp -f "$PACK/.cursor/plans/compass_phase3_status_handoff_20260907.plan.md" "$ROOT/.cursor/plans/" +cp -f "$PACK/docs/CURSOR-HANDOFF.md" "$ROOT/docs/" + +# Sync to Cursor plan mirrors +for D in /Users/rosario/work/.cursor/plans /Users/rosario/.cursor/plans; do + mkdir -p "$D" + cp -f "$PACK/.cursor/plans/compass_phase3_status_handoff_20260907.plan.md" "$D/" +done + +# Patch PLANS.md Phase 3 table if status row missing +python3 - <<'PY' +from pathlib import Path +root = Path("/Users/rosario/work/comPASS") +plans = root / "PLANS.md" +text = plans.read_text() +# bump date header +text2 = text.replace("**Date:** 2026-09-05", "**Date:** 2026-09-07", 1) +if "compass_phase3_status_handoff_20260907" not in text2: + needle = "| O | Generic LLM adapter |" + idx = text2.find(needle) + if idx < 0: + raise SystemExit("O row missing") + # find end of O table row + line_end = text2.find("\n", idx) + row = text2[idx:line_end] + status_row = "| P3 Status | Phase 3 status handoff (Grok→Cursor) | [`/Users/rosario/work/.cursor/plans/compass_phase3_status_handoff_20260907.plan.md`](/Users/rosario/work/.cursor/plans/compass_phase3_status_handoff_20260907.plan.md) | [`/Users/rosario/.cursor/plans/compass_phase3_status_handoff_20260907.plan.md`](/Users/rosario/.cursor/plans/compass_phase3_status_handoff_20260907.plan.md) | [`/Users/rosario/work/comPASS/.cursor/plans/compass_phase3_status_handoff_20260907.plan.md`](/Users/rosario/work/comPASS/.cursor/plans/compass_phase3_status_handoff_20260907.plan.md) |" + text2 = text2[:line_end+1] + status_row + "\n" + text2[line_end+1:] + notes = ''' +### Phase 3 progress notes (2026-09-07 PT) + +- **Handoff:** [`docs/CURSOR-HANDOFF.md`](docs/CURSOR-HANDOFF.md) — open first in Cursor after leaving Grok Bot. +- **Audit:** [`docs/AUDIT-GOALS-VS-BROWSER-STACK.md`](docs/AUDIT-GOALS-VS-BROWSER-STACK.md); stack map [`docs/WASMER-DEPLOYMENT.md`](docs/WASMER-DEPLOYMENT.md). +- Track O generic adapter **completed** (ADR 0006 / `src/compass/serve/adapter.py` + tests). +- ADRs **0005** (browser ENI6MA agent), **0007** (agy behind Gate) Accepted; agy-bridge + Compose on `:8791`. +- **PR #2** Docker `browser-client` challenge UI on `:8088` (handle / binary_url → digest-pin → minimal proof → Ask Gate) — merge + smoke still open. +- **Outstanding:** six-color ceremony UX, verify+burn ledger, full agent boot page, real comPREssOR hop inject, live-agy optional. +''' + if "Phase 3 progress notes" not in text2: + text2 = text2.rstrip() + "\n" + notes + "\n" +plans.write_text(text2) +print("PLANS.md updated") + +readme = root / ".cursor/plans/README.md" +r = readme.read_text() +if "compass_phase3_status_handoff_20260907" not in r: + if "## Phase 3" not in r: + r = r.rstrip() + ''' + +## Phase 3 — Browser agent + status handoff + +| Track | Plan | +| --- | --- | +| O — Generic LLM adapter | [compass_phase3_track_o_generic_adapter_a7c3e91f.plan.md](compass_phase3_track_o_generic_adapter_a7c3e91f.plan.md) | +| P3 Status — Grok→Cursor handoff | [compass_phase3_status_handoff_20260907.plan.md](compass_phase3_status_handoff_20260907.plan.md) | + +Canonical narrative status: [`../../docs/CURSOR-HANDOFF.md`](../../docs/CURSOR-HANDOFF.md) +''' + else: + r = r.rstrip() + "\n| P3 Status — Grok→Cursor handoff | [compass_phase3_status_handoff_20260907.plan.md](compass_phase3_status_handoff_20260907.plan.md) |\n" + readme.write_text(r + "\n") + print("plans README updated") +else: + print("plans README already has handoff") + +# Link from docs/README.md +dread = root / "docs/README.md" +dt = dread.read_text() +if "CURSOR-HANDOFF.md" not in dt: + needle = "| [`AUDIT-GOALS-VS-BROWSER-STACK.md`](AUDIT-GOALS-VS-BROWSER-STACK.md)" + if needle in dt: + row = "| [`CURSOR-HANDOFF.md`](CURSOR-HANDOFF.md) | **Cursor handoff (2026-09-07):** Phase 3 status, compose ports, PR #2, next todos |" + # insert after AUDIT row - find full line + import re + m = re.search(r"\| \[`AUDIT-GOALS-VS-BROWSER-STACK\.md`\].*\n", dt) + if m: + dt = dt[:m.end()] + row + "\n" + dt[m.end():] + dread.write_text(dt) + print("docs README linked") + else: + print("AUDIT row regex miss") + else: + # insert after WASMER-DEPLOYMENT + needle2 = "| [`WASMER-DEPLOYMENT.md`](WASMER-DEPLOYMENT.md) | **Phase 3 lifecycle:** zones A–D, Gate auth, adapter→bridge→agy, module maps, ports, operator cheat sheet |" + if needle2 in dt: + dt = dt.replace(needle2, needle2 + "\n| [`CURSOR-HANDOFF.md`](CURSOR-HANDOFF.md) | **Cursor handoff (2026-09-07):** Phase 3 status, compose ports, PR #2, next todos |") + dread.write_text(dt) + print("docs README linked via WASMER-DEPLOYMENT") + else: + print("docs README needle miss") +else: + print("docs README already linked") +PY + +# Ensure .dockerignore exists on branch +if [ ! -f "$ROOT/.dockerignore" ]; then + cat > "$ROOT/.dockerignore" <<'DI' +.git +**/node_modules +**/.venv +**/__pycache__ +**/*.pyc +test-results +**/target +**/.pytest_cache +**/dist +**/build +*.tgz +.env +.env.* +DI +fi + +echo "Applied handoff into $ROOT" diff --git a/scripts/push_dockerignore.sh b/scripts/push_dockerignore.sh new file mode 100644 index 0000000..05c3931 --- /dev/null +++ b/scripts/push_dockerignore.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -euo pipefail +cd /Users/rosario/work/comPASS +/usr/bin/git add .dockerignore +/usr/bin/git commit -m "chore: dockerignore for faster browser-client builds" || true +/usr/bin/git push +gh pr view 2 --json url,state diff --git a/scripts/ship_challenge_pr.sh b/scripts/ship_challenge_pr.sh new file mode 100644 index 0000000..cf31745 --- /dev/null +++ b/scripts/ship_challenge_pr.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -euo pipefail +cd /Users/rosario/work/comPASS +/usr/bin/git fetch origin main +/usr/bin/git stash push -u -m "wip-unrelated" -- test-results/j-wasmer-packaging/size-budget.json scripts/airgap_adapter_sim.py scripts/apply-browser-challenge.sh scripts/git_status_only.sh services/agy-bridge-gate-harden.tgz || true +/usr/bin/git checkout -B feat/docker-browser-challenge origin/main +/usr/bin/git add wasmer/browser/challenge.html wasmer/browser/challenge.js wasmer/browser/circuitLoader.js wasmer/browser/README.challenge.md services/browser-client/Dockerfile services/browser-client/nginx.conf docker-compose.yml docs/DOCKER.md +/usr/bin/git status -sb +/usr/bin/git commit -m "feat: Docker browser challenge client (Pass+-style)" +/usr/bin/git push -u origin HEAD +gh pr create --fill --title "feat: Docker browser challenge client" --body "Challenge-first UI on :8088 (handle/binary_url), digest-pin GitHub wasm, prove in-browser, ask agy-bridge :8791. docker compose up --build -d then open http://127.0.0.1:8088/" +gh pr view --json url,number -q .url diff --git a/scripts/ship_handoff.sh b/scripts/ship_handoff.sh new file mode 100644 index 0000000..ab44007 --- /dev/null +++ b/scripts/ship_handoff.sh @@ -0,0 +1,92 @@ +#!/bin/bash +set -euo pipefail +cd /Users/rosario/work/comPASS + +# Sync plan mirrors +mkdir -p /Users/rosario/work/.cursor/plans /Users/rosario/.cursor/plans +cp -f .cursor/plans/compass_phase3_status_handoff_20260907.plan.md /Users/rosario/work/.cursor/plans/ +cp -f .cursor/plans/compass_phase3_status_handoff_20260907.plan.md /Users/rosario/.cursor/plans/ + +python3 <<'PY' +from pathlib import Path +import re +root = Path("/Users/rosario/work/comPASS") +plans = root / "PLANS.md" +text = plans.read_text() +text2 = text.replace("**Date:** 2026-09-05", "**Date:** 2026-09-07", 1) +if "compass_phase3_status_handoff_20260907" not in text2: + needle = "| O | Generic LLM adapter |" + idx = text2.find(needle) + if idx < 0: + raise SystemExit("O row missing") + line_end = text2.find("\n", idx) + status_row = "| P3 Status | Phase 3 status handoff (Grok→Cursor) | [`/Users/rosario/work/.cursor/plans/compass_phase3_status_handoff_20260907.plan.md`](/Users/rosario/work/.cursor/plans/compass_phase3_status_handoff_20260907.plan.md) | [`/Users/rosario/.cursor/plans/compass_phase3_status_handoff_20260907.plan.md`](/Users/rosario/.cursor/plans/compass_phase3_status_handoff_20260907.plan.md) | [`/Users/rosario/work/comPASS/.cursor/plans/compass_phase3_status_handoff_20260907.plan.md`](/Users/rosario/work/comPASS/.cursor/plans/compass_phase3_status_handoff_20260907.plan.md) |" + text2 = text2[:line_end+1] + status_row + "\n" + text2[line_end+1:] +if "Phase 3 progress notes" not in text2: + notes = ''' +### Phase 3 progress notes (2026-09-07 PT) + +- **Handoff:** [`docs/CURSOR-HANDOFF.md`](docs/CURSOR-HANDOFF.md) — open first in Cursor after leaving Grok Bot. +- **Audit:** [`docs/AUDIT-GOALS-VS-BROWSER-STACK.md`](docs/AUDIT-GOALS-VS-BROWSER-STACK.md); stack map [`docs/WASMER-DEPLOYMENT.md`](docs/WASMER-DEPLOYMENT.md). +- Track O generic adapter **completed** (ADR 0006 / `src/compass/serve/adapter.py` + tests). +- ADRs **0005** (browser ENI6MA agent), **0007** (agy behind Gate) Accepted; agy-bridge + Compose on `:8791`. +- **PR #2** Docker `browser-client` challenge UI on `:8088` (handle / binary_url → digest-pin → minimal proof → Ask Gate) — merge + smoke still open. +- **Outstanding:** six-color ceremony UX, verify+burn ledger, full agent boot page, real comPREssOR hop inject, live-agy optional. +''' + text2 = text2.rstrip() + "\n" + notes + "\n" +plans.write_text(text2) +print("PLANS.md ok") + +readme = root / ".cursor/plans/README.md" +r = readme.read_text() +if "compass_phase3_status_handoff_20260907" not in r: + block = ''' +## Phase 3 — Browser agent + status handoff + +| Track | Plan | +| --- | --- | +| O — Generic LLM adapter | [compass_phase3_track_o_generic_adapter_a7c3e91f.plan.md](compass_phase3_track_o_generic_adapter_a7c3e91f.plan.md) | +| P3 Status — Grok→Cursor handoff | [compass_phase3_status_handoff_20260907.plan.md](compass_phase3_status_handoff_20260907.plan.md) | + +Canonical narrative status: [`../../docs/CURSOR-HANDOFF.md`](../../docs/CURSOR-HANDOFF.md) +''' + if "## Phase 3" not in r: + r = r.rstrip() + "\n" + block + "\n" + else: + r = r.rstrip() + "\n| P3 Status — Grok→Cursor handoff | [compass_phase3_status_handoff_20260907.plan.md](compass_phase3_status_handoff_20260907.plan.md) |\n" + readme.write_text(r) + print("plans README ok") + +dread = root / "docs/README.md" +dt = dread.read_text() +if "CURSOR-HANDOFF.md" not in dt: + row = "| [`CURSOR-HANDOFF.md`](CURSOR-HANDOFF.md) | **Cursor handoff (2026-09-07):** Phase 3 status, compose ports, PR #2, next todos |" + m = re.search(r"\| \[`AUDIT-GOALS-VS-BROWSER-STACK\.md`\].*\n", dt) + if m: + dt = dt[:m.end()] + row + "\n" + dt[m.end():] + else: + needle2 = "| [`WASMER-DEPLOYMENT.md`](WASMER-DEPLOYMENT.md) | **Phase 3 lifecycle:** zones A–D, Gate auth, adapter→bridge→agy, module maps, ports, operator cheat sheet |" + if needle2 not in dt: + raise SystemExit("docs README insert failed") + dt = dt.replace(needle2, needle2 + "\n" + row) + dread.write_text(dt) + print("docs README ok") + +if not (root / ".dockerignore").exists(): + (root / ".dockerignore").write_text(".git\n**/node_modules\n**/.venv\n**/__pycache__\n**/*.pyc\ntest-results\n**/target\n**/.pytest_cache\n**/dist\n**/build\n*.tgz\n.env\n.env.*\n") + print("dockerignore created") +PY + +/usr/bin/git add \ + docs/CURSOR-HANDOFF.md \ + .cursor/plans/compass_phase3_status_handoff_20260907.plan.md \ + .cursor/plans/README.md \ + PLANS.md \ + docs/README.md \ + .dockerignore || true + +/usr/bin/git status -sb +/usr/bin/git commit -m "docs: Cursor Phase 3 status handoff from Grok Bot" +/usr/bin/git push +gh pr comment 2 --body "Handoff for Cursor: \`docs/CURSOR-HANDOFF.md\` + \`.cursor/plans/compass_phase3_status_handoff_20260907.plan.md\` (also mirrored under ~/.cursor/plans and work/.cursor/plans). Open those first after switching from Grok Bot." +gh pr view 2 --json url,commits --jq '{url, last: .commits[-1].messageHeadline}' diff --git a/scripts/sync_schema.py b/scripts/sync_schema.py new file mode 100755 index 0000000..bcb4a05 --- /dev/null +++ b/scripts/sync_schema.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Regenerate the model-graph.v1.json mirrors from the canonical package copy. + +Canonical source of truth: + + src/compass/schema/model-graph.v1.json + +That path is canonical because ``compass.schema.loader`` reads it at runtime via +``importlib.resources``, and because ``[tool.setuptools.package-data]`` makes it the +only copy that ships in the sdist and wheel. The other two paths are mirrors kept for +machine consumers (``schema/``) and for docs readers (``docs/schema/``); neither is +packaged, so neither may be edited by hand. + +Usage: + + python scripts/sync_schema.py # rewrite both mirrors from canonical + python scripts/sync_schema.py --check # report drift, write nothing, exit 1 on drift + +``tests/test_schema.py`` enforces the same digests, so drift fails the suite as well. +""" + +from __future__ import annotations + +import argparse +import hashlib +import shutil +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +CANONICAL = REPO_ROOT / "src" / "compass" / "schema" / "model-graph.v1.json" +MIRRORS = ( + REPO_ROOT / "schema" / "model-graph.v1.json", + REPO_ROOT / "docs" / "schema" / "model-graph.v1.json", +) + + +def sha256_file(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def rel(path: Path) -> str: + return path.relative_to(REPO_ROOT).as_posix() + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument( + "--check", + action="store_true", + help="verify mirrors match canonical; write nothing; exit 1 on drift", + ) + args = parser.parse_args(argv) + + if not CANONICAL.is_file(): + print(f"[FAIL] canonical schema missing: {rel(CANONICAL)}") + return 2 + + canonical_digest = sha256_file(CANONICAL) + print(f"canonical {rel(CANONICAL)} sha256={canonical_digest}") + + drift = 0 + for mirror in MIRRORS: + if not mirror.is_file(): + if args.check: + print(f"[FAIL] mirror missing: {rel(mirror)}") + drift += 1 + continue + mirror.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(CANONICAL, mirror) + print(f"[PASS] created {rel(mirror)}") + continue + + mirror_digest = sha256_file(mirror) + if mirror_digest == canonical_digest: + print(f"[PASS] in sync {rel(mirror)}") + continue + + if args.check: + print(f"[FAIL] drift {rel(mirror)} sha256={mirror_digest}") + drift += 1 + else: + shutil.copyfile(CANONICAL, mirror) + print(f"[PASS] rewrote {rel(mirror)} (was sha256={mirror_digest})") + + if drift: + print(f"[FAIL] {drift} mirror(s) out of sync — run: python scripts/sync_schema.py") + return 1 + + print("[PASS] all model-graph.v1.json copies share one digest") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/validate-wasmer-mobile.sh b/scripts/validate-wasmer-mobile.sh new file mode 100755 index 0000000..691d016 --- /dev/null +++ b/scripts/validate-wasmer-mobile.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Discoverable validation entry for mobile Wasmer hosts (B4). +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +exec "$ROOT/wasmer/mobile/run-hosts.sh" diff --git a/scripts/wasmer_browser_sdk_smoke.mjs b/scripts/wasmer_browser_sdk_smoke.mjs new file mode 100644 index 0000000..d9cfb25 --- /dev/null +++ b/scripts/wasmer_browser_sdk_smoke.mjs @@ -0,0 +1,405 @@ +#!/usr/bin/env node +/** + * B2 — @wasmer/sdk browser boot + Zone A evidence. + * Isolated page (COOP/COEP) vs fail-open fallback without isolation. + * Does not fetch compass/decide from the registry (publish NOT_RUN). + */ +import http from "node:http"; +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, ".."); +const WASMER = path.join(ROOT, "wasmer"); +const OUT_DIR = path.join(ROOT, "test-results", "r-browser-sdk"); +const ISOLATED_PORT = Number(process.env.COMPASS_SDK_SMOKE_PORT || 8766); +const FALLBACK_PORT = Number(process.env.COMPASS_SDK_FALLBACK_PORT || 8767); +const RUN_PYTHON = process.env.COMPASS_ZONEA_PYTHON !== "0"; + +const CSP = + "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob:; worker-src 'self' blob: 'wasm-unsafe-eval' 'unsafe-eval'; connect-src 'self' http://127.0.0.1:8791 http://localhost:8791 https://raw.githubusercontent.com https://github.com https://objects.githubusercontent.com https://registry.wasmer.io https://cdn.wasmer.io; img-src 'self' data:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'"; + +const MIME = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".wasm": "application/wasm", + ".json": "application/json", + ".webc": "application/webc", + ".md": "text/markdown; charset=utf-8", +}; + +function stamp() { + const d = new Date(); + const p = (n) => String(n).padStart(2, "0"); + return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`; +} + +function logLine(lines, msg) { + const line = `[${new Date().toISOString()}] ${msg}`; + lines.push(line); + console.log(line); +} + +function isolationHeaders(isolated) { + const h = { + "Content-Security-Policy": CSP, + "Cross-Origin-Resource-Policy": "cross-origin", + }; + if (isolated) { + h["Cross-Origin-Opener-Policy"] = "same-origin"; + h["Cross-Origin-Embedder-Policy"] = "require-corp"; + } + return h; +} + +function startStaticServer({ port, isolated, extraFiles }) { + const extras = extraFiles || {}; + const server = http.createServer((req, res) => { + const urlPath = decodeURIComponent((req.url || "/").split("?")[0]); + if (Object.prototype.hasOwnProperty.call(extras, urlPath)) { + const filePath = extras[urlPath]; + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404, isolationHeaders(isolated)); + res.end("not found extra: " + urlPath); + return; + } + const ext = path.extname(filePath); + res.writeHead(200, { + "Content-Type": MIME[ext] || "application/octet-stream", + ...isolationHeaders(isolated), + }); + res.end(data); + }); + return; + } + const rel = urlPath === "/" ? "/browser/zonea.html" : urlPath; + const filePath = path.normalize(path.join(WASMER, rel)); + if (!filePath.startsWith(WASMER)) { + res.writeHead(403, isolationHeaders(isolated)); + res.end("forbidden"); + return; + } + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404, isolationHeaders(isolated)); + res.end("not found: " + rel); + return; + } + const ext = path.extname(filePath); + res.writeHead(200, { + "Content-Type": MIME[ext] || "application/octet-stream", + ...isolationHeaders(isolated), + }); + res.end(data); + }); + }); + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => resolve(server)); + }); +} + +async function loadPw() { + const require = createRequire(import.meta.url); + const name = "play" + "wright"; + const candidates = [ + path.join(WASMER, "browser", "node_modules", name), + path.join(ROOT, "node_modules", name), + name, + ]; + let lastErr; + for (const c of candidates) { + try { + return require(c); + } catch (e) { + lastErr = e; + } + } + throw new Error( + "playwright module missing. From wasmer/browser run npm install, then npx playwright install chromium. " + + lastErr + ); +} + +function buildLocalWebc(lines) { + const outWebc = path.join(OUT_DIR, "compass-decide-0.1.0.webc"); + if (fs.existsSync(outWebc)) { + const st = fs.statSync(outWebc); + logLine(lines, `reusing local webc ${outWebc} bytes=${st.size}`); + return outWebc; + } + const wasmerBin = spawnSync("which", ["wasmer"], { encoding: "utf8" }); + if (wasmerBin.status !== 0) { + logLine(lines, "webc build NOT_RUN: wasmer CLI not on PATH"); + return null; + } + const built = spawnSync( + "wasmer", + ["package", "build", "-o", outWebc, "."], + { cwd: ROOT, encoding: "utf8", maxBuffer: 4 * 1024 * 1024 } + ); + logLine( + lines, + `wasmer package build exit=${built.status} stderr=${(built.stderr || "").trim().slice(0, 400)}` + ); + if (built.status !== 0 || !fs.existsSync(outWebc)) { + logLine(lines, "webc build failed; Zone A will use host wasm only"); + return null; + } + const st = fs.statSync(outWebc); + logLine(lines, `local webc ${outWebc} bytes=${st.size}`); + return outWebc; +} + +function dumpHeaders(url, lines, label) { + return new Promise((resolve) => { + const u = new URL(url); + http + .get(u, (res) => { + const headers = res.headers; + logLine( + lines, + `${label} status=${res.statusCode} csp=${JSON.stringify(headers["content-security-policy"] || null)} coop=${headers["cross-origin-opener-policy"] || ""} coep=${headers["cross-origin-embedder-policy"] || ""}` + ); + res.resume(); + resolve({ + status: res.statusCode, + csp: headers["content-security-policy"] || null, + coop: headers["cross-origin-opener-policy"] || null, + coep: headers["cross-origin-embedder-policy"] || null, + corp: headers["cross-origin-resource-policy"] || null, + }); + }) + .on("error", (e) => { + logLine(lines, `${label} header dump failed: ${e}`); + resolve({ error: String(e) }); + }); + }); +} + +async function launchBrowser(pw, lines) { + const chromium = pw.chromium; + const channel = process.env.COMPASS_SMOKE_CHANNEL || (process.env.CI ? undefined : "chrome"); + const launchOpts = { headless: true }; + if (channel) launchOpts.channel = channel; + try { + const browser = await chromium.launch(launchOpts); + logLine(lines, `launched headless channel=${channel || "bundled"}`); + return browser; + } catch (e) { + const browser = await chromium.launch({ headless: true }); + logLine(lines, "launched bundled after channel failure: " + e); + return browser; + } +} + +async function main() { + fs.mkdirSync(OUT_DIR, { recursive: true }); + const ts = stamp(); + const lines = []; + const evidence = { + stage: "B2", + target: "browser-@wasmer/sdk", + recorded_at: new Date().toISOString(), + grade: "NOT_RUN", + pass: false, + csp: CSP, + python_pin: "python/python@=3.13.18", + sdk_npm: "0.11.0", + registry_compass: "NOT_RUN", + cases: {}, + blocker: null, + }; + + const webcPath = buildLocalWebc(lines); + const extraFiles = {}; + if (webcPath) { + extraFiles["/artifacts/compass-decide-0.1.0.webc"] = webcPath; + } + + let isolatedServer; + let fallbackServer; + try { + isolatedServer = await startStaticServer({ + port: ISOLATED_PORT, + isolated: true, + extraFiles, + }); + fallbackServer = await startStaticServer({ + port: FALLBACK_PORT, + isolated: false, + extraFiles: {}, + }); + logLine(lines, `isolated http://127.0.0.1:${ISOLATED_PORT}/`); + logLine(lines, `fallback http://127.0.0.1:${FALLBACK_PORT}/`); + } catch (e) { + evidence.blocker = "static_server: " + String(e); + writeOut(ts, evidence, lines); + process.exitCode = 2; + return; + } + + evidence.cases.isolated_headers = await dumpHeaders( + `http://127.0.0.1:${ISOLATED_PORT}/browser/zonea.html`, + lines, + "isolated zonea.html" + ); + evidence.cases.fallback_headers = await dumpHeaders( + `http://127.0.0.1:${FALLBACK_PORT}/browser/index.html`, + lines, + "fallback index.html" + ); + + let pw; + try { + pw = await loadPw(); + } catch (e) { + evidence.blocker = String(e); + evidence.grade = "NOT_RUN"; + logLine(lines, "BLOCKER: " + evidence.blocker); + writeOut(ts, evidence, lines); + isolatedServer.close(); + fallbackServer.close(); + process.exitCode = 2; + return; + } + + let browser; + try { + browser = await launchBrowser(pw, lines); + } catch (e) { + evidence.blocker = "browser_launch_failed: " + String(e); + evidence.grade = "NOT_RUN"; + logLine(lines, "BLOCKER: " + evidence.blocker); + writeOut(ts, evidence, lines); + isolatedServer.close(); + fallbackServer.close(); + process.exitCode = 2; + return; + } + + try { + const page = await browser.newPage(); + page.on("console", (msg) => logLine(lines, `isolated console.${msg.type()}: ${msg.text()}`)); + page.on("pageerror", (err) => logLine(lines, "isolated pageerror: " + err)); + page.on("response", (res) => { + if (res.status() >= 400) { + logLine(lines, `isolated http ${res.status()} ${res.url()}`); + } + }); + const pythonQ = RUN_PYTHON ? "python=1" : "python=0"; + const url = `http://127.0.0.1:${ISOLATED_PORT}/browser/zonea.html?${pythonQ}`; + logLine(lines, "goto " + url); + const nav = await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 }); + evidence.cases.isolated_nav_csp = nav ? nav.headers()["content-security-policy"] : null; + const pythonTimeout = RUN_PYTHON ? 300000 : 90000; + await page.waitForFunction( + () => ["1", "0"].includes(document.documentElement.dataset.zoneaDone), + null, + { timeout: pythonTimeout } + ); + const isolatedEval = await page.evaluate(() => ({ + isolated: window.crossOriginIsolated === true, + ready: document.documentElement.dataset.zoneaReady, + sdk: document.documentElement.dataset.zoneaSdk, + host: document.documentElement.dataset.zoneaHost, + webc: document.documentElement.dataset.zoneaWebc, + python: document.documentElement.dataset.zoneaPython, + report: window.__COMPASS_ZONEA__ ? window.__COMPASS_ZONEA__.report() : null, + sharedArrayBuffer: typeof SharedArrayBuffer !== "undefined", + })); + evidence.cases.isolated = isolatedEval; + logLine( + lines, + `isolated crossOriginIsolated=${isolatedEval.isolated} sdk=${isolatedEval.sdk} host=${isolatedEval.host} webc=${isolatedEval.webc} python=${isolatedEval.python}` + ); + + const fb = await browser.newPage(); + fb.on("console", (msg) => logLine(lines, `fallback console.${msg.type()}: ${msg.text()}`)); + const fbUrl = `http://127.0.0.1:${FALLBACK_PORT}/browser/index.html?smoke=1`; + await fb.goto(fbUrl, { waitUntil: "domcontentloaded", timeout: 60000 }); + await fb.waitForFunction( + () => ["1", "0"].includes(document.documentElement.dataset.smokeReady), + null, + { timeout: 60000 } + ); + const fallbackEval = await fb.evaluate(() => ({ + isolated: window.crossOriginIsolated === true, + ready: document.documentElement.dataset.smokeReady, + sdk: window.__COMPASS_SDK__ || null, + fixture: window.__COMPASS_SMOKE__ ? window.__COMPASS_SMOKE__.decideFixture() : null, + })); + evidence.cases.fallback = fallbackEval; + const fallbackOk = + fallbackEval.isolated === false && + fallbackEval.ready === "1" && + fallbackEval.fixture && + fallbackEval.fixture.fail_open === false && + fallbackEval.fixture.selected_model_version_id === "urn:mg:model:cheap"; + logLine( + lines, + `fallback isolated=${fallbackEval.isolated} ready=${fallbackEval.ready} sdk_skipped=${fallbackEval.sdk && fallbackEval.sdk.skipped} fixture=${fallbackEval.fixture && fallbackEval.fixture.selected_model_version_id}` + ); + + const hostOk = isolatedEval.report && isolatedEval.report.compass_host.grade === "FULL"; + const sdkBooted = isolatedEval.isolated === true && isolatedEval.sdk === "1"; + const webcGrade = isolatedEval.report && isolatedEval.report.compass_webc.grade; + const pythonGrade = isolatedEval.report && isolatedEval.report.python.grade; + const registryFake = isolatedEval.report && isolatedEval.report.registry_compass.attempted === true; + + if (registryFake) { + evidence.grade = "FAIL"; + evidence.blocker = "registry compass fetch was attempted; forbidden while publish is NOT_RUN"; + } else if (hostOk && fallbackOk && sdkBooted && isolatedEval.isolated === true) { + // compass/decide is unpublished — Zone A is PARTIAL even when local + // webc + python/python registry succeed. Do not claim FULL. + evidence.grade = "PARTIAL"; + evidence.pass = true; + evidence.blocker = + pythonGrade === "FULL" && webcGrade === "FULL" + ? "compass/decide registry fetch NOT_RUN (publish blocked); local webc + python/python@=3.13.18 succeeded" + : "SDK booted against local guest; registry compass/decide NOT_RUN"; + } else if (hostOk && fallbackOk) { + evidence.grade = "PARTIAL"; + evidence.pass = true; + evidence.blocker = + "SDK/python/webc not fully green; host compass_decide_json + fail-open fallback passed"; + } else { + evidence.grade = "PARTIAL"; + evidence.pass = false; + evidence.blocker = "host decide or fallback failed"; + } + logLine(lines, `RESULT grade=${evidence.grade} pass=${evidence.pass} python=${pythonGrade} webc=${webcGrade}`); + } catch (e) { + evidence.blocker = "smoke_runtime: " + String(e); + evidence.grade = "PARTIAL"; + evidence.pass = false; + logLine(lines, "ERROR: " + evidence.blocker); + } finally { + await browser.close(); + isolatedServer.close(); + fallbackServer.close(); + } + + writeOut(ts, evidence, lines); + process.exitCode = evidence.pass ? 0 : 1; +} + +function writeOut(ts, evidence, lines) { + fs.mkdirSync(OUT_DIR, { recursive: true }); + const logPath = path.join(OUT_DIR, `sdk-boot-${ts}.log.txt`); + fs.writeFileSync(logPath, lines.join("\n") + "\n"); + fs.writeFileSync(path.join(OUT_DIR, "evidence.json"), JSON.stringify(evidence, null, 2) + "\n"); + fs.writeFileSync(path.join(OUT_DIR, "sdk-boot.txt"), lines.join("\n") + "\n"); + evidence.log = path.relative(ROOT, logPath); +} + +main().catch((e) => { + console.error(e); + process.exit(2); +}); diff --git a/scripts/wasmer_desktop_packaged.py b/scripts/wasmer_desktop_packaged.py new file mode 100755 index 0000000..65fe11e --- /dev/null +++ b/scripts/wasmer_desktop_packaged.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +"""Packaged .webc vs loose-artifact decide parity for desktop B3. + +Does not replace scripts/wasmer_parity.py (Python vs raw wasm). This checks +that wasmer/desktop/run-decide.sh's local .webc hop and its air-gap wasm hop +emit the same decide envelope / reason codes. + +Writes desktop-only evidence under test-results/s-desktop-mobile/. +Registry-by-name is recorded PARTIAL / NOT_RUN — never faked. +""" +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "wasmer" / "desktop" / "run-decide.sh" +WASM = ROOT / "wasmer" / "artifacts" / "compass-decide.wasm" +OUT_DIR = ROOT / "test-results" / "s-desktop-mobile" +NOW = "2026-09-05T00:00:00Z" +REQUEST = "implement a function" + +COMPARE_KEYS = ( + "selected_model_version_id", + "task_class_id", + "fail_open", + "default_reason", + "rationale", + "decided_at", + "score", + "scores", + "module_version", +) + + +def _ts() -> str: + return datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + + +def _rel(path: Path) -> str: + try: + return path.resolve().relative_to(ROOT).as_posix() + except ValueError: + return path.name + + +def score_close(a, b, tol=1e-9) -> bool: + try: + return abs(float(a) - float(b)) <= tol + except (TypeError, ValueError): + return a == b + + +def compare(name: str, packaged: dict, loose: dict) -> list[str]: + errs: list[str] = [] + for k in COMPARE_KEYS: + if k == "score": + if not score_close(packaged.get(k), loose.get(k)): + errs.append(f"{name}: score: packaged={packaged.get(k)!r} loose={loose.get(k)!r}") + continue + if k == "scores": + a = packaged.get("scores") or {} + b = loose.get("scores") or {} + if set(a) != set(b): + errs.append(f"{name}: score keys diverge: {set(a)} vs {set(b)}") + else: + for mid in a: + if not score_close(a[mid], b[mid]): + errs.append(f"{name}: scores[{mid}]: {a[mid]!r} vs {b[mid]!r}") + continue + if packaged.get(k) != loose.get(k): + errs.append(f"{name}: {k}: packaged={packaged.get(k)!r} loose={loose.get(k)!r}") + return errs + + +def run_decide(env_extra: dict[str, str], *args: str) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env.update(env_extra) + return subprocess.run( + [str(SCRIPT), *args], + cwd=str(ROOT), + capture_output=True, + text=True, + env=env, + check=False, + ) + + +def parse_envelope(stdout: str) -> dict: + lines = [ln for ln in stdout.strip().splitlines() if ln.strip()] + if not lines: + raise ValueError("empty stdout; expected a decide envelope") + return json.loads(lines[-1]) + + +def main() -> int: + OUT_DIR.mkdir(parents=True, exist_ok=True) + ts = _ts() + errors: list[str] = [] + cases: list[dict] = [] + + if shutil.which("wasmer") is None: + print(json.dumps({"ok": False, "errors": ["wasmer binary not found on PATH"]}, indent=2)) + return 1 + if not SCRIPT.is_file() or not os.access(SCRIPT, os.X_OK): + print(json.dumps({"ok": False, "errors": [f"missing executable {_rel(SCRIPT)}"]}, indent=2)) + return 1 + if not WASM.is_file(): + print(json.dumps({"ok": False, "errors": [f"missing {_rel(WASM)}"]}, indent=2)) + return 1 + + wasmer_ver = subprocess.check_output(["wasmer", "--version"], text=True).strip() + + # --- packaged webc vs loose wasm (fixture + one fail-open reason code) --- + scenarios = [ + {"name": "fixture_min", "env": {}}, + {"name": "fail_open_missing", "env": {"COMPASS_FAIL_OPEN_DEMO": "missing", "COMPASS_REQUEST": "x"}}, + ] + for sc in scenarios: + base = { + "COMPASS_NOW": NOW, + "COMPASS_REQUEST": sc["env"].get("COMPASS_REQUEST", REQUEST), + **{k: v for k, v in sc["env"].items() if k != "COMPASS_REQUEST"}, + } + webc = run_decide({**base, "COMPASS_DECIDE_SOURCE": "webc"}) + wasm = run_decide({**base, "COMPASS_DECIDE_SOURCE": "wasm"}) + case = { + "name": sc["name"], + "webc_rc": webc.returncode, + "wasm_rc": wasm.returncode, + "webc_stderr": webc.stderr.strip().splitlines()[-8:], + "wasm_stderr": wasm.stderr.strip().splitlines()[-8:], + } + if webc.returncode != 0: + errors.append(f"{sc['name']}: webc hop rc={webc.returncode}") + if wasm.returncode != 0: + errors.append(f"{sc['name']}: wasm hop rc={wasm.returncode}") + packaged = loose = None + if webc.returncode == 0: + packaged = parse_envelope(webc.stdout) + case["packaged"] = packaged + if wasm.returncode == 0: + loose = parse_envelope(wasm.stdout) + case["loose"] = loose + if packaged is not None and loose is not None: + case["identical_parsed"] = packaged == loose + errs = compare(sc["name"], packaged, loose) + errors.extend(errs) + case["compare_errors"] = errs + cases.append(case) + + # --- registry-by-name: code present, runtime NOT_RUN --- + reg_only = run_decide( + { + "COMPASS_WASMER_USE_REGISTRY": "1", + "COMPASS_WASMER_REGISTRY_ONLY": "1", + "COMPASS_NOW": NOW, + "COMPASS_REQUEST": REQUEST, + }, + "--registry", + ) + registry = { + "grade": "PARTIAL", + "runtime": "NOT_RUN", + "reason": ( + "Wasmer registry publish is NOT_RUN: no login, namespace compass unclaimed. " + "run-decide.sh implements wasmer run @ and falls through. " + "This capture uses COMPASS_WASMER_REGISTRY_ONLY=1 so the hop cannot hide behind local .webc." + ), + "command": "COMPASS_WASMER_USE_REGISTRY=1 COMPASS_WASMER_REGISTRY_ONLY=1 ./wasmer/desktop/run-decide.sh --registry", + "rc": reg_only.returncode, + "stdout_empty": not reg_only.stdout.strip(), + "stderr_tail": reg_only.stderr.strip().splitlines()[-16:], + "publish_state_ref": "wasmer/PUBLISH-NOT_RUN.md", + "faked": False, + } + if reg_only.returncode == 0: + errors.append("registry-only hop unexpectedly succeeded; do not claim a published package") + registry["grade"] = "UNEXPECTED_SUCCESS" + else: + blob = (reg_only.stderr or "") + (reg_only.stdout or "") + honest = ( + "NOT_RUN" in blob + or "not found" in blob.lower() + or "Unable to find" in blob + or "not on the registry" in blob + ) + registry["honest_failure_logged"] = honest + if not honest: + errors.append("registry-only hop failed but did not log an honest NOT_RUN / not-found diagnostic") + + # Fall-through path: registry requested, then local webc must still emit an envelope. + reg_fall = run_decide( + { + "COMPASS_WASMER_USE_REGISTRY": "1", + "COMPASS_NOW": NOW, + "COMPASS_REQUEST": REQUEST, + } + ) + fallthrough = { + "rc": reg_fall.returncode, + "stderr_mentions_fallthrough": "Falling through" in (reg_fall.stderr or ""), + "stderr_mentions_webc": "source=webc" in (reg_fall.stderr or ""), + } + if reg_fall.returncode != 0: + errors.append(f"registry-then-webc fallthrough rc={reg_fall.returncode}") + else: + env = parse_envelope(reg_fall.stdout) + fallthrough["selected_model_version_id"] = env.get("selected_model_version_id") + if env.get("selected_model_version_id") != "urn:mg:model:cheap": + errors.append("fallthrough envelope did not select urn:mg:model:cheap") + + evidence = { + "stage": "s-desktop-mobile", + "plan_todo": "b3-desktop", + "mobile_coverage": "none — desktop-only files; B4 owns mobile", + "script": _rel(SCRIPT), + "wasmer": wasmer_ver, + "parity_script_untouched": "scripts/wasmer_parity.py still runs wasmer on the loose wasm artifact", + "run_order": [ + "1 registry-by-name if requested (COMPASS_WASMER_USE_REGISTRY / --registry)", + "2 local .webc (wasmer package build if missing) — default today", + "3 air-gap wasmer/artifacts/compass-decide.wasm", + ], + "cases": cases, + "registry_by_name": registry, + "registry_fallthrough": fallthrough, + "errors": errors, + "ok": not errors, + "captured_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + } + + json_path = OUT_DIR / "desktop-evidence.json" + json_path.write_text(json.dumps(evidence, indent=2) + "\n") + + # Timestamped transcripts: .log.txt is gitignored; sibling .txt is the committed copy. + def write_pair(stem: str, body: str) -> None: + (OUT_DIR / f"{stem}-{ts}.log.txt").write_text(body) + (OUT_DIR / f"{stem}.txt").write_text(body) + + def fmt_case(c: dict) -> str: + packed = json.dumps(c.get("packaged"), sort_keys=True) + loose = json.dumps(c.get("loose"), sort_keys=True) + ident = "IDENTICAL" if c.get("identical_parsed") else "DIVERGED" + return ( + f"## {c['name']}\n" + f"webc_rc={c['webc_rc']} wasm_rc={c['wasm_rc']} parsed={ident}\n" + f"packaged: {packed}\n" + f"loose: {loose}\n" + ) + + write_pair( + "desktop-packaged-vs-loose", + "\n".join( + [ + f"# packaged .webc vs loose wasm — captured {evidence['captured_at']}", + f"# wasmer: {wasmer_ver}", + f"# script: {_rel(SCRIPT)}", + "", + *[fmt_case(c) for c in cases], + f"errors: {errors}", + f"ok: {not errors}", + "", + ] + ), + ) + write_pair( + "desktop-registry", + "\n".join( + [ + f"# registry-by-name — captured {evidence['captured_at']}", + "# Grade: PARTIAL (code present). Runtime: NOT_RUN. Not faked.", + f"rc={registry['rc']}", + f"stdout_empty={registry['stdout_empty']}", + "stderr:", + *registry["stderr_tail"], + "", + f"fallthrough_rc={fallthrough['rc']}", + f"fallthrough_logged={fallthrough.get('stderr_mentions_fallthrough')}", + f"fallthrough_source_webc={fallthrough.get('stderr_mentions_webc')}", + "", + ] + ), + ) + + print( + json.dumps( + { + "ok": evidence["ok"], + "errors": errors, + "evidence": _rel(json_path), + "registry_by_name": {"grade": "PARTIAL", "runtime": "NOT_RUN"}, + }, + indent=2, + ) + ) + return 0 if not errors else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/wasmer_mobile_glue_check.mjs b/scripts/wasmer_mobile_glue_check.mjs new file mode 100644 index 0000000..c61828b --- /dev/null +++ b/scripts/wasmer_mobile_glue_check.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +/** + * Shared-glue check (NOT a mobile emulator/device run). + * Instantiates compass_core_bg.wasm with the same host.js the iOS/Android + * WebViews load, verifies SHA-256 against SHA256SUMS, and asserts + * fixture_min → urn:mg:model:cheap and missing → snapshot_missing. + */ +import { createHash, webcrypto } from "node:crypto"; +import { readFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import vm from "node:vm"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const ART = join(ROOT, "wasmer", "artifacts"); +const SUMS = join(ART, "SHA256SUMS"); +const WASM = join(ART, "compass_core_bg.wasm"); +const FIXTURE = join(ROOT, "wasmer", "fixtures", "snapshot_min.json"); +const HOST_JS = join(ROOT, "wasmer", "mobile", "shared", "host.js"); +const PIN = join(ROOT, "wasmer", "mobile", "shared", "EXPECTED_SHA256"); + +function sumsLine(text, name) { + for (const line of text.split("\n")) { + const t = line.trim(); + if (!t) continue; + const [digest, file] = t.split(/\s+/, 2); + if (file === name) return digest.toLowerCase(); + } + return null; +} + +function stamp() { + const d = new Date(); + const p = (n) => String(n).padStart(2, "0"); + return `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}-${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}`; +} + +async function main() { + const wasm = readFileSync(WASM); + const expected = sumsLine(readFileSync(SUMS, "utf8"), "compass_core_bg.wasm"); + const pin = readFileSync(PIN, "utf8").trim().toLowerCase(); + const actual = createHash("sha256").update(wasm).digest("hex"); + const snapshotJson = readFileSync(FIXTURE, "utf8"); + + const evidence = { + kind: "mobile-shared-glue", + not_a_device_run: true, + artifact: "wasmer/artifacts/compass_core_bg.wasm", + expected_sha256: expected, + pin_file: pin, + actual_sha256: actual, + size_bytes: wasm.length, + ok: false, + glue: null, + error: null, + }; + + if (!expected) { + evidence.error = "SHA256SUMS missing compass_core_bg.wasm"; + } else if (actual !== expected) { + evidence.error = `artifact digest mismatch expected=${expected} actual=${actual}`; + } else if (pin !== expected) { + evidence.error = `EXPECTED_SHA256 (${pin}) != SHA256SUMS (${expected})`; + } else { + const ctx = { + console, + WebAssembly, + TextEncoder, + TextDecoder, + Uint8Array, + ArrayBuffer, + Int8Array, + Uint16Array, + Int16Array, + Uint32Array, + Int32Array, + Float32Array, + Float64Array, + DataView, + JSON, + Promise, + Error, + Object, + String, + Number, + Array, + Boolean, + Math, + Date, + parseInt, + parseFloat, + isNaN, + isFinite, + undefined, + atob: (s) => Buffer.from(s, "base64").toString("latin1"), + crypto: webcrypto, + setTimeout, + clearTimeout, + queueMicrotask, + }; + ctx.globalThis = ctx; + vm.createContext(ctx); + vm.runInContext(readFileSync(HOST_JS, "utf8"), ctx, { filename: "host.js" }); + const glue = ctx.CompassMobileHost; + evidence.glue = await glue.runFromBytes(wasm, { + expectedSha256: expected, + snapshotJson, + nowIso: glue.NOW_ISO, + requireJsDigest: true, + }); + evidence.ok = !!(evidence.glue && evidence.glue.ok); + if (!evidence.ok) evidence.error = evidence.glue && evidence.glue.error; + } + + const outDir = join(ROOT, "test-results", "s-desktop-mobile"); + mkdirSync(outDir, { recursive: true }); + const ts = stamp(); + const jsonPath = join(outDir, "mobile-glue-check.json"); + const logPath = join(outDir, `mobile-glue-check-${ts}.log.txt`); + const body = JSON.stringify(evidence, null, 2) + "\n"; + writeFileSync(jsonPath, body); + writeFileSync(logPath, body); + + const fixtureId = evidence.glue && evidence.glue.fixture_min && evidence.glue.fixture_min.selected_model_version_id; + const missingReason = evidence.glue && evidence.glue.missing && evidence.glue.missing.default_reason; + console.log(`[${evidence.ok ? "PASS" : "FAIL"}] mobile shared glue (Node, not emulator)`); + console.log(`digest ${actual} match=${actual === expected}`); + console.log(`fixture_min selected=${fixtureId}`); + console.log(`missing default_reason=${missingReason}`); + console.log(`evidence ${jsonPath}`); + if (!evidence.ok) { + console.error(evidence.error || "glue returned ok=false"); + process.exit(1); + } +} + +await main(); diff --git a/services/browser-client/Dockerfile b/services/browser-client/Dockerfile index aaab068..7bc152c 100644 --- a/services/browser-client/Dockerfile +++ b/services/browser-client/Dockerfile @@ -1,12 +1,22 @@ # Serve wasmer/browser + artifacts for the challenge-first appliance. +# Stage 1 copies @wasmer/sdk (browser entrypoint + wasm) so Zone A can +# dynamic-import /browser without baking node_modules into git. +FROM node:22-alpine AS sdk +WORKDIR /sdk +COPY wasmer/browser/package.json wasmer/browser/package-lock.json ./ +RUN npm ci --omit=dev --ignore-scripts + FROM nginx:1.27-alpine COPY services/browser-client/nginx.conf /etc/nginx/nginx.conf COPY wasmer/browser/ /usr/share/nginx/html/ +COPY --from=sdk /sdk/node_modules/@wasmer/sdk /usr/share/nginx/html/vendor/@wasmer/sdk # challenge.html expects ../artifacts from browser/ → map artifacts at /artifacts and also sibling path COPY wasmer/artifacts/ /usr/share/nginx/artifacts/ +COPY wasmer/fixtures/ /usr/share/nginx/fixtures/ # Mirror layout expected by relative ../artifacts from /challenge.html RUN mkdir -p /usr/share/nginx/wasmer && \ ln -sfn /usr/share/nginx/artifacts /usr/share/nginx/wasmer/artifacts && \ + ln -sfn /usr/share/nginx/fixtures /usr/share/nginx/wasmer/fixtures && \ # Pages live at /; relative ../artifacts from /challenge.html resolves to /artifacts — good. true EXPOSE 80 diff --git a/services/browser-client/nginx.conf b/services/browser-client/nginx.conf index 0f74947..4c524af 100644 --- a/services/browser-client/nginx.conf +++ b/services/browser-client/nginx.conf @@ -27,7 +27,18 @@ http { add_header Cross-Origin-Opener-Policy same-origin always; add_header Cross-Origin-Embedder-Policy require-corp always; add_header Cross-Origin-Resource-Policy cross-origin always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self' http://127.0.0.1:8791 http://localhost:8791 https://raw.githubusercontent.com https://github.com https://objects.githubusercontent.com; img-src 'self' data:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'" always; + # COOP/COEP stay same-origin / require-corp (SharedArrayBuffer). + # worker-src: SDK thread-pool uses same-origin browser-worker.js and + # wasm-bindgen blob: workers. 'wasm-unsafe-eval' is required because a + # set worker-src does not inherit script-src, and workers compile WASM. + # blob: on script-src: blob workers execute as scripts. + # 'unsafe-eval': @wasmer/sdk 0.11.0 wasm-bindgen uses new Function + # (__wbg_new_with_args) inside WASIX workers; wasm-unsafe-eval is not + # sufficient (verified: EvalError in browser-worker when creating a sandbox). + # connect-src Wasmer origins verified from @wasmer/sdk 0.11.0 wasm + # (https://registry.wasmer.io/graphql) and python/python@=3.13.18 + # distribution.downloadUrl (https://cdn.wasmer.io/webcimages/...). + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob:; worker-src 'self' blob: 'wasm-unsafe-eval' 'unsafe-eval'; connect-src 'self' http://127.0.0.1:8791 http://localhost:8791 https://raw.githubusercontent.com https://github.com https://objects.githubusercontent.com https://registry.wasmer.io https://cdn.wasmer.io; img-src 'self' data:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'" always; location = / { return 302 /challenge.html; @@ -40,7 +51,16 @@ http { location /artifacts/ { alias /usr/share/nginx/artifacts/; add_header Cross-Origin-Resource-Policy cross-origin always; - types { application/wasm wasm; } + types { + application/wasm wasm; + application/webc webc; + } + } + + location /fixtures/ { + alias /usr/share/nginx/fixtures/; + add_header Cross-Origin-Resource-Policy cross-origin always; + types { application/json json; } } location = /circuit-proxy { diff --git a/test-results/PROOF-consolidation-wasmer-20260907.md b/test-results/PROOF-consolidation-wasmer-20260907.md new file mode 100644 index 0000000..09207fe --- /dev/null +++ b/test-results/PROOF-consolidation-wasmer-20260907.md @@ -0,0 +1,239 @@ +# comPASS consolidation + Wasmer — log-backed proof report + +**Purpose:** Cross-cutting Validation stage for the consolidation and Wasmer deployment plan (todo `validation` only). +**Report date:** 2026-09-07 PT (capture `TS=20260907-212310`, UTC 2026-09-08T04:23:10Z) +**Branch:** `feat/consolidation-and-wasmer` (HEAD at capture: `305609a`) +**Plan alignment:** consolidation + Wasmer plan — Parts A and B implementation todos already complete; this report grades them from artifacts and re-runs the required guards. +**Playwright re-run this session:** no +**iOS Simulator re-run this session:** no + +`.gitignore` line 15 excludes `test-results/**/*.log.txt`. Claims below quote the committed twins under [`t-validation/`](t-validation/README.md). + +## State (validation node) + +| Field | Value | +|---|---| +| Node | Critic (Validate + Prove) | +| Implementation | not in scope — no feature work | +| `iteration_count` | 1 | +| `convergence_status` | **CONVERGED** for the offline/local path | +| Registry publish | NOT_RUN (no `wasmer login`; namespace `compass` unclaimed) | +| Android | NOT_RUN (no SDK) | +| Fixes this session | none | + +## What was run this session + +| Run | Command | Log / twin | Exit | +|---|---|---|---| +| Schema tests | `python -m pytest tests/test_schema.py -v` | [pytest-schema.txt](t-validation/pytest-schema.txt) | 0 | +| Schema mirrors | `python scripts/sync_schema.py --check` | [sync-schema-check.txt](t-validation/sync-schema-check.txt) | 0 | +| Size budget | `python scripts/wasmer_size_budget.py` | [wasmer-size-budget.txt](t-validation/wasmer-size-budget.txt) | 0 | +| Parity | `python scripts/wasmer_parity.py` | [wasmer-parity.txt](t-validation/wasmer-parity.txt) | 0 | +| Full pytest (default venv) | `python -m pytest` | [pytest-full.txt](t-validation/pytest-full.txt) | 0 | +| Desktop smoke | `./wasmer/desktop/run-decide.sh` | [desktop-smoke.txt](t-validation/desktop-smoke.txt) | 0 | +| Desktop fail-open | `COMPASS_FAIL_OPEN_DEMO=missing ./wasmer/desktop/run-decide.sh` | [desktop-fail-open.txt](t-validation/desktop-fail-open.txt) | 0 | +| Registry auth | `wasmer whoami` | [wasmer-whoami.txt](t-validation/wasmer-whoami.txt) | non-zero (expected) | +| Credential boundary | `python -m pytest tests/test_credential_boundary.py -v` | [pytest-credential-boundary.txt](t-validation/pytest-credential-boundary.txt) | 0 | +| paid_sync default | `python -m pytest tests/test_paid_sync.py -v` | [pytest-paid-sync-default.txt](t-validation/pytest-paid-sync-default.txt) | 0 | +| paid_sync sibling venv | compressor engine venv + `PYTHONPATH=src:../comPREssOR/engine/src` | [pytest-paid-sync-compressor-venv.txt](t-validation/pytest-paid-sync-compressor-venv.txt) | 1 (expected) | + +Pytest `tests/test_wasmer_desktop_packaged.py` (part of the 185) re-wrote B3 timestamps only: + +- [s-desktop-mobile/desktop-evidence.json](s-desktop-mobile/desktop-evidence.json) `captured_at` → `2026-09-08T04:23:44Z` +- [s-desktop-mobile/desktop-packaged-vs-loose.txt](s-desktop-mobile/desktop-packaged-vs-loose.txt) +- [s-desktop-mobile/desktop-registry.txt](s-desktop-mobile/desktop-registry.txt) still `rc=1`, runtime NOT_RUN + +## Results summary + +| Scenario | Expected | Actual | Result | Evidence | +|---|---|---|---|---| +| Schema checksum guard | 10 passed | 10 passed in 0.04s | PASS | [pytest-schema.txt](t-validation/pytest-schema.txt) | +| Three schema copies one digest | `7fe7ea117c…` × 3 | same digest, `--check` exit 0 | PASS | [sync-schema-check.txt](t-validation/sync-schema-check.txt) | +| Browser cdylib ≤ 150 KB | ≤ 150000 | 103980 | PASS | [wasmer-size-budget.txt](t-validation/wasmer-size-budget.txt) | +| SHA256SUMS match on-disk | actual == expected | MATCH, `errors: []` | PASS | same | +| Python vs wasm parity | `ok: true` | `ok: true`, fail-open cases listed | PASS | [wasmer-parity.txt](t-validation/wasmer-parity.txt) | +| Default full pytest | honest count | **185 passed, 0 failed** | PASS (default venv) | [pytest-full.txt](t-validation/pytest-full.txt) | +| Desktop local `.webc` | decide `urn:mg:model:cheap` | that id, `fail_open: false` | PASS | [desktop-smoke.txt](t-validation/desktop-smoke.txt) | +| Fail-open demo | `snapshot_missing` | `fail_open: true`, reason `snapshot_missing` | PASS | [desktop-fail-open.txt](t-validation/desktop-fail-open.txt) | +| `wasmer login` | not present | `Not logged in registry wasmer.io` | NOT_RUN (honest) | [wasmer-whoami.txt](t-validation/wasmer-whoami.txt) | +| Sibling `test_paid_sync` | still broken | `AttributeError: 'str' object has no attribute 'lineage'` at `bundle.py:117` | FAIL / **NOT_FIXED** | [pytest-paid-sync-compressor-venv.txt](t-validation/pytest-paid-sync-compressor-venv.txt) | + +## Stage grades (verified against the tree; not inflated) + +| Stage | Grade | Why | Evidence | +|---|---|---|---| +| A1–A3 consolidation | **FULL** | Canonical `docs/FRAMEWORK.md`; `PROTOTYPE.md` superseded banner; ground truth in `PLANS.md` / `docs/README.md`; schema digest + packaging guard | [p-consolidation/README.md](p-consolidation/README.md); this-run schema + `--check` | +| B1 registry publish | **NOT_RUN** | No login; `compass` namespace unclaimed; no fake publish | [wasmer/PUBLISH-NOT_RUN.md](../wasmer/PUBLISH-NOT_RUN.md); [q-wasmer-publish/README.md](q-wasmer-publish/README.md); [wasmer-whoami.txt](t-validation/wasmer-whoami.txt) | +| B1 local `.webc` | **FULL** | Manifest at repo-root `wasmer.toml`; packaged run still decides | [q-wasmer-publish/evidence.json](q-wasmer-publish/evidence.json); [desktop-smoke.txt](t-validation/desktop-smoke.txt) | +| B2 browser `@wasmer/sdk` | **PARTIAL** | SDK 0.11.0 local guest FULL in prior capture; registry `compass/decide` NOT_RUN; Playwright **not** re-run here | [r-browser-sdk/README.md](r-browser-sdk/README.md); [r-browser-sdk/evidence.json](r-browser-sdk/evidence.json) | +| B3 desktop | **PARTIAL** | Local `.webc` default FULL (re-smoked); packaged≡loose (pytest refresh); registry-by-name code present, runtime NOT_RUN | [desktop-smoke.txt](t-validation/desktop-smoke.txt); [s-desktop-mobile/README.md](s-desktop-mobile/README.md); [s-desktop-mobile/desktop-registry.txt](s-desktop-mobile/desktop-registry.txt) | +| B4 mobile | **PARTIAL** | iOS Simulator prior run matches Python reason codes; Android NOT_RUN (no SDK); Simulator **not** re-run here | [s-desktop-mobile/mobile-hosts-summary.json](s-desktop-mobile/mobile-hosts-summary.json); [s-desktop-mobile/mobile-ios-simulator.json](s-desktop-mobile/mobile-ios-simulator.json); [s-desktop-mobile/mobile-android.json](s-desktop-mobile/mobile-android.json); commit `305609a` | + +### A1–A3 tree check (this session) + +- `docs/FRAMEWORK.md` exists, status **Canonical**, ADR 0001–0007 as current. +- `PROTOTYPE.md` banner: **HISTORICAL — origin brief**; points at `docs/FRAMEWORK.md`. +- `PLANS.md` line 4 and `docs/README.md` line 6 name `docs/FRAMEWORK.md` as ground truth. +- Canonical schema `src/compass/schema/model-graph.v1.json` digest `7fe7ea117cf40a692d9d62907a722a529cfcd437ab43873d4d8bf16d86c3bae0` shared with both mirrors. +- Stale `wasmer/desktop/wasmer.toml` path: **already corrected** in `docs/WASMER.md` and `docs/WASMER-DEPLOYMENT.md`. File `wasmer/desktop/wasmer.toml` is absent. No docs edit required this session. + +## Environment matrix + +| Environment | Prerequisite | Command / artifact | Grade | +|---|---|---|---| +| CLI / Python (this machine) | `.venv` | full pytest + schema + sync | **FULL** (185 passed) | +| Wasmer CLI local `.webc` | CLI 7.4.0, local package | `run-decide.sh` | **FULL** | +| Wasmer CLI registry-by-name | `wasmer login` + published `compass/decide` | `wasmer whoami`; desktop-registry.txt | **NOT_RUN** | +| Browser Playwright / Zone A | Chromium + prior B2 capture | [r-browser-sdk/](r-browser-sdk/README.md) | **PARTIAL** (prior; not re-run) | +| iOS Simulator | Xcode + simctl | [mobile-ios-simulator.json](s-desktop-mobile/mobile-ios-simulator.json) | **PARTIAL** (prior; not re-run) | +| Android emulator | Android SDK | [mobile-android.json](s-desktop-mobile/mobile-android.json) | **NOT_RUN** | +| Sibling comPREssOR engine venv | engine `.venv` + `chat_compressor` | paid_sync diagnostic | **FAIL** (pre-existing, NOT_FIXED) | + +## Pytest counts (honest) + +**Default comPASS `.venv` (the required full run):** + +``` +185 passed in 4.01s +``` + +`tests/test_schema.py`: **10 passed**. +`tests/test_paid_sync.py` in that venv: **5 passed**. +`tests/test_credential_boundary.py`: **12 passed**. +Collected total: **185** tests. + +This is **not** the same environment as the Part A capture (`1 failed, 182 passed`). Here `chat_compressor` does not import (`ModuleNotFoundError: safetensors`), so `compass.bundle.export_bundle` uses the local free fallback and the test stays green. + +**Sibling engine (diagnostic, expected failure, not fixed):** + +``` +FAILED tests/test_paid_sync.py::test_manual_compass_bundle_api_stays_free +AttributeError: 'str' object has no attribute 'lineage' + ../comPREssOR/engine/src/chat_compressor/bundle.py:117 +``` + +`compass.bundle.export_bundle` passes a graph-root **string** into compressor `export_bundle(store: StateStore, ...)`. Graded **NOT_FIXED** / out of scope (ADR 0002/0003). This session did not change `tests/test_paid_sync.py`. + +## Size budget and parity (this run) + +**Size budget:** `ok: true`. `compass_core_bg.wasm` **103980** bytes ≤ **150000**. All `SHA256SUMS` keys MATCH, including Path-B `eni6ma/demo-wasm/v1/eni6ma_wasm.wasm` (digest-checked only, not size-capped). Side effect: [j-wasmer-packaging/size-budget.json](j-wasmer-packaging/size-budget.json) now includes that eni6ma row (B1 deferred this refresh to validation). + +**Parity:** `ok: true`. Fail-open lines: + +``` +core decide fail-open: snapshot_missing → default +core decide fail-open: snapshot_corrupt → default +core decide fail-open: no_candidates → default +``` + +## Outer `.webc` fingerprint vs module trust root + +| Artifact | sha256 | Role | +|---|---|---| +| `test-results/q-wasmer-publish/compass-decide-0.1.0.webc` | `fe2dfc91893d692cb350ae92dee38a6c71bd669fd1fd847359f0fab2ffe8b5d9` | B1 recorded wrapper | +| repo-root `compass-decide-0.1.0.webc` (used by smoke) | `ee7fab4d7ac38a6519e9207a7b138d05a487a1b46fc758c9c3d3f0eb3637c7b5` | current wrapper (258019 bytes either way) | +| `wasmer/artifacts/compass-decide.wasm` | `e77301bed6f3bcdf8541ba7256cb6a4e58e1da62d7a98edb52fa27bdc1fee553` | **trust root** (MATCH) | +| `wasmer/artifacts/compass_core_bg.wasm` | `9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db` | **trust root** (MATCH) | + +Wrapper drift is documented in `wasmer/PUBLISH-NOT_RUN.md` (readme/license embed). Not a SHA256SUMS violation. `wasmer/artifacts/PACKAGE-DIGESTS.json` still stores the B1 wrapper hash; modules remain the authority. + +## Locked invariants (still hold) + +| Invariant | Status | Evidence | +|---|---|---| +| No `CURSOR_API_KEY` on the hook path | HOLD | Sibling `hook_cli.py` header: "Never requires CURSOR_API_KEY." comPASS [pytest-credential-boundary.txt](t-validation/pytest-credential-boundary.txt): core/route/serve do not import `compass.probe.credentials`; wasmer tree has no credential-module refs. | +| Fail-open | HOLD | Parity fail-open lines; desktop demo `fail_open: true` / `snapshot_missing`; Route tests inside the 185. | +| Digest-as-trust-root | HOLD | Size-budget `sha256_actual == sha256_expected`; iOS prior report `digest_match: true` on the same cdylib hash. | +| Size budget ≤ 150 KB browser cdylib | HOLD | 103980 ≤ 150000 | +| No fake green | HOLD | Registry publish NOT_RUN; Android NOT_RUN; Playwright/iOS not claimed as re-run; sibling paid_sync reproduced as FAIL | + +CSP + COOP/COEP (not re-probed in a browser this session): `services/browser-client/nginx.conf` still has `Cross-Origin-Opener-Policy: same-origin`, `Cross-Origin-Embedder-Policy: require-corp`, `worker-src 'self' blob:`, `blob:` on `script-src`, Wasmer origins on `connect-src`. Prior live header dump: [r-browser-sdk/evidence.json](r-browser-sdk/evidence.json) `isolated_headers`. + +## B2 / B4 prior artifacts (cited, not re-run) + +**B2** ([evidence.json](r-browser-sdk/evidence.json), recorded `2026-09-08T03:54:29.637Z`): + +- `sdk_npm`: 0.11.0; isolated `crossOriginIsolated` true; host/webc/python grades FULL; fail-open fallback with isolation off still decides `urn:mg:model:cheap`; `registry_compass`: NOT_RUN. + +**B4** ([mobile-hosts-summary.json](s-desktop-mobile/mobile-hosts-summary.json)): + +- iOS Simulator PARTIAL: `fixture_min` → `urn:mg:model:cheap`; missing → `snapshot_missing`; `digest_match: true`. +- Android NOT_RUN: `"reason": "Android SDK missing"`. + +## Convergence tracker + +| Iter | Phase | Fix | Criteria | Status | +|---|---|---|---|---| +| 1 | Validate + Prove | none (no breakage that this todo is allowed to fix) | offline guards 5/5 this run; registry/Android still NOT_RUN by design | **CONVERGED** (offline/local) | + +Halt conditions: divergence no; oscillation no; iteration limit n/a. + +## Commit gate + +| Check | Result | +|---|---| +| All hard offline criteria | YES (schema, size, parity, default pytest, desktop local smoke) | +| Registry publish | NO — NOT_RUN by design | +| Android | NO — NOT_RUN by design | +| Proof report | YES (this file) | +| User authorized commit | YES (validation todo: commit proof) | +| Docker | N/A for this todo (no image rebuild) | + +## Fixes this session + +None. No feature re-implementation. Stale `wasmer/desktop/wasmer.toml` docs were already fixed by earlier commits. Leftover untracked `scripts/APPLY_HANDOFF.sh`, `scripts/push_dockerignore.sh`, `scripts/ship_challenge_pr.sh`, `scripts/ship_handoff.sh` left untracked. Dirty `test-results/h-session-polish/` left unstaged (not this todo). + +## Re-run instructions + +```bash +cd +# use the project venv +.venv/bin/python -m pytest tests/test_schema.py -v +.venv/bin/python scripts/sync_schema.py --check +.venv/bin/python scripts/wasmer_size_budget.py +.venv/bin/python scripts/wasmer_parity.py +.venv/bin/python -m pytest +./wasmer/desktop/run-decide.sh +COMPASS_FAIL_OPEN_DEMO=missing ./wasmer/desktop/run-decide.sh +wasmer whoami # expect: Not logged in registry wasmer.io until a human logs in + +# sibling failure (do not treat default-venv green as a fix): +PYTHONPATH=src:../comPREssOR/engine/src \ + ../comPREssOR/engine/.venv/bin/python -m pytest \ + tests/test_paid_sync.py::test_manual_compass_bundle_api_stays_free -v --tb=short +``` + +Browser (not run this session): + +```bash +cd wasmer/browser && npm install && npx playwright install chromium +cd +COMPASS_SMOKE_CHANNEL=chrome node scripts/wasmer_browser_smoke.mjs +COMPASS_ZONEA_PYTHON=1 COMPASS_SMOKE_CHANNEL=chrome node scripts/wasmer_browser_sdk_smoke.mjs +``` + +Mobile (not run this session): `./scripts/validate-wasmer-mobile.sh` + +## Conclusion + +**Overall grade:** **PARTIAL** (program) — **CONVERGED** for the offline/local path. + +- Offline/local: schema, size budget, parity, default pytest (185 passed), desktop `.webc` smoke, fail-open demo — **FULL** this run. +- Registry `compass/decide` publish and Android — **NOT_RUN** by design, not faked. +- Browser SDK and iOS Simulator — **PARTIAL** from prior stage artifacts; not re-executed here. +- Sibling `test_paid_sync` compressor path — **FAIL / NOT_FIXED**. + +**Evidence-backed claims:** all rows in the results table link to an artifact. +**Fixes:** none. +**Next (human):** `wasmer login` + claim `compass` + `wasmer publish .` per `wasmer/PUBLISH-NOT_RUN.md`; Android SDK for B4; optional compressor-side bundle API mismatch (out of this repo). + +## Provenance + +| Section | Source | Type | +|---|---|---| +| Schema / sync / size / parity / pytest / desktop | `test-results/t-validation/*` | Live capture 2026-09-07 PT | +| B1 publish NOT_RUN | `wasmer/PUBLISH-NOT_RUN.md`, `test-results/q-wasmer-publish/` | Prior stage + this-run `wasmer whoami` | +| B2 browser | `test-results/r-browser-sdk/` | Prior capture; Playwright not re-run | +| B3 desktop packaged≡loose | `test-results/s-desktop-mobile/desktop-*` | Prior + pytest timestamp refresh | +| B4 iOS / Android | `test-results/s-desktop-mobile/mobile-*` | Prior; Simulator/SDK not re-run | +| A1–A3 | `docs/FRAMEWORK.md`, `PROTOTYPE.md`, `test-results/p-consolidation/` | Tree check + this-run schema guards | diff --git a/test-results/h-session-polish/README.md b/test-results/h-session-polish/README.md index 2f33e0c..c7566db 100644 --- a/test-results/h-session-polish/README.md +++ b/test-results/h-session-polish/README.md @@ -1,11 +1,6 @@ # Track H — session polish evidence - `evidence.json` — grades FULL/PARTIAL/NOT_RUN for CC-9 and CC-6 -- `session-harness.txt` — harness stdout (fresh advisory + fail-open + CC-6) -- `session-fail-open.txt` — fail-open summary (missing/corrupt/stale) -- `pytest-compass.txt` — full comPASS pytest (must stay green) -- `pytest-compressor-cc9-cc6.txt` — comPREssOR `test_cc9_advisory` + `test_cc6_tokens` +- `session-harness.txt` — harness stdout twin +- `session-fail-open.txt` — fail-open summary - Manual IDE: see `docs/session/CC9-CC6-CHECKLIST.md` §B (`cc9_live_ide_session` stays NOT_RUN until operator runs it) -- No comPREssOR `.py` diffs; HOOK_CONTRACT already documents CC-9 — CC-6 registration documented in checklist (no compressor PR) - -Recorded: 2026-09-05 ~13:59 PT (harness UTC in evidence.json). diff --git a/test-results/h-session-polish/evidence.json b/test-results/h-session-polish/evidence.json index 858cd35..5d69406 100644 --- a/test-results/h-session-polish/evidence.json +++ b/test-results/h-session-polish/evidence.json @@ -1,6 +1,6 @@ { "track": "H", - "recorded_at": "2026-09-05T20:59:02Z", + "recorded_at": "2026-09-08T04:23:42Z", "schema": "compass-advisory/v1", "compressor_root": "/Users/rosario/work/comPREssOR", "cc9_compose_fresh": "FULL", diff --git a/test-results/h-session-polish/session-harness.txt b/test-results/h-session-polish/session-harness.txt index ade96c9..bdb6a73 100644 --- a/test-results/h-session-polish/session-harness.txt +++ b/test-results/h-session-polish/session-harness.txt @@ -1,6 +1,6 @@ compressor_root=/Users/rosario/work/comPREssOR -CC9 wrote advisory → /var/folders/f1/_jlm457d6ts_f77gqp7z08q40000gn/T/h-session-polish-e44bqkrn/context-graphs/advisory/latest.json -CC9 CHAT_COMPRESSOR_ADVISORY_PATH=/var/folders/f1/_jlm457d6ts_f77gqp7z08q40000gn/T/h-session-polish-e44bqkrn/context-graphs/advisory/latest.json +CC9 wrote advisory → /var/folders/f1/_jlm457d6ts_f77gqp7z08q40000gn/T/h-session-polish-tsrg7q59/context-graphs/advisory/latest.json +CC9 CHAT_COMPRESSOR_ADVISORY_PATH=/var/folders/f1/_jlm457d6ts_f77gqp7z08q40000gn/T/h-session-polish-tsrg7q59/context-graphs/advisory/latest.json CC9 compose fresh: ok=True CC9 compose snippet: COMPASS_ADVISORY: task_class=multi_file_refactor; recommended_model=cursor-harness-cc9; Track H harness: fresh advisory must appear in additional_context. CC9 beforeSubmitPrompt fresh: continue=True ok=True diff --git a/test-results/j-wasmer-packaging/browser-smoke.json b/test-results/j-wasmer-packaging/browser-smoke.json index e117b3f..1cb373a 100644 --- a/test-results/j-wasmer-packaging/browser-smoke.json +++ b/test-results/j-wasmer-packaging/browser-smoke.json @@ -1,7 +1,7 @@ { "track": "J", "target": "browser-headless", - "recorded_at": "2026-09-05T21:13:30.228Z", + "recorded_at": "2026-09-08T03:54:15.586Z", "grade": "FULL", "pass": true, "cases": { diff --git a/test-results/j-wasmer-packaging/browser-smoke.txt b/test-results/j-wasmer-packaging/browser-smoke.txt index 9f5e8eb..b99a94d 100644 --- a/test-results/j-wasmer-packaging/browser-smoke.txt +++ b/test-results/j-wasmer-packaging/browser-smoke.txt @@ -1,8 +1,8 @@ -[2026-09-05T21:13:30.231Z] static server on http://127.0.0.1:8765/ -[2026-09-05T21:13:30.601Z] launched headless channel=chrome -[2026-09-05T21:13:30.761Z] module ready -[2026-09-05T21:13:30.762Z] fixture decide: ok=true selected=urn:mg:model:cheap -[2026-09-05T21:13:30.764Z] missing fail-open: ok=true -[2026-09-05T21:13:30.769Z] corrupt fail-open: ok=true -[2026-09-05T21:13:30.771Z] import table empty: ok=true count=0 -[2026-09-05T21:13:30.771Z] RESULT grade=FULL pass=true +[2026-09-08T03:54:15.676Z] static server on http://127.0.0.1:8765/ +[2026-09-08T03:54:20.187Z] launched headless channel=chrome +[2026-09-08T03:54:24.993Z] module ready +[2026-09-08T03:54:25.092Z] fixture decide: ok=true selected=urn:mg:model:cheap +[2026-09-08T03:54:25.098Z] missing fail-open: ok=true +[2026-09-08T03:54:25.102Z] corrupt fail-open: ok=true +[2026-09-08T03:54:25.212Z] import table empty: ok=true count=0 +[2026-09-08T03:54:25.212Z] RESULT grade=FULL pass=true diff --git a/test-results/j-wasmer-packaging/size-budget.json b/test-results/j-wasmer-packaging/size-budget.json index a1bdb3a..84fd466 100644 --- a/test-results/j-wasmer-packaging/size-budget.json +++ b/test-results/j-wasmer-packaging/size-budget.json @@ -4,17 +4,20 @@ "sizes": { "compass-decide.wasm": 135419, "compass_core.wasm": 103980, - "compass_core_bg.wasm": 103980 + "compass_core_bg.wasm": 103980, + "eni6ma/demo-wasm/v1/eni6ma_wasm.wasm": 600779 }, "sha256_expected": { "compass-decide.wasm": "e77301bed6f3bcdf8541ba7256cb6a4e58e1da62d7a98edb52fa27bdc1fee553", "compass_core.wasm": "9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db", - "compass_core_bg.wasm": "9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db" + "compass_core_bg.wasm": "9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db", + "eni6ma/demo-wasm/v1/eni6ma_wasm.wasm": "853717e421a36fc93d0791d3f2718ecf3e9c449fb3c60d4084dedab3af75c389" }, "sha256_actual": { "compass-decide.wasm": "e77301bed6f3bcdf8541ba7256cb6a4e58e1da62d7a98edb52fa27bdc1fee553", "compass_core.wasm": "9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db", - "compass_core_bg.wasm": "9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db" + "compass_core_bg.wasm": "9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db", + "eni6ma/demo-wasm/v1/eni6ma_wasm.wasm": "853717e421a36fc93d0791d3f2718ecf3e9c449fb3c60d4084dedab3af75c389" }, "errors": [] } diff --git a/test-results/p-consolidation/README.md b/test-results/p-consolidation/README.md new file mode 100644 index 0000000..80d44ba --- /dev/null +++ b/test-results/p-consolidation/README.md @@ -0,0 +1,99 @@ +# Stage P — consolidation evidence + +**Date:** 2026-09-07 (PT) / 2026-09-08T03:32Z +**Branch:** `feat/consolidation-and-wasmer` (stacked on `feat/docker-browser-challenge`) +**Scope:** Part A of the consolidation plan — todos `a1-framework`, `a2-demote`, `a3-schema`. +**Not in scope here:** Part B (Wasmer publish / browser SDK / desktop / mobile) and the +cross-cutting proof report. This directory records **this stage only**. + +## Artifacts + +| File | What it captures | +|---|---| +| `consolidation-validation-.log.txt` | Full stage run: schema tests, unit suite, `sync_schema --check`, digests, banner check, ground-truth pointers, link check, sanitization scan | +| `schema-drift-guard-.log.txt` | **Negative control**: injected byte-level drift, observed the guard fail, repaired with `sync_schema.py`, observed it pass again | +| `evidence.json` | Machine-readable summary of the same claims | + +Log files carry the `.log.txt` extension per the SDLC constitution. Note that +`.gitignore` line 15 excludes `test-results/**/*.log.txt` from the repo, so the raw logs +stay local; the claims below reproduce their key lines verbatim so this committed record +stands alone. + +## Claims and evidence + +| # | Claim | Evidence | +|---|---|---| +| 1 | `docs/FRAMEWORK.md` exists as the canonical framework document, assembled from CHARTER / ARCHITECTURE / STACK / audit, with ADR 0001–0007 stated as current and a supersession ledger in Appendix B | File present; `git show --stat` on the commit; §0 decision table and Appendix B ledger | +| 2 | Every relative link in the touched docs resolves | `[PASS] 179 relative links resolved, 0 broken` | +| 3 | No machine-specific absolute path was introduced | `/Users/ hits=0` for `docs/FRAMEWORK.md`, `scripts/sync_schema.py`, `tests/test_schema.py`, `docs/README.md`, `docs/CHARTER.md`, `docs/ARCHITECTURE.md`, `README.md`. `docs/README.md` previously carried 2 and now carries 0. `docs/STACK.md` retains 1 pre-existing hit inside a *prohibition* sentence, untouched | +| 4 | `PROTOTYPE.md` is demoted, not rewritten | `[PASS] superseded banner present`; `[PASS] points at canonical FRAMEWORK.md`; `git diff --numstat` = `24 1 PROTOTYPE.md`; **1** diff hunk, at line 3; heading count `old=67 new=67` | +| 5 | "Ground truth" now names `docs/FRAMEWORK.md` | `PLANS.md:4`, `docs/README.md:6`, `docs/CHARTER.md:4` all read `**Ground truth:** … FRAMEWORK.md`; `docs/ARCHITECTURE.md:6` adds `**Canonical framework:**`; root `README.md:13` lists it first | +| 6 | The canonical schema stays at `src/compass/schema/model-graph.v1.json` and is the only packaged copy | `dist/compass_router-0.1.0.tar.gz` and `…-py3-none-any.whl` each contain exactly `compass/schema/model-graph.v1.json`; neither contains `schema/` nor `docs/schema/`. Guarded by `test_canonical_schema_is_the_packaged_copy` | +| 7 | All three copies share one digest | `7fe7ea117cf40a692d9d62907a722a529cfcd437ab43873d4d8bf16d86c3bae0` for all three paths (unchanged from the pre-change value — no schema content was modified) | +| 8 | `scripts/sync_schema.py --check` reports sync and exits 0 | `[PASS] all model-graph.v1.json copies share one digest`, `exit=0` | +| 9 | The drift guard actually fails on drift | Negative control: reformatting `docs/schema/model-graph.v1.json` (parses equal, bytes differ) produced digest `4d66f123…`; `sync_schema.py --check` → `check-exit=1`; pytest → `1 failed, 1 passed` where the **failure is `test_schema_mirrors_have_no_checksum_drift`** and the pass is the older semantic-equality test. That asymmetry is the reason the checksum guard was added | +| 10 | The guard is repairable in one command | `python scripts/sync_schema.py` → `[PASS] rewrote docs/schema/model-graph.v1.json (was sha256=4d66f123…)`; re-run → `10 passed` | +| 11 | Target suite green | `python -m pytest tests/test_schema.py` → `10 passed in 0.04s` | +| 12 | No regression introduced in the unit suite | `python -m pytest` → `1 failed, 182 passed`. The single failure is **pre-existing and unrelated** — see below | + +## Known pre-existing failure (not caused by this stage) + +``` +FAILED tests/test_paid_sync.py::test_manual_compass_bundle_api_stays_free +AttributeError: 'str' object has no attribute 'lineage' + ../comPREssOR/engine/src/chat_compressor/bundle.py:117 +``` + +Reproduced with **this branch's edits stashed**: `1 failed, 4 passed` on +`tests/test_paid_sync.py` alone at the unmodified tree. The fault is in the sibling +comPREssOR engine's `bundle.py`, which this stage does not touch, and which ADR +0002/0003 place outside this repo's edit surface. Graded **NOT_FIXED / out of scope** +rather than papered over. + +## Re-run instructions + +```bash +cd +python -m pytest tests/test_schema.py # 10 passed +python scripts/sync_schema.py --check # exit 0, three matching digests +shasum -a 256 src/compass/schema/model-graph.v1.json \ + schema/model-graph.v1.json \ + docs/schema/model-graph.v1.json # one digest, three paths + +# reproduce the negative control +python -c "import json,pathlib; p=pathlib.Path('docs/schema/model-graph.v1.json'); \ +p.write_text(json.dumps(json.loads(p.read_text()), indent=4)+chr(10))" +python scripts/sync_schema.py --check # exit 1, [FAIL] drift +python -m pytest tests/test_schema.py # test_schema_mirrors_have_no_checksum_drift fails +python scripts/sync_schema.py # repair +python -m pytest tests/test_schema.py # 10 passed +``` + +## Commit-history note (concurrent branch work) + +Four commits were made for this stage. Three are intact: + +| Commit | Todo | +|---|---| +| `1e75e66` | `a1-framework` — add `docs/FRAMEWORK.md` | +| `3a95d5c` | `a3-schema` — checksum guard + `scripts/sync_schema.py` | +| `6c4df27` | this evidence directory | + +The fourth, `a84612f` (`a2-demote`), was absorbed into `3816465` when a concurrent +agent working the Part B todos on this same branch ran `git commit --amend` while +`a84612f` was `HEAD`. **No content was lost:** all seven files in `3816465` are +byte-identical to `a84612f`, verified with `git diff a84612f HEAD -- ` per file. +Only the commit message for that change now reads as the Part B author's. History was +deliberately **not** rewritten to repair the attribution, because the concurrent agent +was still committing and a rebase would have clobbered its work. `a84612f` remains in +the reflog if the mapping ever needs to be shown. + +## Grade + +| Item | Grade | +|---|---| +| `a1-framework` | **FULL** — document created, links resolve, ADR decisions stated as current | +| `a2-demote` | **FULL** — banner added, body byte-intact, five pointers repointed | +| `a3-schema` | **FULL** — canonical path confirmed by packaging evidence, generator added, checksum guard proven by negative control | +| Unit suite | **PARTIAL** — 182/183; one pre-existing sibling-engine failure, evidenced above | +| Part B (Wasmer) | **NOT_RUN** — owned by a parallel agent; nothing under `wasmer/` or `services/` touched here | diff --git a/test-results/p-consolidation/evidence.json b/test-results/p-consolidation/evidence.json new file mode 100644 index 0000000..758bd40 --- /dev/null +++ b/test-results/p-consolidation/evidence.json @@ -0,0 +1,76 @@ +{ + "stage": "p-consolidation", + "scope": ["a1-framework", "a2-demote", "a3-schema"], + "date_utc": "2026-09-08T03:32:28Z", + "branch": "feat/consolidation-and-wasmer", + "base_head": "c6f5aa0", + "a1_framework": { + "grade": "FULL", + "artifact": "docs/FRAMEWORK.md", + "adrs_stated_as_current": ["0001", "0002", "0003", "0004", "0005", "0006", "0007"], + "supersession_ledger_entries": 9, + "relative_links_checked": 179, + "relative_links_broken": 0, + "absolute_path_hits": 0 + }, + "a2_demote": { + "grade": "FULL", + "prototype_banner_present": true, + "prototype_points_at_framework": true, + "prototype_diff_numstat": "24 1", + "prototype_diff_hunks": 1, + "prototype_heading_count_before": 67, + "prototype_heading_count_after": 67, + "ground_truth_repointed": [ + "PLANS.md:4", + "docs/README.md:6", + "docs/CHARTER.md:4", + "docs/ARCHITECTURE.md:6", + "README.md:13" + ] + }, + "a3_schema": { + "grade": "FULL", + "canonical": "src/compass/schema/model-graph.v1.json", + "canonical_rationale": "read at runtime by compass.schema.loader; only copy present in sdist and wheel", + "mirrors": ["schema/model-graph.v1.json", "docs/schema/model-graph.v1.json"], + "generator": "scripts/sync_schema.py", + "generator_check_exit": 0, + "digest_all_three": "7fe7ea117cf40a692d9d62907a722a529cfcd437ab43873d4d8bf16d86c3bae0", + "schema_bytes_modified": false, + "guards_added": [ + "tests/test_schema.py::test_schema_mirrors_have_no_checksum_drift", + "tests/test_schema.py::test_canonical_schema_is_the_packaged_copy" + ], + "guard_negative_control": { + "injected_drift_digest": "4d66f123e759ca7391d702b45024f6d92392feb1cff25b07a8c36d3f90873a45", + "drift_kind": "byte-level only (reformat; parses equal)", + "sync_check_exit": 1, + "checksum_guard_failed": true, + "pre_existing_semantic_test_failed": false, + "repaired_by": "python scripts/sync_schema.py", + "post_repair_pytest": "10 passed" + } + }, + "tests": { + "tests/test_schema.py": { "passed": 10, "failed": 0 }, + "full_unit_suite": { "passed": 182, "failed": 1 } + }, + "known_pre_existing_failure": { + "test": "tests/test_paid_sync.py::test_manual_compass_bundle_api_stays_free", + "error": "AttributeError: 'str' object has no attribute 'lineage'", + "location": "sibling comPREssOR engine/src/chat_compressor/bundle.py:117", + "caused_by_this_stage": false, + "verification": "reproduced with this branch's edits stashed (1 failed, 4 passed)", + "grade": "NOT_FIXED / out of scope (ADR 0002/0003 edit surface)" + }, + "not_run": { + "part_b_wasmer": "owned by a parallel agent; nothing under wasmer/ or services/ touched", + "cross_cutting_proof_report": "owned by a later dedicated validation agent", + "browser_smoke_size_budget_parity": "not re-run this stage; no artifact or module bytes changed" + }, + "logs": [ + "consolidation-validation-20260907-203228.log.txt", + "schema-drift-guard-20260907-203059.log.txt" + ] +} diff --git a/test-results/q-wasmer-publish/README.md b/test-results/q-wasmer-publish/README.md new file mode 100644 index 0000000..60aace5 --- /dev/null +++ b/test-results/q-wasmer-publish/README.md @@ -0,0 +1,61 @@ +# q-wasmer-publish — stage evidence (plan B1) + +Stage: **B1 registry publish** (`b1-namespace`, `b1-publish`). +Captured 2026-09-07 PT on branch `feat/consolidation-and-wasmer`. + +**Outcome: publish is NOT_RUN. Offline packaging is complete and verified.** +Rationale and unblocking steps: [`wasmer/PUBLISH-NOT_RUN.md`](../../wasmer/PUBLISH-NOT_RUN.md). + +## Artifacts + +| File | Contents | +|---|---| +| `registry-state.txt` | CLI version, auth state, `publish --dry-run` refusal, registry GraphQL namespace/user/package probes plus two control queries | +| `package-build.txt` | `--check` validation with negative controls, local `.webc` build, digest, reproducibility, unpacked contents, `SHA256SUMS` reconciliation, packaged-vs-loose run diff | +| `guards.txt` | `wasmer_size_budget.py`, `wasmer_parity.py`, and four targeted pytest modules after the manifest move | +| `evidence.json` | The same results, machine-readable | +| `compass-decide-0.1.0.webc` | Local package build. **Gitignored** (build output); rebuild to reproduce digest `fe2dfc91…` | + +Raw `.log.txt` transcripts are gitignored repo-wide by `.gitignore` line 15, so the +committed evidence uses `.txt` / `.json`, matching the other `test-results/` stages. + +## Claim → evidence map + +| Claim | Where | +|---|---| +| Wasmer CLI 7.4.0 present | `registry-state.txt` | +| Not authenticated; no token in config or env | `registry-state.txt` | +| `compass` namespace and `compass/decide` package do not exist | `registry-state.txt` (with `wasmer` / `python/python` controls proving the probe works) | +| `wasmer publish --dry-run` cannot run offline — it requires auth first | `registry-state.txt` | +| Root manifest is well-formed | `package-build.txt`, `--check` exit 0 plus two failing negative controls | +| Package identity parses as namespace `compass`, name `decide`, version `0.1.0` | `package-build.txt`, CLI default output filename `compass-decide-0.1.0.webc` | +| `.webc` build is reproducible | `package-build.txt`, two builds → same sha256 | +| Both modules reconcile against `SHA256SUMS` | `package-build.txt`, MATCH lines | +| Packaged run equals loose-artifact run | `package-build.txt`, `diff: IDENTICAL` | +| Existing guards still green | `guards.txt`, all exit 0; 13 tests pass | + +## Re-run + +```bash +cd +wasmer --version && wasmer whoami # expect "Not logged in" until unblocked +wasmer package build --check . +wasmer package build -o test-results/q-wasmer-publish/compass-decide-0.1.0.webc . +shasum -a 256 test-results/q-wasmer-publish/compass-decide-0.1.0.webc +# expect fe2dfc91893d692cb350ae92dee38a6c71bd669fd1fd847359f0fab2ffe8b5d9 + +wasmer run test-results/q-wasmer-publish/compass-decide-0.1.0.webc -- \ + --request "implement a function" \ + --snapshot /wasmer/fixtures/snapshot_min.json \ + --now "2026-09-05T00:00:00Z" + +.venv/bin/python scripts/wasmer_size_budget.py +.venv/bin/python scripts/wasmer_parity.py +.venv/bin/python -m pytest tests/test_wasmer_size_budget.py tests/test_wasmer_parity.py \ + tests/test_wasm_boundary.py tests/test_release_metadata.py -q +``` + +## Not covered here + +Cross-cutting proof reporting and the full end-to-end validation suite belong to +the dedicated validation stage, not to B1. diff --git a/test-results/q-wasmer-publish/evidence.json b/test-results/q-wasmer-publish/evidence.json new file mode 100644 index 0000000..9d4b3b0 --- /dev/null +++ b/test-results/q-wasmer-publish/evidence.json @@ -0,0 +1,158 @@ +{ + "stage": "q-wasmer-publish", + "plan_todos": [ + "b1-namespace", + "b1-publish" + ], + "branch": "feat/consolidation-and-wasmer", + "captured_at": "2026-09-08T03:30:00Z", + "outcome": "PARTIAL", + "outcome_detail": "Offline packaging complete and verified; registry publish NOT_RUN (no credential, namespace unclaimed).", + "b1_namespace": { + "result": "BLOCKED", + "cli": { + "path": "/opt/homebrew/bin/wasmer", + "version": "wasmer 7.4.0", + "present": true + }, + "auth": { + "whoami": "error: Not logged in registry wasmer.io", + "registry_url": "https://registry.wasmer.io/graphql", + "config_token_present": false, + "env_WASMER_TOKEN_present": false + }, + "namespace_probe": { + "endpoint": "https://registry.wasmer.io/graphql", + "getNamespace_compass": null, + "getUser_compass": null, + "getPackage_compass_decide": null, + "control_getNamespace_wasmer": "exists (createdAt 2022-02-15T19:40:20.25664Z)", + "control_getPackage_python_python": "exists (lastVersion 3.13.18)", + "interpretation": "compass is unclaimed, not taken by a third party. Controls prove the probe is sound.", + "search_note": "wasmer package search compass returns only unrelated third-party packages with 'compass' inside the package name, none in a 'compass' namespace." + }, + "fallback_namespace_chosen": null, + "fallback_rationale": "No name collision exists, so no fallback is needed. The blocker is the missing credential.", + "actions_declined": [ + "create a Wasmer account", + "publish under a guessed or squatted namespace", + "fabricate a publish transcript" + ], + "evidence": "registry-state.txt" + }, + "b1_publish": { + "result": "NOT_RUN", + "reason": "wasmer publish --dry-run . exits 1 with 'You are not logged in.' \u2014 the dry run is server-aware and cannot be used offline.", + "not_run_doc": "wasmer/PUBLISH-NOT_RUN.md", + "manifest": { + "path": "wasmer.toml", + "promoted_from": "wasmer/desktop/wasmer.toml", + "package_name": "compass/decide", + "version": "0.1.0", + "modules": [ + "compass-decide (wasi)", + "compass-core (none)" + ], + "commands": [ + "compass-decide -> compass-decide, runner wasi" + ], + "fs_mappings": { + "/wasmer/fixtures": "wasmer/fixtures" + }, + "validation": { + "command": "wasmer package build --check .", + "exit_code": 0, + "negative_controls": [ + "missing module source -> exit 1", + "abi = \"bogus\" -> exit 1, 'expected one of none, wasi, wasm4'" + ] + } + }, + "local_package": { + "filename": "compass-decide-0.1.0.webc", + "sha256": "fe2dfc91893d692cb350ae92dee38a6c71bd669fd1fd847359f0fab2ffe8b5d9", + "bytes": 258019, + "reproducible": true, + "reproducibility_note": "Byte-identical across repeated builds from an unchanged tree. The webc embeds [package].readme and license-file contents, so editing wasmer/README.md or LICENSE moves this digest with no module change; treat it as a build fingerprint, not the trust root.", + "committed": false, + "gitignore_rule": "test-results/**/*.webc", + "contents": { + "modules/compass-decide": "e77301bed6f3bcdf8541ba7256cb6a4e58e1da62d7a98edb52fa27bdc1fee553", + "modules/compass-core": "9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db", + "wasmer.toml": "22a144a3e52b7a7af27a2cc3aa47272b2593deb48d7f2aa6465db99bac45549b", + "wasmer/fixtures/snapshot_min.json": "8318571b06d35c805b21c404a3dc076a1af2be055f3c9fab037abd27404df6a4" + } + }, + "digest_reconciliation": { + "trust_root": "ADR 0005 digest-as-trust-root", + "against": "wasmer/artifacts/SHA256SUMS", + "compass-decide": "MATCH", + "compass-core_vs_compass_core_bg.wasm": "MATCH", + "recorded_in": "wasmer/artifacts/PACKAGE-DIGESTS.json", + "pins_json_touched": false, + "artifacts_modified": false + }, + "packaged_execution": { + "command": "wasmer run -- --request 'implement a function' --snapshot /wasmer/fixtures/snapshot_min.json --now 2026-09-05T00:00:00Z", + "selected_model_version_id": "urn:mg:model:cheap", + "task_class_id": "code_generation", + "fail_open": false, + "score": 0.6, + "vs_loose_artifact_run": "IDENTICAL" + }, + "evidence": "package-build.txt" + }, + "guards": { + "note": "Neither script reads wasmer.toml; both resolve wasmer/artifacts/ directly. Run anyway to prove the manifest move caused no regression.", + "wasmer_size_budget.py": { + "exit_code": 0, + "ok": true, + "browser_cdylib_bytes": 103980, + "budget_bytes": 150000 + }, + "wasmer_parity.py": { + "exit_code": 0, + "ok": true, + "parity_json_changed": false + }, + "pytest": { + "selection": [ + "tests/test_wasmer_size_budget.py", + "tests/test_wasmer_parity.py", + "tests/test_wasm_boundary.py", + "tests/test_release_metadata.py" + ], + "exit_code": 0, + "passed": 13, + "failed": 0 + }, + "evidence": "guards.txt", + "size_budget_side_effect": "scripts/wasmer_size_budget.py rewrites test-results/j-wasmer-packaging/size-budget.json. The committed copy predates the eni6ma artifact entry in SHA256SUMS, so a run adds it. Reverted here to leave that stage's evidence to its owner; the refresh is harmless and 'ok': true either way." + }, + "findings_for_downstream": [ + { + "id": "stale-doc-refs", + "severity": "low", + "detail": "docs/WASMER.md line 63 and docs/WASMER-DEPLOYMENT.md line 172 still place wasmer.toml under wasmer/desktop/. Left untouched because docs/ was owned by a parallel agent.", + "owner": "docs consolidation stage" + }, + { + "id": "builder-paths-in-wasm", + "severity": "medium", + "detail": "Both committed .wasm artifacts embed absolute builder paths (~17-19 occurrences of /Users//.asdf/... rust panic locations). Pre-existing since the initial commit, not introduced by B1, but publishing would expose them publicly. Fix by rebuilding with --remap-path-prefix, which changes digests and therefore needs its own change.", + "owner": "pre-publish hygiene, before wasmer publish" + }, + { + "id": "b3-desktop-blocked", + "severity": "high", + "detail": "B3 cannot run the published package by name. It can run the local .webc by path (packaged command plus bundled fixture both verified) with the raw .wasm path as offline fallback, graded PARTIAL.", + "owner": "b3-desktop" + }, + { + "id": "stale-size-budget-json", + "severity": "low", + "detail": "test-results/j-wasmer-packaging/size-budget.json is stale: it lacks the eni6ma/demo-wasm/v1/eni6ma_wasm.wasm entry that a current run of scripts/wasmer_size_budget.py produces. Reverted rather than refreshed, to stay out of another stage's evidence.", + "owner": "validation stage" + } + ] +} diff --git a/test-results/q-wasmer-publish/guards.txt b/test-results/q-wasmer-publish/guards.txt new file mode 100644 index 0000000..711ebee --- /dev/null +++ b/test-results/q-wasmer-publish/guards.txt @@ -0,0 +1,54 @@ +# Existing-guard regression after manifest promotion — captured 2026-09-08T03:30:34Z + +Neither scripts/wasmer_size_budget.py nor scripts/wasmer_parity.py reads wasmer.toml; +both resolve artifacts from wasmer/artifacts/ directly. Run anyway to prove no regression. + +$ .venv/bin/python scripts/wasmer_size_budget.py +{ + "ok": true, + "budget_bytes": 150000, + "sizes": { + "compass-decide.wasm": 135419, + "compass_core.wasm": 103980, + "compass_core_bg.wasm": 103980, + "eni6ma/demo-wasm/v1/eni6ma_wasm.wasm": 600779 + }, + "sha256_expected": { + "compass-decide.wasm": "e77301bed6f3bcdf8541ba7256cb6a4e58e1da62d7a98edb52fa27bdc1fee553", + "compass_core.wasm": "9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db", + "compass_core_bg.wasm": "9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db", + "eni6ma/demo-wasm/v1/eni6ma_wasm.wasm": "853717e421a36fc93d0791d3f2718ecf3e9c449fb3c60d4084dedab3af75c389" + }, + "sha256_actual": { + "compass-decide.wasm": "e77301bed6f3bcdf8541ba7256cb6a4e58e1da62d7a98edb52fa27bdc1fee553", + "compass_core.wasm": "9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db", + "compass_core_bg.wasm": "9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db", + "eni6ma/demo-wasm/v1/eni6ma_wasm.wasm": "853717e421a36fc93d0791d3f2718ecf3e9c449fb3c60d4084dedab3af75c389" + }, + "errors": [] +} +exit=0 + +$ .venv/bin/python scripts/wasmer_parity.py +core decide fail-open: snapshot_missing → default +core decide fail-open: snapshot_corrupt → default +core decide fail-open: no_candidates → default +{ + "ok": true, + "errors": [], + "evidence": "/test-results/wasmer-parity/parity.json" +} +exit=0 + +$ git diff --stat test-results/wasmer-parity/parity.json +(no output — parity.json byte-identical to committed version) + +$ .venv/bin/python -m pytest tests/test_wasmer_size_budget.py tests/test_wasmer_parity.py tests/test_wasm_boundary.py tests/test_release_metadata.py -q +............. [100%] +exit=0 + +--- note: size-budget.json side effect --- +scripts/wasmer_size_budget.py rewrites test-results/j-wasmer-packaging/size-budget.json. +The committed copy predates the eni6ma entry in SHA256SUMS, so a fresh run adds it. +That refresh was reverted here (git checkout) to leave the j-wasmer-packaging stage's +evidence to its owner. Both versions report "ok": true. diff --git a/test-results/q-wasmer-publish/package-build.txt b/test-results/q-wasmer-publish/package-build.txt new file mode 100644 index 0000000..e8dc08e --- /dev/null +++ b/test-results/q-wasmer-publish/package-build.txt @@ -0,0 +1,59 @@ +# Offline manifest validation + local package build — captured 2026-09-08T03:34:16Z +# Repo root manifest: wasmer.toml (promoted from wasmer/desktop/wasmer.toml) + +$ wasmer package build --check . +exit=0 (silent + 0 == valid) + +--- negative controls: prove --check actually validates --- +$ manifest with a missing module source +error: While parsing the manifest (loaded from /private/tmp/wtcheck/wasmer.toml) +│ 1: Unable to load the "wasmer.toml" manifest +│ 2: Unable to read the "nope" module's file from "/private/tmp/wtcheck/does-not-exist.wasm" +╰─▶ 3: No such file or directory (os error 2) +exit=1 + +$ manifest with abi = "bogus" +╰─▶ 1: TOML parse error at line 8, column 7 + | + 8 | abi = "bogus" + | ^^^^^^^ + unknown variant `bogus`, expected one of `none`, `wasi`, `wasm4` + +exit=1 + +--- package identity --- +$ wasmer package build # run from an empty dir, filename is CLI-chosen +compass-decide-0.1.0.webc + => namespace=compass name=decide version=0.1.0 + +--- build --- +$ wasmer package build -o test-results/q-wasmer-publish/compass-decide-0.1.0.webc . + 258019 bytes + +$ shasum -a 256 +fe2dfc91893d692cb350ae92dee38a6c71bd669fd1fd847359f0fab2ffe8b5d9 test-results/q-wasmer-publish/compass-decide-0.1.0.webc + +$ second independent build into /tmp (reproducibility) +fe2dfc91893d692cb350ae92dee38a6c71bd669fd1fd847359f0fab2ffe8b5d9 /tmp/repro2.webc + +NOTE: the webc embeds [package].readme and license-file contents, so editing +wasmer/README.md or LICENSE moves this digest with no module change. The module +digests below are the stable trust root. + +--- unpacked package contents (sha256, bytes, path) --- +9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db 103980 modules/compass-core +e77301bed6f3bcdf8541ba7256cb6a4e58e1da62d7a98edb52fa27bdc1fee553 135419 modules/compass-decide +22a144a3e52b7a7af27a2cc3aa47272b2593deb48d7f2aa6465db99bac45549b 457 wasmer.toml +8318571b06d35c805b21c404a3dc076a1af2be055f3c9fab037abd27404df6a4 551 wasmer/fixtures/snapshot_min.json + +--- reconciliation vs wasmer/artifacts/SHA256SUMS (ADR 0005 trust root) --- +modules/compass-decide vs SHA256SUMS compass-decide.wasm : MATCH e77301bed6f3bcdf8541ba7256cb6a4e58e1da62d7a98edb52fa27bdc1fee553 +modules/compass-core vs SHA256SUMS compass_core_bg.wasm : MATCH 9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db + +--- packaged run vs loose-artifact run --- +$ wasmer run -- --request "implement a function" --snapshot /wasmer/fixtures/snapshot_min.json --now 2026-09-05T00:00:00Z +{"abi_max":"1.999.0","abi_min":"1.0.0","constraints_applied":[],"decided_at":"2026-09-05T00:00:00Z","default_reason":null,"fail_open":false,"lambda":1.0,"module_version":"0.1.0","rationale":"highest score under quality−λ·cost","score":0.6,"scores":{"urn:mg:model:cheap":0.6,"urn:mg:model:pricey":0.25},"selected_model_version_id":"urn:mg:model:cheap","task_class_id":"code_generation"} +$ wasmer run wasmer/artifacts/compass-decide.wasm --volume "$PWD/wasmer:/wasmer" -- (same args) +{"abi_max":"1.999.0","abi_min":"1.0.0","constraints_applied":[],"decided_at":"2026-09-05T00:00:00Z","default_reason":null,"fail_open":false,"lambda":1.0,"module_version":"0.1.0","rationale":"highest score under quality−λ·cost","score":0.6,"scores":{"urn:mg:model:cheap":0.6,"urn:mg:model:pricey":0.25},"selected_model_version_id":"urn:mg:model:cheap","task_class_id":"code_generation"} + +diff: IDENTICAL diff --git a/test-results/q-wasmer-publish/registry-state.txt b/test-results/q-wasmer-publish/registry-state.txt new file mode 100644 index 0000000..4de179b --- /dev/null +++ b/test-results/q-wasmer-publish/registry-state.txt @@ -0,0 +1,35 @@ +# Wasmer registry state probe — captured 2026-09-08T03:29:55Z + +$ which wasmer && wasmer --version +/opt/homebrew/bin/wasmer +wasmer 7.4.0 + +$ wasmer whoami +error: Not logged in registry wasmer.io + +$ wasmer config get registry.url +https://registry.wasmer.io/graphql + +$ wasmer config get registry.token # empty output == no token +(end of token output) + +$ env | grep -c WASMER_TOKEN +0 + +$ wasmer publish --dry-run . +You are not logged in. Use the `--token` flag or log in (use `wasmer login`) to publish a package. +error: Stopping execution as the user is not logged in. +exit=1 + +--- registry GraphQL (https://registry.wasmer.io/graphql) --- + +query: getNamespace(name:"compass") +{"data":{"getNamespace":null}} +query: getUser(username:"compass") +{"data":{"getUser":null}} +query: getPackage(name:"compass/decide") +{"data":{"getPackage":null}} +CONTROL query (proves the probe works): getNamespace(name:"wasmer") +{"data":{"getNamespace":{"name":"wasmer","createdAt":"2022-02-15T19:40:20.25664Z"}}} +CONTROL query: getPackage(name:"python/python") +{"data":{"getPackage":{"name":"python/python","lastVersion":{"version":"3.13.18"}}}} diff --git a/test-results/r-browser-sdk/README.md b/test-results/r-browser-sdk/README.md new file mode 100644 index 0000000..57f4cbc --- /dev/null +++ b/test-results/r-browser-sdk/README.md @@ -0,0 +1,52 @@ +# B2 — Browser `@wasmer/sdk` boot (Zone A) + +**Date:** 2026-09-07 PT +**Stage grade:** PARTIAL +**Why not FULL:** Registry `compass/decide` is unpublished (`wasmer/PUBLISH-NOT_RUN.md`). Zone A boots the **local** `.webc` / `compass_core_bg.wasm`. No fake registry download of `compass/decide`. + +## Subgrades + +| Check | Grade | Evidence | +|---|---|---| +| CSP (COOP/COEP unchanged; worker-src + blob + Wasmer origins) | FULL | `sdk-boot-*.log.txt` header dump; `evidence.json` `isolated_headers` | +| `window.crossOriginIsolated === true` with COOP/COEP | FULL | isolated page | +| Dynamic import of `@wasmer/sdk` `/browser` entry (`dist/index.js`) | FULL | `sdk.imported` / `sdk.ready` | +| Host `compass_decide_json` vs `wasmer_parity.py` reason codes | FULL | fixture `urn:mg:model:cheap`; missing `snapshot_missing`; corrupt `snapshot_corrupt` | +| Local `.webc` sandbox `compass-decide` | FULL | 258019-byte local package | +| `python/python@=3.13.18` from registry | FULL | stdout `zone-a-python-ok` | +| Fail-open raw instantiate when not isolated | FULL | fallback page `crossOriginIsolated=false`, fixture still green | +| Registry `compass/decide` | NOT_RUN | unpublished; not attempted | + +## CSP directive set (document) + +``` +default-src 'self'; +script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob:; +worker-src 'self' blob: 'wasm-unsafe-eval' 'unsafe-eval'; +connect-src 'self' http://127.0.0.1:8791 http://localhost:8791 https://raw.githubusercontent.com https://github.com https://objects.githubusercontent.com https://registry.wasmer.io https://cdn.wasmer.io; +img-src 'self' data:; +style-src 'self' 'unsafe-inline'; +object-src 'none'; +base-uri 'none' +``` + +COOP `same-origin` / COEP `require-corp` unchanged. + +`'unsafe-eval'` is required: `@wasmer/sdk` 0.11.0 wasm-bindgen calls `new Function` (`__wbg_new_with_args`) in WASIX workers. `'wasm-unsafe-eval'` alone produced an `EvalError` (see `sdk-boot-20260907-205015.log.txt`). + +Wasmer `connect-src` hosts were verified, not guessed: GraphQL `https://registry.wasmer.io/graphql` is embedded in the SDK wasm; `python/python@=3.13.18` `distribution.downloadUrl` is `https://cdn.wasmer.io/webcimages/…`. + +## Docker nginx + +`docker-nginx-csp.txt`: COOP/COEP/CSP match the smoke server. Also HTTP 200 for `/healthz`, `/vendor/@wasmer/sdk/dist/index.js`, `/vendor/@wasmer/sdk/pkg/wasmer_sdk_js_bg.wasm`, `/fixtures/snapshot_min.json`, `/artifacts/compass_core_bg.wasm`. + +## Re-run + +```bash +cd wasmer/browser && npm install && npx playwright install chromium +# from repo root +COMPASS_SMOKE_CHANNEL=chrome node scripts/wasmer_browser_smoke.mjs +COMPASS_ZONEA_PYTHON=1 COMPASS_SMOKE_CHANNEL=chrome node scripts/wasmer_browser_sdk_smoke.mjs +``` + +Skip the python registry download: `COMPASS_ZONEA_PYTHON=0`. diff --git a/test-results/r-browser-sdk/docker-nginx-csp.txt b/test-results/r-browser-sdk/docker-nginx-csp.txt new file mode 100644 index 0000000..9ae14b2 --- /dev/null +++ b/test-results/r-browser-sdk/docker-nginx-csp.txt @@ -0,0 +1,11 @@ +# nginx (Docker compass-browser-client:b2) document headers — 2026-09-08 + +curl -sS -D - -o /dev/null http://127.0.0.1:18088/zonea.html + +HTTP/1.1 200 OK +Cross-Origin-Opener-Policy: same-origin +Cross-Origin-Embedder-Policy: require-corp +Cross-Origin-Resource-Policy: cross-origin +Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob:; worker-src 'self' blob: 'wasm-unsafe-eval' 'unsafe-eval'; connect-src 'self' http://127.0.0.1:8791 http://localhost:8791 https://raw.githubusercontent.com https://github.com https://objects.githubusercontent.com https://registry.wasmer.io https://cdn.wasmer.io; img-src 'self' data:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none' + +Also 200: /healthz, /vendor/@wasmer/sdk/dist/index.js, /vendor/@wasmer/sdk/pkg/wasmer_sdk_js_bg.wasm, /fixtures/snapshot_min.json, /artifacts/compass_core_bg.wasm diff --git a/test-results/r-browser-sdk/evidence.json b/test-results/r-browser-sdk/evidence.json new file mode 100644 index 0000000..ff5084a --- /dev/null +++ b/test-results/r-browser-sdk/evidence.json @@ -0,0 +1,136 @@ +{ + "stage": "B2", + "target": "browser-@wasmer/sdk", + "recorded_at": "2026-09-08T03:54:29.637Z", + "grade": "PARTIAL", + "pass": true, + "csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob:; worker-src 'self' blob: 'wasm-unsafe-eval' 'unsafe-eval'; connect-src 'self' http://127.0.0.1:8791 http://localhost:8791 https://raw.githubusercontent.com https://github.com https://objects.githubusercontent.com https://registry.wasmer.io https://cdn.wasmer.io; img-src 'self' data:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'", + "python_pin": "python/python@=3.13.18", + "sdk_npm": "0.11.0", + "registry_compass": "NOT_RUN", + "cases": { + "isolated_headers": { + "status": 200, + "csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob:; worker-src 'self' blob: 'wasm-unsafe-eval' 'unsafe-eval'; connect-src 'self' http://127.0.0.1:8791 http://localhost:8791 https://raw.githubusercontent.com https://github.com https://objects.githubusercontent.com https://registry.wasmer.io https://cdn.wasmer.io; img-src 'self' data:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'", + "coop": "same-origin", + "coep": "require-corp", + "corp": "cross-origin" + }, + "fallback_headers": { + "status": 200, + "csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob:; worker-src 'self' blob: 'wasm-unsafe-eval' 'unsafe-eval'; connect-src 'self' http://127.0.0.1:8791 http://localhost:8791 https://raw.githubusercontent.com https://github.com https://objects.githubusercontent.com https://registry.wasmer.io https://cdn.wasmer.io; img-src 'self' data:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'", + "coop": null, + "coep": null, + "corp": "cross-origin" + }, + "isolated_nav_csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob:; worker-src 'self' blob: 'wasm-unsafe-eval' 'unsafe-eval'; connect-src 'self' http://127.0.0.1:8791 http://localhost:8791 https://raw.githubusercontent.com https://github.com https://objects.githubusercontent.com https://registry.wasmer.io https://cdn.wasmer.io; img-src 'self' data:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'", + "isolated": { + "isolated": true, + "ready": "1", + "sdk": "1", + "host": "FULL", + "webc": "FULL", + "python": "FULL", + "report": { + "isolated": true, + "sdk": { + "imported": true, + "ready": true, + "error": null + }, + "compass_host": { + "grade": "FULL", + "source": "/artifacts/compass_core_bg.wasm", + "decision": { + "abi_max": "1.999.0", + "abi_min": "1.0.0", + "constraints_applied": [], + "decided_at": "2026-09-05T00:00:00Z", + "default_reason": null, + "fail_open": false, + "lambda": 1, + "module_version": "0.1.0", + "rationale": "highest score under quality−λ·cost", + "score": 0.6, + "scores": { + "urn:mg:model:cheap": 0.6, + "urn:mg:model:pricey": 0.25 + }, + "selected_model_version_id": "urn:mg:model:cheap", + "task_class_id": "code_generation" + }, + "missing_reason": "snapshot_missing", + "corrupt_reason": "snapshot_corrupt", + "error": null + }, + "compass_webc": { + "grade": "FULL", + "source": "local_webc", + "bytes": 258019, + "decision": { + "abi_max": "1.999.0", + "abi_min": "1.0.0", + "constraints_applied": [], + "decided_at": "2026-09-05T00:00:00Z", + "default_reason": null, + "fail_open": false, + "lambda": 1, + "module_version": "0.1.0", + "rationale": "highest score under quality−λ·cost", + "score": 0.6, + "scores": { + "urn:mg:model:cheap": 0.6, + "urn:mg:model:pricey": 0.25 + }, + "selected_model_version_id": "urn:mg:model:cheap", + "task_class_id": "code_generation" + }, + "stdout_ok": true, + "error": null + }, + "python": { + "grade": "FULL", + "pin": "python/python@=3.13.18", + "output": "zone-a-python-ok", + "error": null, + "source": "registry" + }, + "registry_compass": { + "attempted": false, + "note": "NOT_RUN — wasmer/PUBLISH-NOT_RUN.md; no fake registry fetch" + } + }, + "sharedArrayBuffer": true + }, + "fallback": { + "isolated": false, + "ready": "1", + "sdk": { + "isolated": false, + "imported": false, + "ready": false, + "skipped": "not_cross_origin_isolated", + "error": null + }, + "fixture": { + "abi_max": "1.999.0", + "abi_min": "1.0.0", + "constraints_applied": [], + "decided_at": "2026-09-05T00:00:00Z", + "default_reason": null, + "fail_open": false, + "lambda": 1, + "module_version": "0.1.0", + "rationale": "highest score under quality−λ·cost", + "score": 0.6, + "scores": { + "urn:mg:model:cheap": 0.6, + "urn:mg:model:pricey": 0.25 + }, + "selected_model_version_id": "urn:mg:model:cheap", + "task_class_id": "code_generation" + } + } + }, + "blocker": "compass/decide registry fetch NOT_RUN (publish blocked); local webc + python/python@=3.13.18 succeeded" +} diff --git a/test-results/r-browser-sdk/sdk-boot.txt b/test-results/r-browser-sdk/sdk-boot.txt new file mode 100644 index 0000000..34818a9 --- /dev/null +++ b/test-results/r-browser-sdk/sdk-boot.txt @@ -0,0 +1,10 @@ +[2026-09-08T03:54:29.637Z] reusing local webc /Users/rosario/work/comPASS/test-results/r-browser-sdk/compass-decide-0.1.0.webc bytes=258019 +[2026-09-08T03:54:29.646Z] isolated http://127.0.0.1:8766/ +[2026-09-08T03:54:29.646Z] fallback http://127.0.0.1:8767/ +[2026-09-08T03:54:29.661Z] isolated zonea.html status=200 csp="default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob:; worker-src 'self' blob: 'wasm-unsafe-eval' 'unsafe-eval'; connect-src 'self' http://127.0.0.1:8791 http://localhost:8791 https://raw.githubusercontent.com https://github.com https://objects.githubusercontent.com https://registry.wasmer.io https://cdn.wasmer.io; img-src 'self' data:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'" coop=same-origin coep=require-corp +[2026-09-08T03:54:29.685Z] fallback index.html status=200 csp="default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob:; worker-src 'self' blob: 'wasm-unsafe-eval' 'unsafe-eval'; connect-src 'self' http://127.0.0.1:8791 http://localhost:8791 https://raw.githubusercontent.com https://github.com https://objects.githubusercontent.com https://registry.wasmer.io https://cdn.wasmer.io; img-src 'self' data:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'" coop= coep= +[2026-09-08T03:54:33.562Z] launched headless channel=chrome +[2026-09-08T03:54:37.155Z] goto http://127.0.0.1:8766/browser/zonea.html?python=1 +[2026-09-08T03:54:43.596Z] isolated crossOriginIsolated=true sdk=1 host=FULL webc=FULL python=FULL +[2026-09-08T03:54:45.847Z] fallback isolated=false ready=1 sdk_skipped=not_cross_origin_isolated fixture=urn:mg:model:cheap +[2026-09-08T03:54:45.847Z] RESULT grade=PARTIAL pass=true python=FULL webc=FULL diff --git a/test-results/s-desktop-mobile/README.md b/test-results/s-desktop-mobile/README.md new file mode 100644 index 0000000..8a70ac0 --- /dev/null +++ b/test-results/s-desktop-mobile/README.md @@ -0,0 +1,95 @@ +# s-desktop-mobile — stage evidence (B3 desktop + B4 mobile) + +Captured on branch `feat/consolidation-and-wasmer`. + +Desktop files (`desktop-*.log.txt`, `desktop-evidence.json`) are **B3** and must not be deleted. Mobile files below are **B4**. + +## B4 mobile hosts (2026-09-07 PT / 2026-09-08Z) + +| Path | Grade | Notes | +|---|---|---| +| iOS WKWebView Simulator | **PARTIAL** | `simctl` install/launch on iPhone 16 Pro (iOS 18.6). fixture_min → `urn:mg:model:cheap`; missing → `snapshot_missing`; digest matches `SHA256SUMS`. Not a physical device (**not FULL**). | +| Android WebView | **NOT_RUN** | Host sources present. No Android SDK (`ANDROID_HOME` unset). Not faked. | +| Shared JS glue (Node) | glue-only | Same `host.js`; **not** a mobile run. | +| Size budget | FULL | `compass_core_bg.wasm` 103980 ≤ 150000; hash unchanged | + +### Mobile artifacts + +| File | Contents | +|---|---| +| `mobile-ios-simulator.json` | Simulator grade + JS report | +| `mobile-ios-simulator-*.log.txt` | xcodebuild + simctl transcript (gitignored `*.log.txt`) | +| `mobile-android.json` | Honest SDK-missing NOT_RUN | +| `mobile-glue-check.json` | Node instantiate + parity | +| `mobile-size-budget.json` | cdylib size + digest | +| `mobile-hosts-summary.json` | Overall PARTIAL | + +### Re-run mobile + +```bash +./scripts/validate-wasmer-mobile.sh +# pieces: +node scripts/wasmer_mobile_glue_check.mjs +python scripts/wasmer_size_budget.py +./wasmer/mobile/ios/run-simulator.sh +./wasmer/mobile/android/run-emulator.sh +``` + +Xcode 26.2 on this machine had no `xcodebuild -destination` simulator entries (iOS 26.2 runtime/component missing). The runner builds `-sdk iphonesimulator` and uses `simctl`. Install the iOS 26.2 Simulator runtime for scheme-based `xcodebuild test`. + +## B3 desktop reconcile (unchanged) + +Stage: **B3 desktop reconcile** (`b3-desktop`). +Captured 2026-09-07 PT (2026-09-08Z) on branch `feat/consolidation-and-wasmer`. + +## Outcome + +| Path | Grade | Notes | +|---|---|---| +| Local `.webc` (`wasmer run compass-decide-0.1.0.webc`) | FULL | Default hop. Envelope IDENTICAL to loose wasm | +| Air-gap wasm (`wasmer/artifacts/compass-decide.wasm`) | FULL | Same volume map as `scripts/wasmer_parity.py` | +| Registry-by-name (`wasmer run compass/decide@0.1.0`) | **PARTIAL** | Code present in `run-decide.sh`. Runtime **NOT_RUN** — no login, namespace `compass` unclaimed. Not faked. | +| Module trust root vs B1 | FULL | Unpacked `compass-decide` / `compass-core` bytes MATCH `SHA256SUMS`. Outer `.webc` wrapper digest may differ from `PACKAGE-DIGESTS.json` without a module change. | + +## Run order (`wasmer/desktop/run-decide.sh`) + +1. If `COMPASS_WASMER_USE_REGISTRY=1` or `--registry`, try `wasmer run @`. On failure, log honestly and fall through. +2. Else (default today): `wasmer run --offline` the local `.webc` (build with `wasmer package build` if missing). +3. Air-gap: `wasmer run --offline wasmer/artifacts/compass-decide.wasm` with `--volume "$PWD/wasmer:/wasmer"`. + +`scripts/wasmer_parity.py` still shells `wasmer run` on the raw wasm itself (Python vs wasm). Packaged-vs-loose is `scripts/wasmer_desktop_packaged.py`. + +## Artifacts + +| File | Contents | +|---|---| +| `desktop-evidence.json` | Machine-readable envelopes, compare errors, registry grade | +| `desktop-packaged-vs-loose.txt` | fixture_min + fail_open_missing, both IDENTICAL | +| `desktop-registry.txt` | Honest registry failure + fallthrough-to-webc | +| `desktop-guards.txt` | size-budget, Python-vs-wasm parity, targeted pytest | +| `desktop-*-.log.txt` | Same transcripts, gitignored by `test-results/**/*.log.txt` | + +## Claim → evidence map + +| Claim | Where | +|---|---| +| Packaged webc envelope equals loose wasm (fixture) | `desktop-packaged-vs-loose.txt`, `identical_parsed: true` | +| Reason code `snapshot_missing` matches on both hops | `desktop-packaged-vs-loose.txt`, `fail_open_missing` | +| Registry hop fails with "not found" / NOT_RUN | `desktop-registry.txt` | +| Registry then falls through to webc and still decides | `desktop-evidence.json` `registry_fallthrough` | +| `wasmer_parity.py` / `wasmer_size_budget.py` still green | `desktop-guards.txt` | +| iOS Simulator decide parity | `mobile-ios-simulator.json` | +| Android emulator | `mobile-android.json` (NOT_RUN) | + +## Re-run desktop + +```bash +python scripts/wasmer_desktop_packaged.py +python scripts/wasmer_parity.py +python scripts/wasmer_size_budget.py +python -m pytest tests/test_wasmer_desktop_packaged.py tests/test_wasmer_parity.py tests/test_wasmer_size_budget.py -q +``` + +## Not covered here + +Registry publish, browser `@wasmer/sdk` (B2), full pytest, and the cross-cutting proof report. diff --git a/test-results/s-desktop-mobile/desktop-evidence.json b/test-results/s-desktop-mobile/desktop-evidence.json new file mode 100644 index 0000000..b6cb783 --- /dev/null +++ b/test-results/s-desktop-mobile/desktop-evidence.json @@ -0,0 +1,143 @@ +{ + "stage": "s-desktop-mobile", + "plan_todo": "b3-desktop", + "mobile_coverage": "none \u2014 desktop-only files; B4 owns mobile", + "script": "wasmer/desktop/run-decide.sh", + "wasmer": "wasmer 7.4.0", + "parity_script_untouched": "scripts/wasmer_parity.py still runs wasmer on the loose wasm artifact", + "run_order": [ + "1 registry-by-name if requested (COMPASS_WASMER_USE_REGISTRY / --registry)", + "2 local .webc (wasmer package build if missing) \u2014 default today", + "3 air-gap wasmer/artifacts/compass-decide.wasm" + ], + "cases": [ + { + "name": "fixture_min", + "webc_rc": 0, + "wasm_rc": 0, + "webc_stderr": [ + "[run-decide] using existing webc compass-decide-0.1.0.webc", + "[run-decide] source=webc input=compass-decide-0.1.0.webc" + ], + "wasm_stderr": [ + "[run-decide] source=wasm input=wasmer/artifacts/compass-decide.wasm" + ], + "packaged": { + "abi_max": "1.999.0", + "abi_min": "1.0.0", + "constraints_applied": [], + "decided_at": "2026-09-05T00:00:00Z", + "default_reason": null, + "fail_open": false, + "lambda": 1.0, + "module_version": "0.1.0", + "rationale": "highest score under quality\u2212\u03bb\u00b7cost", + "score": 0.6, + "scores": { + "urn:mg:model:cheap": 0.6, + "urn:mg:model:pricey": 0.25 + }, + "selected_model_version_id": "urn:mg:model:cheap", + "task_class_id": "code_generation" + }, + "loose": { + "abi_max": "1.999.0", + "abi_min": "1.0.0", + "constraints_applied": [], + "decided_at": "2026-09-05T00:00:00Z", + "default_reason": null, + "fail_open": false, + "lambda": 1.0, + "module_version": "0.1.0", + "rationale": "highest score under quality\u2212\u03bb\u00b7cost", + "score": 0.6, + "scores": { + "urn:mg:model:cheap": 0.6, + "urn:mg:model:pricey": 0.25 + }, + "selected_model_version_id": "urn:mg:model:cheap", + "task_class_id": "code_generation" + }, + "identical_parsed": true, + "compare_errors": [] + }, + { + "name": "fail_open_missing", + "webc_rc": 0, + "wasm_rc": 0, + "webc_stderr": [ + "[run-decide] using existing webc compass-decide-0.1.0.webc", + "[run-decide] source=webc input=compass-decide-0.1.0.webc" + ], + "wasm_stderr": [ + "[run-decide] source=wasm input=wasmer/artifacts/compass-decide.wasm" + ], + "packaged": { + "abi_max": "1.999.0", + "abi_min": "1.0.0", + "constraints_applied": [], + "decided_at": "2026-09-05T00:00:00Z", + "default_reason": "snapshot_missing", + "fail_open": true, + "lambda": 1.0, + "module_version": "0.1.0", + "rationale": "fail-open: snapshot_missing", + "score": -0.5, + "scores": { + "default": -0.5 + }, + "selected_model_version_id": "default", + "task_class_id": "general" + }, + "loose": { + "abi_max": "1.999.0", + "abi_min": "1.0.0", + "constraints_applied": [], + "decided_at": "2026-09-05T00:00:00Z", + "default_reason": "snapshot_missing", + "fail_open": true, + "lambda": 1.0, + "module_version": "0.1.0", + "rationale": "fail-open: snapshot_missing", + "score": -0.5, + "scores": { + "default": -0.5 + }, + "selected_model_version_id": "default", + "task_class_id": "general" + }, + "identical_parsed": true, + "compare_errors": [] + } + ], + "registry_by_name": { + "grade": "PARTIAL", + "runtime": "NOT_RUN", + "reason": "Wasmer registry publish is NOT_RUN: no login, namespace compass unclaimed. run-decide.sh implements wasmer run @ and falls through. This capture uses COMPASS_WASMER_REGISTRY_ONLY=1 so the hop cannot hide behind local .webc.", + "command": "COMPASS_WASMER_USE_REGISTRY=1 COMPASS_WASMER_REGISTRY_ONLY=1 ./wasmer/desktop/run-decide.sh --registry", + "rc": 1, + "stdout_empty": true, + "stderr_tail": [ + "[run-decide] trying registry-by-name: wasmer run compass/decide@0.1.0", + "[run-decide] publish state is NOT_RUN until wasmer/PUBLISH-NOT_RUN.md flips; this hop is expected to fail until a human wasmer login + namespace claim + wasmer publish .", + "[run-decide] source=registry input=compass/decide@0.1.0", + "error: Unable to find \"compass/decide@^0.1.0\" in the registry", + "\u2570\u2500\u25b6 1: failed to query package 'compass/decide@^0.1.0': not found", + "[run-decide] source=registry failed rc=1 input=compass/decide@0.1.0", + "[run-decide] registry-by-name PARTIAL (code present) / runtime NOT_RUN: compass/decide@0.1.0 is not on the registry. Falling through. See wasmer/PUBLISH-NOT_RUN.md.", + "[run-decide] COMPASS_WASMER_REGISTRY_ONLY=1 \u2014 not falling through" + ], + "publish_state_ref": "wasmer/PUBLISH-NOT_RUN.md", + "faked": false, + "honest_failure_logged": true + }, + "registry_fallthrough": { + "rc": 0, + "stderr_mentions_fallthrough": true, + "stderr_mentions_webc": true, + "selected_model_version_id": "urn:mg:model:cheap" + }, + "errors": [], + "ok": true, + "captured_at": "2026-09-18T22:20:56Z" +} diff --git a/test-results/s-desktop-mobile/desktop-guards.txt b/test-results/s-desktop-mobile/desktop-guards.txt new file mode 100644 index 0000000..d03da38 --- /dev/null +++ b/test-results/s-desktop-mobile/desktop-guards.txt @@ -0,0 +1,36 @@ +# Desktop B3 guards — captured 2026-09-08T03:46:19Z + +Commands run from repo root with .venv/bin/python. Full pytest was not run. + +$ python scripts/wasmer_desktop_packaged.py +exit=0 +ok: true +errors: [] +registry_by_name: grade=PARTIAL runtime=NOT_RUN +evidence: test-results/s-desktop-mobile/desktop-evidence.json + +$ python scripts/wasmer_parity.py +exit=0 +ok: true +errors: [] +note: this script still shells `wasmer run` on wasmer/artifacts/compass-decide.wasm (Python vs loose wasm). Not changed. + +$ python scripts/wasmer_size_budget.py +exit=0 +ok: true +browser_cdylib_bytes=103980 budget_bytes=150000 +SHA256SUMS: all MATCH (compass-decide.wasm, compass_core.wasm, compass_core_bg.wasm, eni6ma demo wasm) +note: this script rewrites test-results/j-wasmer-packaging/size-budget.json. That file was already dirty in the working tree from another stage and was left uncommitted here. + +$ python -m pytest tests/test_wasmer_desktop_packaged.py tests/test_wasmer_parity.py tests/test_wasmer_size_budget.py -q +.... [100%] +exit=0 +passed=4 failed=0 + +Fresh `wasmer package build` on this tree is reproducible with itself +(sha256 ee7fab4d7ac38a6519e9207a7b138d05a487a1b46fc758c9c3d3f0eb3637c7b5). +Unpacked module bytes still MATCH B1 SHA256SUMS / the B1 webc payload +(cmp modules/compass-decide and modules/compass-core). The outer .webc +wrapper digest can differ from wasmer/artifacts/PACKAGE-DIGESTS.json +without moving the trust root; do not treat a wrapper-fingerprint drift +as a module change. diff --git a/test-results/s-desktop-mobile/desktop-packaged-vs-loose.txt b/test-results/s-desktop-mobile/desktop-packaged-vs-loose.txt new file mode 100644 index 0000000..1f5ffbf --- /dev/null +++ b/test-results/s-desktop-mobile/desktop-packaged-vs-loose.txt @@ -0,0 +1,16 @@ +# packaged .webc vs loose wasm — captured 2026-09-18T22:20:56Z +# wasmer: wasmer 7.4.0 +# script: wasmer/desktop/run-decide.sh + +## fixture_min +webc_rc=0 wasm_rc=0 parsed=IDENTICAL +packaged: {"abi_max": "1.999.0", "abi_min": "1.0.0", "constraints_applied": [], "decided_at": "2026-09-05T00:00:00Z", "default_reason": null, "fail_open": false, "lambda": 1.0, "module_version": "0.1.0", "rationale": "highest score under quality\u2212\u03bb\u00b7cost", "score": 0.6, "scores": {"urn:mg:model:cheap": 0.6, "urn:mg:model:pricey": 0.25}, "selected_model_version_id": "urn:mg:model:cheap", "task_class_id": "code_generation"} +loose: {"abi_max": "1.999.0", "abi_min": "1.0.0", "constraints_applied": [], "decided_at": "2026-09-05T00:00:00Z", "default_reason": null, "fail_open": false, "lambda": 1.0, "module_version": "0.1.0", "rationale": "highest score under quality\u2212\u03bb\u00b7cost", "score": 0.6, "scores": {"urn:mg:model:cheap": 0.6, "urn:mg:model:pricey": 0.25}, "selected_model_version_id": "urn:mg:model:cheap", "task_class_id": "code_generation"} + +## fail_open_missing +webc_rc=0 wasm_rc=0 parsed=IDENTICAL +packaged: {"abi_max": "1.999.0", "abi_min": "1.0.0", "constraints_applied": [], "decided_at": "2026-09-05T00:00:00Z", "default_reason": "snapshot_missing", "fail_open": true, "lambda": 1.0, "module_version": "0.1.0", "rationale": "fail-open: snapshot_missing", "score": -0.5, "scores": {"default": -0.5}, "selected_model_version_id": "default", "task_class_id": "general"} +loose: {"abi_max": "1.999.0", "abi_min": "1.0.0", "constraints_applied": [], "decided_at": "2026-09-05T00:00:00Z", "default_reason": "snapshot_missing", "fail_open": true, "lambda": 1.0, "module_version": "0.1.0", "rationale": "fail-open: snapshot_missing", "score": -0.5, "scores": {"default": -0.5}, "selected_model_version_id": "default", "task_class_id": "general"} + +errors: [] +ok: True diff --git a/test-results/s-desktop-mobile/desktop-registry.txt b/test-results/s-desktop-mobile/desktop-registry.txt new file mode 100644 index 0000000..2470e72 --- /dev/null +++ b/test-results/s-desktop-mobile/desktop-registry.txt @@ -0,0 +1,17 @@ +# registry-by-name — captured 2026-09-18T22:20:56Z +# Grade: PARTIAL (code present). Runtime: NOT_RUN. Not faked. +rc=1 +stdout_empty=True +stderr: +[run-decide] trying registry-by-name: wasmer run compass/decide@0.1.0 +[run-decide] publish state is NOT_RUN until wasmer/PUBLISH-NOT_RUN.md flips; this hop is expected to fail until a human wasmer login + namespace claim + wasmer publish . +[run-decide] source=registry input=compass/decide@0.1.0 +error: Unable to find "compass/decide@^0.1.0" in the registry +╰─▶ 1: failed to query package 'compass/decide@^0.1.0': not found +[run-decide] source=registry failed rc=1 input=compass/decide@0.1.0 +[run-decide] registry-by-name PARTIAL (code present) / runtime NOT_RUN: compass/decide@0.1.0 is not on the registry. Falling through. See wasmer/PUBLISH-NOT_RUN.md. +[run-decide] COMPASS_WASMER_REGISTRY_ONLY=1 — not falling through + +fallthrough_rc=0 +fallthrough_logged=True +fallthrough_source_webc=True diff --git a/test-results/s-desktop-mobile/mobile-android.json b/test-results/s-desktop-mobile/mobile-android.json new file mode 100644 index 0000000..5d9f08d --- /dev/null +++ b/test-results/s-desktop-mobile/mobile-android.json @@ -0,0 +1,9 @@ +{ + "ok": false, + "grade": "NOT_RUN", + "reason": "Android SDK missing", + "host": "android-webview", + "device": false, + "emulator": false, + "compile": false +} diff --git a/test-results/s-desktop-mobile/mobile-glue-check.json b/test-results/s-desktop-mobile/mobile-glue-check.json new file mode 100644 index 0000000..b7dfdf1 --- /dev/null +++ b/test-results/s-desktop-mobile/mobile-glue-check.json @@ -0,0 +1,55 @@ +{ + "kind": "mobile-shared-glue", + "not_a_device_run": true, + "artifact": "wasmer/artifacts/compass_core_bg.wasm", + "expected_sha256": "9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db", + "pin_file": "9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db", + "actual_sha256": "9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db", + "size_bytes": 103980, + "ok": true, + "glue": { + "ok": true, + "expected_sha256": "9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db", + "actual_sha256": "9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db", + "digest_match": true, + "js_digest": "sha-256", + "import_table": "empty", + "fixture_min": { + "abi_max": "1.999.0", + "abi_min": "1.0.0", + "constraints_applied": [], + "decided_at": "2026-09-05T00:00:00Z", + "default_reason": null, + "fail_open": false, + "lambda": 1, + "module_version": "0.1.0", + "rationale": "highest score under quality−λ·cost", + "score": 0.6, + "scores": { + "urn:mg:model:cheap": 0.6, + "urn:mg:model:pricey": 0.25 + }, + "selected_model_version_id": "urn:mg:model:cheap", + "task_class_id": "code_generation" + }, + "missing": { + "abi_max": "1.999.0", + "abi_min": "1.0.0", + "constraints_applied": [], + "decided_at": "2026-09-05T00:00:00Z", + "default_reason": "snapshot_missing", + "fail_open": true, + "lambda": 1, + "module_version": "0.1.0", + "rationale": "fail-open: snapshot_missing", + "score": -0.5, + "scores": { + "default": -0.5 + }, + "selected_model_version_id": "default", + "task_class_id": "general" + }, + "error": null + }, + "error": null +} diff --git a/test-results/s-desktop-mobile/mobile-hosts-summary.json b/test-results/s-desktop-mobile/mobile-hosts-summary.json new file mode 100644 index 0000000..cd74f98 --- /dev/null +++ b/test-results/s-desktop-mobile/mobile-hosts-summary.json @@ -0,0 +1,25 @@ +{ + "overall": "PARTIAL", + "full_requires_physical_device": true, + "ios": { + "grade": "PARTIAL", + "host": "ios-wkwebview-simulator", + "evidence": "mobile-ios-simulator.json" + }, + "android": { + "grade": "NOT_RUN", + "reason": "Android SDK missing", + "evidence": "mobile-android.json" + }, + "shared_glue": { + "grade": "glue-only", + "not_a_device_run": true, + "evidence": "mobile-glue-check.json" + }, + "size_budget": { + "ok": true, + "browser_cdylib_bytes": 103980, + "sha256": "9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db", + "evidence": "mobile-size-budget.json" + } +} diff --git a/test-results/s-desktop-mobile/mobile-ios-sim-pick.err.txt b/test-results/s-desktop-mobile/mobile-ios-sim-pick.err.txt new file mode 100644 index 0000000..7ab48d1 --- /dev/null +++ b/test-results/s-desktop-mobile/mobile-ios-sim-pick.err.txt @@ -0,0 +1,2 @@ +iPhone 16 Pro +com.apple.CoreSimulator.SimRuntime.iOS-18-6 diff --git a/test-results/s-desktop-mobile/mobile-ios-simulator.json b/test-results/s-desktop-mobile/mobile-ios-simulator.json new file mode 100644 index 0000000..0e5e316 --- /dev/null +++ b/test-results/s-desktop-mobile/mobile-ios-simulator.json @@ -0,0 +1,54 @@ +{ + "ok": true, + "grade": "PARTIAL", + "simulator_udid": "78A230B2-6D84-44B4-8838-BA91D49CCE2B", + "host": "ios-wkwebview-simulator", + "device": false, + "emulator": true, + "run_path": "simctl-install-launch", + "js_report": { + "actual_sha256": "9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db", + "digest_match": true, + "error": null, + "expected_sha256": "9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db", + "fixture_min": { + "abi_max": "1.999.0", + "abi_min": "1.0.0", + "constraints_applied": [], + "decided_at": "2026-09-05T00:00:00Z", + "default_reason": null, + "fail_open": false, + "lambda": 1, + "module_version": "0.1.0", + "rationale": "highest score under quality\u2212\u03bb\u00b7cost", + "score": 0.6, + "scores": { + "urn:mg:model:cheap": 0.6, + "urn:mg:model:pricey": 0.25 + }, + "selected_model_version_id": "urn:mg:model:cheap", + "task_class_id": "code_generation" + }, + "import_table": "empty", + "js_digest": "skipped_no_subtle", + "missing": { + "abi_max": "1.999.0", + "abi_min": "1.0.0", + "constraints_applied": [], + "decided_at": "2026-09-05T00:00:00Z", + "default_reason": "snapshot_missing", + "fail_open": true, + "lambda": 1, + "module_version": "0.1.0", + "rationale": "fail-open: snapshot_missing", + "score": -0.5, + "scores": { + "default": -0.5 + }, + "selected_model_version_id": "default", + "task_class_id": "general" + }, + "ok": true + }, + "reason": null +} diff --git a/test-results/s-desktop-mobile/mobile-size-budget.json b/test-results/s-desktop-mobile/mobile-size-budget.json new file mode 100644 index 0000000..9d9a5e5 --- /dev/null +++ b/test-results/s-desktop-mobile/mobile-size-budget.json @@ -0,0 +1,7 @@ +{ + "ok": true, + "budget_bytes": 150000, + "browser_cdylib_bytes": 103980, + "sha256": "9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db", + "errors": [] +} diff --git a/test-results/t-validation/README.md b/test-results/t-validation/README.md new file mode 100644 index 0000000..684e8b6 --- /dev/null +++ b/test-results/t-validation/README.md @@ -0,0 +1,28 @@ +# t-validation — cross-cutting proof capture + +**Date:** 2026-09-07 PT (`TS=20260907-212310`) +**Branch:** `feat/consolidation-and-wasmer` @ `305609a` (plus this commit) +**Scope:** plan todo `validation` only. No feature re-implementation. + +**Proof report (read this):** [`../PROOF-consolidation-wasmer-20260907.md`](../PROOF-consolidation-wasmer-20260907.md) + +Raw `.log.txt` transcripts are gitignored (`.gitignore` line 15). The committed `.txt` / `.json` files below quote the same runs. + +## This-run artifacts + +| File | What | +|---|---| +| `pytest-schema.txt` | `tests/test_schema.py` — 10 passed | +| `pytest-full.txt` | default `.venv` full suite — **185 passed** | +| `pytest-paid-sync-default.txt` | 5 passed (local fallback; compressor not importable) | +| `pytest-paid-sync-compressor-venv.txt` | sibling engine FAIL — `AttributeError: lineage` — **NOT_FIXED** | +| `pytest-credential-boundary.txt` | 12 passed — CURSOR_API_KEY stays Probe-only | +| `sync-schema-check.txt` | three schema copies, one digest | +| `wasmer-size-budget.txt` | 103980 ≤ 150000; SHA256SUMS match | +| `wasmer-parity.txt` | Python vs wasm, including fail-open | +| `desktop-smoke.txt` | `run-decide.sh` local `.webc` → `urn:mg:model:cheap` | +| `desktop-fail-open.txt` | `COMPASS_FAIL_OPEN_DEMO=missing` → `snapshot_missing` | +| `wasmer-whoami.txt` | still not logged in | +| `evidence.json` | machine-readable grades | + +Playwright browser smoke and iOS Simulator were **not** re-run here. Cite [`../r-browser-sdk/`](../r-browser-sdk/README.md) and [`../s-desktop-mobile/`](../s-desktop-mobile/README.md). diff --git a/test-results/t-validation/TS.txt b/test-results/t-validation/TS.txt new file mode 100644 index 0000000..38688f2 --- /dev/null +++ b/test-results/t-validation/TS.txt @@ -0,0 +1 @@ +20260907-212310 diff --git a/test-results/t-validation/desktop-fail-open.txt b/test-results/t-validation/desktop-fail-open.txt new file mode 100644 index 0000000..61ce167 --- /dev/null +++ b/test-results/t-validation/desktop-fail-open.txt @@ -0,0 +1,10 @@ +# COMPASS_FAIL_OPEN_DEMO=missing ./wasmer/desktop/run-decide.sh +# captured 2026-09-07 PT (TS 20260907-212310) +# exit=0 + +## stderr +[run-decide] using existing webc compass-decide-0.1.0.webc +[run-decide] source=webc input=compass-decide-0.1.0.webc + +## stdout +{"abi_max":"1.999.0","abi_min":"1.0.0","constraints_applied":[],"decided_at":"2026-09-05T00:00:00Z","default_reason":"snapshot_missing","fail_open":true,"lambda":1.0,"module_version":"0.1.0","rationale":"fail-open: snapshot_missing","score":-0.5,"scores":{"default":-0.5},"selected_model_version_id":"default","task_class_id":"general"} diff --git a/test-results/t-validation/desktop-smoke.txt b/test-results/t-validation/desktop-smoke.txt new file mode 100644 index 0000000..5c29925 --- /dev/null +++ b/test-results/t-validation/desktop-smoke.txt @@ -0,0 +1,18 @@ +# ./wasmer/desktop/run-decide.sh +# captured 2026-09-07 PT (TS 20260907-212310) +# exit=0 +# default hop: local .webc (not registry) + +## stderr +[run-decide] using existing webc compass-decide-0.1.0.webc +[run-decide] source=webc input=compass-decide-0.1.0.webc + +## stdout (decide envelope) +{"abi_max":"1.999.0","abi_min":"1.0.0","constraints_applied":[],"decided_at":"2026-09-05T00:00:00Z","default_reason":null,"fail_open":false,"lambda":1.0,"module_version":"0.1.0","rationale":"highest score under quality−λ·cost","score":0.6,"scores":{"urn:mg:model:cheap":0.6,"urn:mg:model:pricey":0.25},"selected_model_version_id":"urn:mg:model:cheap","task_class_id":"code_generation"} + +## package fingerprint (outer .webc; not the module trust root) +# repo-root compass-decide-0.1.0.webc 258019 bytes +# sha256=ee7fab4d7ac38a6519e9207a7b138d05a487a1b46fc758c9c3d3f0eb3637c7b5 +# B1 recorded fingerprint fe2dfc91… is the q-wasmer-publish copy; the wrapper +# digest moves when wasmer/README.md or LICENSE is edited (see PUBLISH-NOT_RUN.md). +# Module digests still MATCH wasmer/artifacts/SHA256SUMS. diff --git a/test-results/t-validation/evidence.json b/test-results/t-validation/evidence.json new file mode 100644 index 0000000..80c5322 --- /dev/null +++ b/test-results/t-validation/evidence.json @@ -0,0 +1,69 @@ +{ + "stage": "t-validation", + "plan_todo": "validation", + "branch": "feat/consolidation-and-wasmer", + "head_at_capture": "305609a", + "captured_at": "2026-09-08T04:23:10Z", + "captured_at_pt": "2026-09-07T21:23:10-07:00", + "ts": "20260907-212310", + "playwright_rerun": false, + "ios_simulator_rerun": false, + "android_rerun": false, + "fixes_applied": [], + "convergence": { + "offline_local": "CONVERGED", + "registry_publish": "NOT_RUN", + "android": "NOT_RUN", + "overall_program": "PARTIAL" + }, + "this_run": { + "pytest_schema": {"passed": 10, "failed": 0, "exit": 0}, + "pytest_full_default_venv": {"passed": 185, "failed": 0, "skipped": 0, "exit": 0}, + "sync_schema_check": {"exit": 0, "digest": "7fe7ea117cf40a692d9d62907a722a529cfcd437ab43873d4d8bf16d86c3bae0"}, + "wasmer_size_budget": { + "exit": 0, + "ok": true, + "browser_cdylib_bytes": 103980, + "budget_bytes": 150000 + }, + "wasmer_parity": {"exit": 0, "ok": true}, + "desktop_smoke": { + "exit": 0, + "source": "webc", + "selected_model_version_id": "urn:mg:model:cheap", + "fail_open": false + }, + "desktop_fail_open_demo": { + "exit": 0, + "default_reason": "snapshot_missing", + "fail_open": true + }, + "wasmer_whoami": "Not logged in registry wasmer.io", + "credential_boundary": {"passed": 12, "failed": 0} + }, + "paid_sync": { + "default_venv": {"passed": 5, "failed": 0, "note": "chat_compressor not importable; local fallback"}, + "compressor_engine_venv": { + "test": "tests/test_paid_sync.py::test_manual_compass_bundle_api_stays_free", + "result": "FAILED", + "error": "AttributeError: 'str' object has no attribute 'lineage'", + "location": "../comPREssOR/engine/src/chat_compressor/bundle.py:117", + "grade": "NOT_FIXED", + "scope": "ADR 0002/0003 — sibling engine, not this repo" + } + }, + "webc": { + "root_bytes": 258019, + "root_sha256": "ee7fab4d7ac38a6519e9207a7b138d05a487a1b46fc758c9c3d3f0eb3637c7b5", + "b1_recorded_sha256": "fe2dfc91893d692cb350ae92dee38a6c71bd669fd1fd847359f0fab2ffe8b5d9", + "note": "Outer wrapper digest is a build fingerprint (embeds readme/license). Module digests MATCH SHA256SUMS." + }, + "stage_grades": { + "A1-A3": "FULL", + "B1_publish": "NOT_RUN", + "B1_local_webc": "FULL", + "B2_browser": "PARTIAL", + "B3_desktop": "PARTIAL", + "B4_mobile": "PARTIAL" + } +} diff --git a/test-results/t-validation/pytest-credential-boundary.txt b/test-results/t-validation/pytest-credential-boundary.txt new file mode 100644 index 0000000..1de7f18 --- /dev/null +++ b/test-results/t-validation/pytest-credential-boundary.txt @@ -0,0 +1,23 @@ +# python -m pytest tests/test_credential_boundary.py -v -o addopts= +# captured 2026-09-07 PT (TS 20260907-212310) +# exit=0 +# Locked invariant: CURSOR_API_KEY is Probe-only; core/route/serve/wasmer +# do not import compass.probe.credentials. Hook path is the sibling +# engine (hook_cli.py: "Never requires CURSOR_API_KEY"). + +collected 12 items + +tests/test_credential_boundary.py::test_env_example_has_placeholders_only PASSED +tests/test_credential_boundary.py::test_core_route_serve_sources_do_not_import_credentials PASSED +tests/test_credential_boundary.py::test_wasmer_tree_has_no_credential_module_refs PASSED +tests/test_credential_boundary.py::test_importing_core_does_not_load_credentials_module PASSED +tests/test_credential_boundary.py::test_importing_route_does_not_load_credentials_module PASSED +tests/test_credential_boundary.py::test_importing_serve_proxy_does_not_load_credentials_module PASSED +tests/test_credential_boundary.py::test_loaders_resolve_from_env PASSED +tests/test_credential_boundary.py::test_loaders_return_none_when_unset PASSED +tests/test_credential_boundary.py::test_forbidden_caller_refuses PASSED +tests/test_credential_boundary.py::test_audit_presence_redacted PASSED +tests/test_credential_boundary.py::test_core_route_sources_do_not_reference_provider_env_names PASSED +tests/test_credential_boundary.py::test_docs_probe_credentials_exist PASSED + +12 passed in 0.15s diff --git a/test-results/t-validation/pytest-full.txt b/test-results/t-validation/pytest-full.txt new file mode 100644 index 0000000..aab1e63 --- /dev/null +++ b/test-results/t-validation/pytest-full.txt @@ -0,0 +1,13 @@ +# python -m pytest (addopts -q from pyproject.toml) +# captured 2026-09-07 PT (TS 20260907-212310) +# interpreter: .venv/bin/python (Python 3.13.7) +# exit=0 +# +# chat_compressor is NOT importable in this venv (no safetensors), so +# compass.bundle.export_bundle uses the local fallback. The sibling-engine +# failure is reproduced separately in pytest-paid-sync-compressor-venv.txt. + +........................................................................ [ 38%] +........................................................................ [ 77%] +......................................... [100%] +185 passed in 4.01s diff --git a/test-results/t-validation/pytest-paid-sync-compressor-venv.txt b/test-results/t-validation/pytest-paid-sync-compressor-venv.txt new file mode 100644 index 0000000..a1d9dcb --- /dev/null +++ b/test-results/t-validation/pytest-paid-sync-compressor-venv.txt @@ -0,0 +1,32 @@ +# Diagnostic only — not the default comPASS pytest. +# PYTHONPATH=src:../comPREssOR/engine/src +# interpreter: ../comPREssOR/engine/.venv/bin/python (Python 3.12.11) +# pytest tests/test_paid_sync.py::test_manual_compass_bundle_api_stays_free -v --tb=short +# captured 2026-09-07 PT (TS 20260907-212310) +# exit=1 +# +# Graded NOT_FIXED / out of scope (ADR 0002/0003). This tree does not edit +# the sibling engine. Do not "fix" tests/test_paid_sync.py for this. + +=== default comPASS venv + PYTHONPATH engine/src === +ModuleNotFoundError: No module named 'safetensors' +(chat_compressor.__init__ → handle → store → safetensors) + +=== engine venv import === +file ../comPREssOR/engine/src/chat_compressor/bundle.py + +=== engine venv + compass src pytest === +collected 1 item + +tests/test_paid_sync.py::test_manual_compass_bundle_api_stays_free FAILED + +tests/test_paid_sync.py:60: in test_manual_compass_bundle_api_stays_free + export_bundle(str(src), dest=str(bundle), agent_id="free") +src/compass/bundle.py:33: in export_bundle + return _export(_graph_root, **_kwargs) +../comPREssOR/engine/src/chat_compressor/bundle.py:117: in export_bundle + lineage = store.lineage(agent_id) +E AttributeError: 'str' object has no attribute 'lineage' + +FAILED tests/test_paid_sync.py::test_manual_compass_bundle_api_stays_free +1 failed in 0.08s diff --git a/test-results/t-validation/pytest-paid-sync-default.txt b/test-results/t-validation/pytest-paid-sync-default.txt new file mode 100644 index 0000000..153e31c --- /dev/null +++ b/test-results/t-validation/pytest-paid-sync-default.txt @@ -0,0 +1,18 @@ +# python -m pytest tests/test_paid_sync.py -v --tb=short -o addopts= +# interpreter: comPASS .venv (Python 3.13.7) +# captured 2026-09-07 PT (TS 20260907-212310) +# exit=0 +# +# chat_compressor is not importable here (ModuleNotFoundError / missing +# safetensors). compass.bundle.export_bundle therefore uses the local +# free fallback. This is NOT a fix of the sibling engine. + +collected 5 items + +tests/test_paid_sync.py::test_manual_local_bundle_round_trip_without_paid_flag PASSED +tests/test_paid_sync.py::test_manual_compass_bundle_api_stays_free PASSED +tests/test_paid_sync.py::test_automated_sync_blocked_without_paid_flag PASSED +tests/test_paid_sync.py::test_automated_sync_raises_when_not_fail_open PASSED +tests/test_paid_sync.py::test_automated_round_trip_with_paid_flag PASSED + +5 passed in 0.04s diff --git a/test-results/t-validation/pytest-schema.txt b/test-results/t-validation/pytest-schema.txt new file mode 100644 index 0000000..123bf33 --- /dev/null +++ b/test-results/t-validation/pytest-schema.txt @@ -0,0 +1,13 @@ +# python -m pytest tests/test_schema.py -v +# captured 2026-09-07 PT (TS 20260907-212310) +# exit=0 + +============================= test session starts ============================== +platform darwin -- Python 3.13.7, pytest-9.1.1, pluggy-1.6.0 +rootdir: comPASS (repo root) +configfile: pyproject.toml +collected 10 items + +tests/test_schema.py .......... [100%] + +============================== 10 passed in 0.04s ============================== diff --git a/test-results/t-validation/size-budget-20260907-212310.json b/test-results/t-validation/size-budget-20260907-212310.json new file mode 100644 index 0000000..84fd466 --- /dev/null +++ b/test-results/t-validation/size-budget-20260907-212310.json @@ -0,0 +1,23 @@ +{ + "ok": true, + "budget_bytes": 150000, + "sizes": { + "compass-decide.wasm": 135419, + "compass_core.wasm": 103980, + "compass_core_bg.wasm": 103980, + "eni6ma/demo-wasm/v1/eni6ma_wasm.wasm": 600779 + }, + "sha256_expected": { + "compass-decide.wasm": "e77301bed6f3bcdf8541ba7256cb6a4e58e1da62d7a98edb52fa27bdc1fee553", + "compass_core.wasm": "9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db", + "compass_core_bg.wasm": "9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db", + "eni6ma/demo-wasm/v1/eni6ma_wasm.wasm": "853717e421a36fc93d0791d3f2718ecf3e9c449fb3c60d4084dedab3af75c389" + }, + "sha256_actual": { + "compass-decide.wasm": "e77301bed6f3bcdf8541ba7256cb6a4e58e1da62d7a98edb52fa27bdc1fee553", + "compass_core.wasm": "9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db", + "compass_core_bg.wasm": "9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db", + "eni6ma/demo-wasm/v1/eni6ma_wasm.wasm": "853717e421a36fc93d0791d3f2718ecf3e9c449fb3c60d4084dedab3af75c389" + }, + "errors": [] +} diff --git a/test-results/t-validation/sync-schema-check.txt b/test-results/t-validation/sync-schema-check.txt new file mode 100644 index 0000000..d1355cc --- /dev/null +++ b/test-results/t-validation/sync-schema-check.txt @@ -0,0 +1,8 @@ +# python scripts/sync_schema.py --check +# captured 2026-09-07 PT (TS 20260907-212310) +# exit=0 + +canonical src/compass/schema/model-graph.v1.json sha256=7fe7ea117cf40a692d9d62907a722a529cfcd437ab43873d4d8bf16d86c3bae0 +[PASS] in sync schema/model-graph.v1.json +[PASS] in sync docs/schema/model-graph.v1.json +[PASS] all model-graph.v1.json copies share one digest diff --git a/test-results/t-validation/wasmer-parity.txt b/test-results/t-validation/wasmer-parity.txt new file mode 100644 index 0000000..5ec34d8 --- /dev/null +++ b/test-results/t-validation/wasmer-parity.txt @@ -0,0 +1,12 @@ +# python scripts/wasmer_parity.py +# captured 2026-09-07 PT (TS 20260907-212310) +# exit=0 + +core decide fail-open: snapshot_missing → default +core decide fail-open: snapshot_corrupt → default +core decide fail-open: no_candidates → default +{ + "ok": true, + "errors": [], + "evidence": "test-results/wasmer-parity/parity.json" +} diff --git a/test-results/t-validation/wasmer-size-budget.txt b/test-results/t-validation/wasmer-size-budget.txt new file mode 100644 index 0000000..34dcf4f --- /dev/null +++ b/test-results/t-validation/wasmer-size-budget.txt @@ -0,0 +1,32 @@ +# python scripts/wasmer_size_budget.py +# captured 2026-09-07 PT (TS 20260907-212310) +# exit=0 +# also writes test-results/j-wasmer-packaging/size-budget.json +# (this run added the eni6ma Path-B digest-only entry that SHA256SUMS already lists) + +{ + "ok": true, + "budget_bytes": 150000, + "sizes": { + "compass-decide.wasm": 135419, + "compass_core.wasm": 103980, + "compass_core_bg.wasm": 103980, + "eni6ma/demo-wasm/v1/eni6ma_wasm.wasm": 600779 + }, + "sha256_expected": { + "compass-decide.wasm": "e77301bed6f3bcdf8541ba7256cb6a4e58e1da62d7a98edb52fa27bdc1fee553", + "compass_core.wasm": "9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db", + "compass_core_bg.wasm": "9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db", + "eni6ma/demo-wasm/v1/eni6ma_wasm.wasm": "853717e421a36fc93d0791d3f2718ecf3e9c449fb3c60d4084dedab3af75c389" + }, + "sha256_actual": { + "compass-decide.wasm": "e77301bed6f3bcdf8541ba7256cb6a4e58e1da62d7a98edb52fa27bdc1fee553", + "compass_core.wasm": "9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db", + "compass_core_bg.wasm": "9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db", + "eni6ma/demo-wasm/v1/eni6ma_wasm.wasm": "853717e421a36fc93d0791d3f2718ecf3e9c449fb3c60d4084dedab3af75c389" + }, + "errors": [] +} + +browser cdylib compass_core_bg.wasm: 103980 bytes <= 150000 +digest-as-trust-root: sha256_actual == sha256_expected for every SHA256SUMS key diff --git a/test-results/t-validation/wasmer-whoami.txt b/test-results/t-validation/wasmer-whoami.txt new file mode 100644 index 0000000..3f7a50a --- /dev/null +++ b/test-results/t-validation/wasmer-whoami.txt @@ -0,0 +1,5 @@ +# wasmer --version && wasmer whoami +# captured 2026-09-07 PT (TS 20260907-212310) + +wasmer 7.4.0 +error: Not logged in registry wasmer.io diff --git a/tests/test_schema.py b/tests/test_schema.py index 43c5e24..014e5ee 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import json from pathlib import Path @@ -16,6 +17,21 @@ package_schema_path, ) +# Canonical schema lives at src/compass/schema/model-graph.v1.json — loader.py reads it +# at runtime and it is the only copy packaged into the sdist/wheel. These two are +# generated mirrors, written by scripts/sync_schema.py and never edited by hand. +MIRROR_RELPATHS = ("schema/model-graph.v1.json", "docs/schema/model-graph.v1.json") + + +def _repo_root() -> Path | None: + """Repo checkout root, or None when tests run against an installed distribution.""" + root = Path(__file__).resolve().parents[1] + return root if (root / "pyproject.toml").is_file() else None + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + def test_package_schema_exists_and_loads(): path = package_schema_path() @@ -57,14 +73,52 @@ def test_node_kinds_match_contract(): assert "TaskClass" in NODE_KINDS -def test_docs_mirror_matches_package(tmp_path: Path): - """Repo docs/schema and packaged schema should stay in sync when both present.""" +def test_canonical_schema_is_the_packaged_copy(): + """The canonical file must stay inside the package so it ships in the wheel.""" + packaged = package_schema_path().resolve() + assert packaged.parent.name == "schema" + assert packaged.parent.parent.name == "compass" + root = _repo_root() + if root is None: + pytest.skip("not a repo checkout; canonical path check needs the source tree") + assert packaged == (root / "src" / "compass" / "schema" / "model-graph.v1.json").resolve() + + +def test_docs_mirror_matches_package(): + """Repo and docs mirrors must parse equal to the packaged canonical schema.""" + root = _repo_root() + if root is None: + pytest.skip("not a repo checkout; mirrors are not packaged") packaged = json.loads(package_schema_path().read_text(encoding="utf-8")) - docs = Path("docs/schema/model-graph.v1.json") - repo = Path("schema/model-graph.v1.json") - for mirror in (docs, repo): - if mirror.exists(): - assert json.loads(mirror.read_text(encoding="utf-8")) == packaged + for relpath in MIRROR_RELPATHS: + mirror = root / relpath + assert mirror.is_file(), f"missing mirror {relpath} — run: python scripts/sync_schema.py" + assert json.loads(mirror.read_text(encoding="utf-8")) == packaged, ( + f"{relpath} differs from canonical — run: python scripts/sync_schema.py" + ) + + +def test_schema_mirrors_have_no_checksum_drift(): + """Byte-exact guard: all three copies must share one sha256. + + Semantic equality is not enough — reformatting a mirror would silently make the + three files diverge on disk while still parsing equal, and consumers that pin the + digest (page-recall manifest, SHA256SUMS) would then disagree with the wheel. + """ + root = _repo_root() + if root is None: + pytest.skip("not a repo checkout; mirrors are not packaged") + canonical = root / "src" / "compass" / "schema" / "model-graph.v1.json" + assert canonical.is_file(), "canonical schema missing from src/compass/schema/" + digests = {"src/compass/schema/model-graph.v1.json": _sha256(canonical)} + for relpath in MIRROR_RELPATHS: + mirror = root / relpath + assert mirror.is_file(), f"missing mirror {relpath} — run: python scripts/sync_schema.py" + digests[relpath] = _sha256(mirror) + assert len(set(digests.values())) == 1, ( + "model-graph.v1.json checksum drift — run: python scripts/sync_schema.py\n" + + "\n".join(f" {d} {p}" for p, d in digests.items()) + ) def test_supersede_closes_old_opens_new(): diff --git a/tests/test_wasmer_desktop_packaged.py b/tests/test_wasmer_desktop_packaged.py new file mode 100644 index 0000000..1987ae0 --- /dev/null +++ b/tests/test_wasmer_desktop_packaged.py @@ -0,0 +1,40 @@ +"""Desktop B3: packaged .webc vs loose-artifact decide envelopes. + +scripts/wasmer_parity.py remains the Python-vs-raw-wasm guard and still shells +`wasmer run` on wasmer/artifacts/compass-decide.wasm. This module only checks +that run-decide.sh's webc hop and wasm hop agree. +""" +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +WASM = ROOT / "wasmer" / "artifacts" / "compass-decide.wasm" +SCRIPT = ROOT / "scripts" / "wasmer_desktop_packaged.py" +SHELL = ROOT / "wasmer" / "desktop" / "run-decide.sh" + + +@pytest.mark.skipif(shutil.which("wasmer") is None, reason="wasmer CLI not installed") +@pytest.mark.skipif(not WASM.is_file(), reason="compass-decide.wasm not built") +def test_desktop_packaged_webc_matches_loose_wasm(): + py = ROOT / ".venv" / "bin" / "python" + exe = str(py) if py.is_file() else sys.executable + proc = subprocess.run([exe, str(SCRIPT)], cwd=str(ROOT), capture_output=True, text=True) + assert proc.returncode == 0, proc.stdout + "\n" + proc.stderr + evidence = ROOT / "test-results" / "s-desktop-mobile" / "desktop-evidence.json" + assert evidence.is_file() + + +def test_run_decide_shell_is_executable(): + assert SHELL.is_file() + assert os.access(SHELL, os.X_OK) + text = SHELL.read_text(encoding="utf-8") + assert "wasmer package build" in text + assert "PUBLISH-NOT_RUN" in text + assert "wasmer/artifacts/compass-decide.wasm" in text diff --git a/wasmer.toml b/wasmer.toml new file mode 100644 index 0000000..e8b19cd --- /dev/null +++ b/wasmer.toml @@ -0,0 +1,49 @@ +# comPASS Wasmer package manifest — canonical, repo root (Track J / plan B1). +# +# Registry publish is the primary distribution path. One package carries both +# execution surfaces so desktop and browser resolve the same bytes: +# - compass-decide WASI binary, `wasmer run compass/decide` +# - compass-core wasm32-unknown-unknown cdylib for the browser sandbox +# Local `wasmer run wasmer/artifacts/compass-decide.wasm` via +# wasmer/desktop/run-decide.sh remains the offline/air-gap fallback. +# +# Digest-as-trust-root (ADR 0005): both module sources are pinned in +# wasmer/artifacts/SHA256SUMS, and compass_core_bg.wasm additionally in +# wasmer/artifacts/pins.json. Publishing must not alter those bytes; reconcile +# with `python scripts/wasmer_size_budget.py` before and after any publish. +# Package-level digests: wasmer/artifacts/PACKAGE-DIGESTS.json. +# +# Publish state: NOT_RUN — see wasmer/PUBLISH-NOT_RUN.md. + +[package] +name = "compass/decide" +version = "0.1.0" +description = "comPASS Route+Graph decide — WASI binary plus browser cdylib, no provider keys" +license = "Apache-2.0" +license-file = "LICENSE" +readme = "wasmer/README.md" +repository = "https://github.com/soltrinox/comPASS" + +# WASI decide binary. Reads a host-supplied snapshot, emits a decide envelope. +[[module]] +name = "compass-decide" +source = "wasmer/artifacts/compass-decide.wasm" +abi = "wasi" + +# Browser Route+Graph read core (wasm-bindgen cdylib, empty import table). +# No command: instantiated by the host page / @wasmer/sdk, not run standalone. +[[module]] +name = "compass-core" +source = "wasmer/artifacts/compass_core_bg.wasm" +abi = "none" + +[[command]] +name = "compass-decide" +module = "compass-decide" +runner = "wasi" + +# Guest path matches the `--volume "$PWD/wasmer:/wasmer"` mapping used by +# run-decide.sh and scripts/wasmer_parity.py, so `--snapshot +# /wasmer/fixtures/snapshot_min.json` behaves identically local and published. +[fs] +"/wasmer/fixtures" = "wasmer/fixtures" diff --git a/wasmer/PUBLISH-NOT_RUN.md b/wasmer/PUBLISH-NOT_RUN.md new file mode 100644 index 0000000..434640f --- /dev/null +++ b/wasmer/PUBLISH-NOT_RUN.md @@ -0,0 +1,168 @@ +# ADR-quality: Wasmer registry publish — NOT_RUN + +**Status:** NOT_RUN (2026-09-07 PT) +**Track:** J / plan B1 (registry publish) +**Manifest:** `wasmer.toml` at repo root (promoted from `wasmer/desktop/wasmer.toml`) +**Package:** `compass/decide` v0.1.0 — modules `compass-decide` (WASI) + `compass-core` (browser cdylib) + +## Decision + +Do **not** claim a published Wasmer package. Nothing was pushed to `wasmer.io`. +The manifest is promoted, validated, and built to a local `.webc` so the offline +path is complete, but the registry name `compass/decide` does not exist and this +machine cannot create it. + +Two independent blockers, both verified: + +| Blocker | Evidence | Who can clear it | +|---|---|---| +| No registry credential | `wasmer whoami` → `error: Not logged in registry wasmer.io`; `wasmer config get registry.token` → empty; no `WASMER_TOKEN` in env | Repo owner runs `wasmer login` | +| `compass` namespace unclaimed | `getNamespace(name:"compass")` → `null`, `getUser(username:"compass")` → `null`, `getPackage(name:"compass/decide")` → `null` against `https://registry.wasmer.io/graphql` (control query for `wasmer` returns a real record, so the query itself is sound) | Whoever owns the account, after login | + +Publishing under a guessed or squatted namespace was rejected. Creating an +account was out of scope for this agent. + +## Context + +- Wasmer CLI **7.4.0** is installed at `/opt/homebrew/bin/wasmer`. The tooling is + not the problem; the credential and the name are. +- `wasmer publish --dry-run .` still refuses without a token — the dry run is + server-aware, so it is not usable as an offline validator. Use + `wasmer package build --check .` instead, which does validate (verified against + a deliberately broken manifest: missing module source and bad `abi` both fail). +- `compass` is **unclaimed**, not taken. Registry search shows only unrelated + third-party packages with `compass` inside the package name + (`bagasandika121858f3/clearcompassroom` and similar), all under other users' + namespaces. No fallback namespace was needed or chosen, because the blocker is + the missing credential rather than a name collision. Claiming `compass` should + succeed once someone logs in. +- `publish = false` in `wasmer/crate/Cargo.toml` concerns crates.io and is + unrelated to this. Left as-is. + +## Consequences + +- **B3 desktop reconcile is blocked** for its primary goal. `run-decide.sh` + cannot be rewritten to `wasmer run compass/decide` against the registry. It can + be rewritten to run the local `.webc` by path, which exercises the packaged + command and the bundled fixture, with the raw `.wasm` path as the offline + fallback — but that is packaged-local, not published, and must be graded + PARTIAL until this file flips. +- **B2 browser SDK is not blocked.** `@wasmer/sdk` work does not require the + comPASS package to be in the registry; the existing pinned-digest load path + from `wasmer/artifacts/` stays authoritative. +- Release notes may advertise **locally packaged** wasm. They may not advertise a + registry package, a `wasmer run compass/decide` install line, or a registry URL. +- No fake green. Faking a publish transcript is forbidden here for the same + reason it is forbidden in `wasmer/mobile/NOT_RUN.md`. + +## What was done instead (offline, complete) + +| Step | Result | +|---|---| +| Promote manifest to repo root covering both artifacts | `wasmer.toml`, modules `compass-decide` (`abi = "wasi"`) + `compass-core` (`abi = "none"`) | +| Manifest validation | `wasmer package build --check .` → exit 0 | +| Local package build | `wasmer package build -o …/compass-decide-0.1.0.webc .` → 258019 bytes | +| Package digest | `fe2dfc91893d692cb350ae92dee38a6c71bd669fd1fd847359f0fab2ffe8b5d9` | +| Reproducibility | Repeated builds from an unchanged tree produce a byte-identical `.webc` | +| Digest reconciliation | Both embedded modules match `wasmer/artifacts/SHA256SUMS` exactly (ADR 0005 trust root intact) | +| Packaged execution | `wasmer run -- --request … --snapshot /wasmer/fixtures/snapshot_min.json` emits a decide envelope byte-identical to the loose-artifact run | + +Digests recorded in [`artifacts/PACKAGE-DIGESTS.json`](artifacts/PACKAGE-DIGESTS.json). +Stage evidence in `test-results/q-wasmer-publish/`. + +One caveat on that package digest: the `.webc` embeds the contents of +`[package].readme` and `license-file`, so editing `wasmer/README.md` or `LICENSE` +moves it even when no module byte changes. Treat it as a build fingerprint. The +two module digests are the stable trust root and are the ones that must reconcile +against `SHA256SUMS`. + +## Pre-publish hygiene finding (fix before the first publish) + +Both committed `.wasm` artifacts embed absolute builder paths — 17 occurrences in +`compass-decide.wasm`, 19 in `compass_core_bg.wasm`, all Rust panic-location +strings of the form `/Users//.asdf/installs/rust/1.89.0/...`. They have been +there since the initial commit and were not introduced by this stage, so nothing +here changed them. Locally they are harmless. Published, they leak the builder's +home directory to anyone who runs `wasmer package unpack`. + +The fix is a rebuild with path remapping: + +```bash +cd wasmer/crate +RUSTFLAGS="--remap-path-prefix=$HOME=/build --remap-path-prefix=$PWD=/src" \ + cargo build --release --target wasm32-wasip1 --bin compass-decide +# and the same for the wasm32-unknown-unknown --lib target +``` + +That changes both digests, so it must be its own change: rebuild, refresh +`artifacts/SHA256SUMS`, `artifacts/pins.json`, and +`artifacts/PACKAGE-DIGESTS.json` together, then re-run +`scripts/wasmer_size_budget.py` and `scripts/wasmer_parity.py`. Out of scope for +B1, which is not permitted to move the trust root. + +## Exact next steps (when unblocking) + +1. **Credential.** On a machine with the owner's Wasmer account: + ```bash + wasmer login # opens browser; writes token to $WASMER_DIR + wasmer whoami # must print the account, not "Not logged in" + ``` + For CI instead, set `WASMER_TOKEN` from a registry API token created at + `https://wasmer.io/settings/access-tokens`, and pass `--token $WASMER_TOKEN`. + The token is a secret: it belongs in CI secrets or the shell environment, + never in this repo. +2. **Namespace.** Claim `compass` under that account (Wasmer creates the + namespace on first publish into it, or create it explicitly in the web UI). + Re-verify before publishing: + ```bash + curl -s -X POST https://registry.wasmer.io/graphql \ + -H 'Content-Type: application/json' \ + -d '{"query":"{ getNamespace(name: \"compass\") { name } }"}' + ``` + If `compass` has been taken by someone else in the meantime, pick a fallback + (`/compass-decide`), change `[package].name` in the root `wasmer.toml`, + and update this file — do not publish into a namespace you do not own. +3. **Pre-flight, from the repo root:** + ```bash + python scripts/wasmer_size_budget.py # digests must still match SHA256SUMS + wasmer package build --check . + wasmer publish --dry-run . # now reaches the server + ``` +4. **Publish and record:** + ```bash + wasmer publish . | tee test-results/q-wasmer-publish/publish-$(date -u +%Y%m%d-%H%M%S).log.txt + wasmer package get compass/decide@0.1.0 + ``` + Copy the registry-reported hash into `wasmer/artifacts/PACKAGE-DIGESTS.json` + under `published`, and set `publish_state` to `PUBLISHED`. It must reconcile + against the local `.webc` digest above; if it does not, stop — that is a trust + root violation, not a formatting difference. +5. **Flip this file** to PUBLISHED with the version, date, and registry URL. + B3 already wired `wasmer/desktop/run-decide.sh` to try + `wasmer run compass/decide@0.1.0` when requested, then local `.webc`, then + the air-gap wasm path. Flipping this file is what turns that first hop from + a caught NOT_RUN into a live registry run. +6. **Repoint the stale docs** that still describe the manifest as living under + `wasmer/desktop/`: `docs/WASMER.md` line 63 and `docs/WASMER-DEPLOYMENT.md` + line 172. Left untouched here because `docs/` was owned by a parallel agent. + +## Downstream B3 (2026-09-07) + +`wasmer/desktop/run-decide.sh` now implements the three-hop order: + +1. Opt-in `wasmer run compass/decide@0.1.0` (`COMPASS_WASMER_USE_REGISTRY=1` / `--registry`) +2. Local `compass-decide-0.1.0.webc` (default; `wasmer package build` if missing) +3. Air-gap `wasmer/artifacts/compass-decide.wasm` + +A successful local `.webc` run is **not** a published-package run. Registry-by-name +stays PARTIAL / NOT_RUN until this file flips to PUBLISHED. + +## Alternatives considered + +| Option | Why deferred | +|---|---| +| Publish under a personal or invented namespace | Squats a name the project does not own; `compass/decide` in every doc would then be wrong | +| Create a Wasmer account from this agent | Out of scope; account ownership is a human decision | +| Ship the `.webc` as a GitHub release asset instead | Reasonable stopgap and does not conflict with publishing later, but it is a distribution decision for the owner, not a substitute for B1 | +| Skip the manifest work until credentials exist | Wastes the sequencing; the manifest, digests, and packaged-run proof are all verifiable offline today | +| Fake a publish transcript | Forbidden — same rule as `mobile/NOT_RUN.md` | diff --git a/wasmer/README.md b/wasmer/README.md index 02dd4ee..f16b4e7 100644 --- a/wasmer/README.md +++ b/wasmer/README.md @@ -7,7 +7,9 @@ | Python `compass.core` (wasm-boundary stand-in) | **READY** | Import-graph + fail-open tests | | Browser sandbox `.wasm` | **READY** | `artifacts/compass_core_bg.wasm` + `browser/` + headless smoke | | Desktop Wasmer embed | **READY** | `artifacts/compass-decide.wasm` + `desktop/run-decide.sh` shell | -| Mobile Wasmer | **NOT_RUN** | See `mobile/NOT_RUN.md` — same module bytes when a host exists | +| Local `.webc` package | **READY** | Root `wasmer.toml` → `wasmer package build`, both modules, reproducible | +| Wasmer registry publish | **NOT_RUN** | See `PUBLISH-NOT_RUN.md` — no credential, `compass` namespace unclaimed | +| Mobile Wasmer | **PARTIAL** | iOS Simulator host; Android NOT_RUN — `mobile/NOT_RUN.md` | ## Artifacts (hashed) @@ -46,10 +48,15 @@ wasmer/ crate/ # Rust compass-core (cdylib + WASI bin) artifacts/ # hashed .wasm outputs browser/ # CSP sandbox page + JS glue + smoke package.json - desktop/ # packaged Wasmer shell (run-decide.sh, wasmer.toml) - mobile/ # NOT_RUN ADR + next steps + desktop/ # packaged Wasmer shell (run-decide.sh) + mobile/ # iOS WKWebView + Android WebView hosts; status in NOT_RUN.md + PUBLISH-NOT_RUN.md # registry publish state + unblocking steps ``` +The package manifest is `wasmer.toml` at the **repo root** (promoted from +`desktop/` so one `compass/decide` package covers both the WASI binary and the +browser cdylib). + ## Run (Python stand-in) ```bash @@ -127,3 +134,13 @@ COMPASS_FAIL_OPEN_DEMO=corrupt ./wasmer/desktop/run-decide.sh ```bash python scripts/wasmer_size_budget.py ``` + +## Mobile hosts + +```bash +./scripts/validate-wasmer-mobile.sh +# iOS Simulator only: +./wasmer/mobile/ios/run-simulator.sh +# Android (NOT_RUN without SDK): +./wasmer/mobile/android/run-emulator.sh +``` diff --git a/wasmer/artifacts/PACKAGE-DIGESTS.json b/wasmer/artifacts/PACKAGE-DIGESTS.json new file mode 100644 index 0000000..93b53d1 --- /dev/null +++ b/wasmer/artifacts/PACKAGE-DIGESTS.json @@ -0,0 +1,51 @@ +{ + "schema": "compass-wasmer-package-digests/v1", + "notes": "Package-level digests for the root wasmer.toml. Module digests must reconcile against SHA256SUMS (ADR 0005 digest-as-trust-root). The 'published' block stays null until wasmer/PUBLISH-NOT_RUN.md flips to PUBLISHED.", + "package": { + "name": "compass/decide", + "version": "0.1.0", + "manifest": "wasmer.toml", + "publish_state": "NOT_RUN", + "publish_state_ref": "wasmer/PUBLISH-NOT_RUN.md" + }, + "local_package": { + "filename": "compass-decide-0.1.0.webc", + "sha256": "fe2dfc91893d692cb350ae92dee38a6c71bd669fd1fd847359f0fab2ffe8b5d9", + "bytes": 258019, + "built_by": "wasmer 7.4.0", + "built_at": "2026-09-07T00:00:00Z", + "reproducible": true, + "reproducibility_scope": "Byte-identical across repeated builds from an unchanged tree. The webc embeds the [package].readme and license-file contents, so editing wasmer/README.md or LICENSE changes this digest even though the module bytes do not move. Module digests below are the stable trust root; this one is a build fingerprint.", + "build_command": "wasmer package build -o test-results/q-wasmer-publish/compass-decide-0.1.0.webc .", + "committed": false, + "committed_note": "Build output, gitignored. Rebuild from the manifest to reproduce this digest." + }, + "modules": { + "compass-decide": { + "abi": "wasi", + "source": "wasmer/artifacts/compass-decide.wasm", + "sha256": "e77301bed6f3bcdf8541ba7256cb6a4e58e1da62d7a98edb52fa27bdc1fee553", + "bytes": 135419, + "sha256sums_key": "compass-decide.wasm", + "reconciled": true + }, + "compass-core": { + "abi": "none", + "source": "wasmer/artifacts/compass_core_bg.wasm", + "sha256": "9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db", + "bytes": 103980, + "sha256sums_key": "compass_core_bg.wasm", + "pins_json_key": "compass_core_bg.wasm", + "reconciled": true + } + }, + "fs": { + "/wasmer/fixtures": { + "source": "wasmer/fixtures", + "files": { + "snapshot_min.json": "8318571b06d35c805b21c404a3dc076a1af2be055f3c9fab037abd27404df6a4" + } + } + }, + "published": null +} diff --git a/wasmer/browser/README.md b/wasmer/browser/README.md index d67db1a..02e77db 100644 --- a/wasmer/browser/README.md +++ b/wasmer/browser/README.md @@ -12,6 +12,7 @@ cd wasmer && python3 -m http.server 8765 - **No** `fetch` import; **no** key material - Probe remains a native/extension sidecar (not in this page) - Smoke hooks: `window.__COMPASS_SMOKE__`, `?smoke=1`, `data-smoke-ready` +- Zone A SDK: `zonea.html` (dynamic `import("@wasmer/sdk/browser")`, `python/python@=3.13.18`, local compass guest) ## Headless smoke (Track J) @@ -23,7 +24,12 @@ npx playwright install chromium node ../../scripts/wasmer_browser_smoke.mjs # or with system Chrome: COMPASS_SMOKE_CHANNEL=chrome node scripts/wasmer_browser_smoke.mjs +# B2 SDK / Zone A (COOP/COEP on, then fail-open with isolation off): +node scripts/wasmer_browser_sdk_smoke.mjs +# Skip the ~157MB python/python registry download: +COMPASS_ZONEA_PYTHON=0 node scripts/wasmer_browser_sdk_smoke.mjs ``` Evidence: `test-results/j-wasmer-packaging/browser-smoke.json` + `.log.txt`. +SDK/Zone A: `test-results/r-browser-sdk/`. CI: `.github/workflows/wasmer-browser.yml`. diff --git a/wasmer/browser/index.html b/wasmer/browser/index.html index 24f366b..9b34e9f 100644 --- a/wasmer/browser/index.html +++ b/wasmer/browser/index.html @@ -2,8 +2,9 @@ - + comPASS browser sandbox — Route+Graph WASM + + + +

Zone A — Wasmer SDK + compass guest

+

+ Dynamic import("@wasmer/sdk/browser"), guarded on + window.crossOriginIsolated. Compass guest is the local + package / compass_core_bg.wasm, not registry + compass/decide. Python pin is python/python@=3.13.18. +

+

idle

+

+  
+
+
diff --git a/wasmer/browser/zonea.js b/wasmer/browser/zonea.js
new file mode 100644
index 0000000..49311db
--- /dev/null
+++ b/wasmer/browser/zonea.js
@@ -0,0 +1,324 @@
+/* Zone A: @wasmer/sdk/browser boot + local compass guest.
+ * Dynamic import of the /browser entrypoint (static import is documented
+ * to crash workers). Guard sandbox creation on crossOriginIsolated.
+ * compass/decide is NOT fetched from the registry (publish NOT_RUN).
+ */
+const statusEl = document.getElementById("status");
+const outEl = document.getElementById("out");
+const NOW = "2026-09-05T00:00:00Z";
+const REQUEST = "implement a function";
+
+let pins = {
+  registry_packages: { python: "python/python@=3.13.18" },
+};
+
+const report = {
+  isolated: window.crossOriginIsolated === true,
+  sdk: { imported: false, ready: false, error: null },
+  compass_host: { grade: "NOT_RUN", decision: null, error: null },
+  compass_webc: { grade: "NOT_RUN", decision: null, error: null, note: null },
+  python: { grade: "NOT_RUN", pin: "python/python@=3.13.18", output: null, error: null },
+  registry_compass: { attempted: false, note: "NOT_RUN — wasmer/PUBLISH-NOT_RUN.md; no fake registry fetch" },
+};
+
+function setStatus(msg, ok) {
+  statusEl.textContent = msg;
+  statusEl.className = ok === true ? "ok" : ok === false ? "err" : "";
+  document.documentElement.dataset.zoneaStatus = msg;
+}
+
+function publish() {
+  document.documentElement.dataset.zoneaIsolated = report.isolated ? "1" : "0";
+  document.documentElement.dataset.zoneaSdk = report.sdk.ready ? "1" : "0";
+  document.documentElement.dataset.zoneaHost = report.compass_host.grade;
+  document.documentElement.dataset.zoneaWebc = report.compass_webc.grade;
+  document.documentElement.dataset.zoneaPython = report.python.grade;
+  document.documentElement.dataset.zoneaOut = JSON.stringify(report);
+  outEl.textContent = JSON.stringify(report, null, 2);
+}
+
+function writeUtf8(memory, alloc, text) {
+  const bytes = new TextEncoder().encode(text);
+  const ptr = alloc(bytes.length);
+  new Uint8Array(memory.buffer, ptr, bytes.length).set(bytes);
+  return { ptr, len: bytes.length };
+}
+
+function decideWithExports(exp, request, snapshotText, nowIso) {
+  try {
+    const r = writeUtf8(exp.memory, exp.compass_alloc, request);
+    const s = snapshotText == null
+      ? { ptr: 0, len: 0 }
+      : writeUtf8(exp.memory, exp.compass_alloc, snapshotText);
+    const n = writeUtf8(exp.memory, exp.compass_alloc, nowIso || NOW);
+    const outPtr = exp.compass_decide_json(r.ptr, r.len, s.ptr, s.len, n.ptr, n.len);
+    const outLen = exp.compass_last_len();
+    const jsonBytes = new Uint8Array(exp.memory.buffer, outPtr, outLen);
+    return JSON.parse(new TextDecoder().decode(jsonBytes));
+  } catch (e) {
+    return {
+      fail_open: true,
+      default_reason: "module_trap",
+      selected_model_version_id: "default",
+      rationale: "fail-open: module_trap",
+      error: String(e),
+    };
+  }
+}
+
+function parityOk(d) {
+  return Boolean(
+    d &&
+      d.fail_open === false &&
+      d.selected_model_version_id === "urn:mg:model:cheap" &&
+      (d.default_reason === null || d.default_reason === undefined)
+  );
+}
+
+async function hostInstantiateCompass(snapshotText) {
+  const wasmUrl = new URL("../artifacts/compass_core_bg.wasm", import.meta.url);
+  const res = await fetch(wasmUrl);
+  if (!res.ok) throw new Error("wasm fetch failed: " + res.status);
+  const bytes = await res.arrayBuffer();
+  const { instance } = await WebAssembly.instantiate(bytes, {});
+  const exp = instance.exports;
+  if (!exp.compass_decide_json || !exp.compass_alloc || !exp.compass_last_len || !exp.memory) {
+    throw new Error("missing compass_* exports");
+  }
+  const fixture = decideWithExports(exp, REQUEST, snapshotText, NOW);
+  const missing = decideWithExports(exp, "x", null, NOW);
+  const corrupt = decideWithExports(exp, "x", "{truncated", NOW);
+  const missingOk = missing && missing.fail_open === true && missing.default_reason === "snapshot_missing";
+  const corruptOk = corrupt && corrupt.fail_open === true && corrupt.default_reason === "snapshot_corrupt";
+  report.compass_host = {
+    grade: parityOk(fixture) && missingOk && corruptOk ? "FULL" : "PARTIAL",
+    source: wasmUrl.pathname,
+    decision: fixture,
+    missing_reason: missing && missing.default_reason,
+    corrupt_reason: corrupt && corrupt.default_reason,
+    error: null,
+  };
+}
+
+async function loadLocalWebc() {
+  const url = new URL("../artifacts/compass-decide-0.1.0.webc", import.meta.url);
+  const res = await fetch(url);
+  if (!res.ok) {
+    return { ok: false, status: res.status, url: url.pathname };
+  }
+  return { ok: true, bytes: new Uint8Array(await res.arrayBuffer()), url: url.pathname };
+}
+
+function lastJsonLine(text) {
+  const lines = String(text || "").trim().split(/\r?\n/).filter(Boolean);
+  if (!lines.length) return null;
+  return JSON.parse(lines[lines.length - 1]);
+}
+
+function withTimeout(promise, ms, label) {
+  return Promise.race([
+    promise,
+    new Promise((_, reject) => {
+      setTimeout(() => reject(new Error(label + " timed out after " + ms + "ms")), ms);
+    }),
+  ]);
+}
+
+async function bootSdkAndGuests(snapshotText) {
+  if (!report.isolated) {
+    report.sdk.error = "not_cross_origin_isolated";
+    report.compass_webc.note = "SDK sandbox skipped: page is not cross-origin isolated; host instantiate remains the fail-open path";
+    report.python.grade = "NOT_RUN";
+    report.python.error = "not_cross_origin_isolated";
+    return;
+  }
+
+  let Wasmer;
+  try {
+    // Dynamic import of the /browser entrypoint. A static import is documented
+    // to pull DOM-touching dependencies into workers and crash them.
+    // Dynamic import of the package "./browser" entry (dist/index.js).
+    // Bare "@wasmer/sdk/browser" needs an import map, and import maps are
+    // inline scripts that CSP would have to hash or allow via unsafe-inline.
+    // Same file the package exports as "./browser".
+    const mod = await import(
+      new URL("./vendor/@wasmer/sdk/dist/index.js", import.meta.url).href
+    );
+    Wasmer = mod.Wasmer;
+    report.sdk.imported = true;
+  } catch (e) {
+    report.sdk.error = String(e);
+    report.compass_webc.grade = "NOT_RUN";
+    report.compass_webc.error = "sdk_import_failed";
+    report.python.grade = "NOT_RUN";
+    report.python.error = "sdk_import_failed";
+    return;
+  }
+
+  let wasmer;
+  try {
+    wasmer = new Wasmer();
+    await wasmer.ready();
+    report.sdk.ready = true;
+  } catch (e) {
+    report.sdk.error = String(e);
+    report.python.grade = "NOT_RUN";
+    report.python.error = "sdk_ready_failed";
+    report.compass_webc.grade = "NOT_RUN";
+    report.compass_webc.error = String(e);
+    return;
+  }
+
+  const webc = await loadLocalWebc();
+  if (!webc.ok) {
+    report.compass_webc = {
+      grade: "NOT_RUN",
+      decision: null,
+      error: "local_webc_http_" + webc.status,
+      note: "Local .webc not served (gitignored build artifact). Host compass_decide_json path is the compass guest.",
+    };
+  } else {
+    try {
+      const sandbox = await withTimeout(
+        wasmer.sandboxes.create({ packages: [webc.bytes] }),
+        60000,
+        "local_webc_sandbox"
+      );
+      const output = await withTimeout(
+        sandbox.command("compass-decide", [
+          "--request",
+          REQUEST,
+          "--snapshot",
+          "/wasmer/fixtures/snapshot_min.json",
+          "--now",
+          NOW,
+        ]).run(),
+        60000,
+        "local_webc_compass_decide"
+      );
+      const decision = lastJsonLine(output.text());
+      report.compass_webc = {
+        grade: parityOk(decision) ? "FULL" : "PARTIAL",
+        source: "local_webc",
+        bytes: webc.bytes.byteLength,
+        decision,
+        stdout_ok: output.ok,
+        error: null,
+      };
+      await sandbox.close();
+    } catch (e) {
+      report.compass_webc = {
+        grade: "PARTIAL",
+        decision: null,
+        error: String(e),
+        note: "Local webc loaded but sandbox/command failed",
+      };
+    }
+  }
+
+  const skipPython = new URLSearchParams(location.search).get("python") === "0";
+  if (skipPython) {
+    report.python.grade = "NOT_RUN";
+    report.python.error = "skipped_by_query";
+    report.python.note = "Pass ?python=0 to skip the ~157MB python/python download";
+  } else {
+    try {
+      const sandbox = await withTimeout(
+        wasmer.sandboxes.create({
+          packages: [pins.registry_packages.python],
+        }),
+        180000,
+        "python_sandbox"
+      );
+      const output = await withTimeout(
+        sandbox.command("python", ["-c", "print('zone-a-python-ok')"]).run(),
+        60000,
+        "python_print"
+      );
+      const text = output.text().trim();
+      report.python = {
+        grade: output.ok && text.includes("zone-a-python-ok") ? "FULL" : "PARTIAL",
+        pin: pins.registry_packages.python,
+        output: text,
+        error: null,
+        source: "registry",
+      };
+      await sandbox.close();
+    } catch (e) {
+      report.python = {
+        grade: "NOT_RUN",
+        pin: pins.registry_packages.python,
+        output: null,
+        error: String(e),
+        note: "Honest NOT_RUN: registry python/python download did not complete (auth, COEP/CORP, network, or size). Compass guest does not depend on this.",
+      };
+    }
+  }
+
+  try {
+    await wasmer.close();
+  } catch {
+    /* ignore */
+  }
+}
+
+async function boot() {
+  setStatus("loading…");
+  publish();
+  try {
+    pins = await (await fetch(new URL("./sdk-pins.json", import.meta.url))).json();
+    report.python.pin = pins.registry_packages.python;
+  } catch (e) {
+    report.sdk.error = "sdk-pins.json: " + String(e);
+  }
+  let snapshotText = "";
+  try {
+    const snapUrl = new URL("../fixtures/snapshot_min.json", import.meta.url);
+    const res = await fetch(snapUrl);
+    if (!res.ok) throw new Error("snapshot fetch failed: " + res.status);
+    snapshotText = await res.text();
+  } catch (e) {
+    report.compass_host = { grade: "NOT_RUN", decision: null, error: String(e) };
+    document.documentElement.dataset.zoneaReady = "0";
+    document.documentElement.dataset.zoneaDone = "1";
+    document.documentElement.dataset.zoneaError = String(e);
+    setStatus("snapshot load failed", false);
+    publish();
+    return;
+  }
+
+  try {
+    await hostInstantiateCompass(snapshotText);
+  } catch (e) {
+    report.compass_host = { grade: "NOT_RUN", decision: null, error: String(e) };
+  }
+
+  try {
+    await bootSdkAndGuests(snapshotText);
+  } catch (e) {
+    report.sdk.error = String(e);
+  }
+
+  const hostOk = report.compass_host.grade === "FULL";
+  const sdkOk = report.sdk.ready;
+  setStatus(
+    "isolated=" + report.isolated + " sdk=" + sdkOk + " host=" + report.compass_host.grade,
+    hostOk
+  );
+  document.documentElement.dataset.zoneaReady = hostOk ? "1" : "0";
+  document.documentElement.dataset.zoneaDone = "1";
+  publish();
+}
+
+window.__COMPASS_ZONEA__ = {
+  report: () => report,
+  pins,
+};
+
+boot().catch((e) => {
+  document.documentElement.dataset.zoneaReady = "0";
+  document.documentElement.dataset.zoneaDone = "1";
+  document.documentElement.dataset.zoneaError = String(e);
+  setStatus("boot failed: " + e, false);
+  publish();
+});
diff --git a/wasmer/desktop/README.md b/wasmer/desktop/README.md
index 002ab73..bf9711c 100644
--- a/wasmer/desktop/README.md
+++ b/wasmer/desktop/README.md
@@ -1,25 +1,54 @@
-# Desktop Wasmer shell (Track J)
+# Desktop Wasmer shell (Track J / plan B3)
 
 Packaged entrypoint beyond raw `.wasm` bytes:
 
 | File | Role |
 |---|---|
-| `run-decide.sh` | Operator script: volume map, defaults, fail-open demos, exit codes |
-| `wasmer.toml` | Wasmer package manifest pointing at `../artifacts/compass-decide.wasm` |
+| `run-decide.sh` | Operator script: registry hop (opt-in), local `.webc`, air-gap wasm, volume map, fail-open demos |
 
-## Run
+The package manifest lives at the repo root as [`wasmer.toml`](../../wasmer.toml).
+It covers both the WASI binary and the browser cdylib in one `compass/decide`
+package. Registry publish is **NOT_RUN** — see [`../PUBLISH-NOT_RUN.md`](../PUBLISH-NOT_RUN.md).
+
+## Run order
+
+`run-decide.sh` tries hops in this order. Stdout is the decide envelope; hop
+diagnostics go to stderr.
+
+1. **Registry by name/version** — only if `COMPASS_WASMER_USE_REGISTRY=1` or
+   `--registry`, and a package ref is set (`compass/decide@0.1.0` from the
+   manifest, overridable with `COMPASS_WASMER_PACKAGE` / `COMPASS_WASMER_VERSION`
+   / `COMPASS_WASMER_REF`). This hop is **code-complete** and **runtime NOT_RUN**
+   until a human `wasmer login`, claims namespace `compass`, and
+   `wasmer publish .`. Failure is caught and the script falls through.
+2. **Local `.webc` (default today)** — `wasmer run --offline` on
+   `compass-decide-0.1.0.webc` at the repo root. Built with
+   `wasmer package build` if missing.
+3. **Air-gap wasm** — `wasmer run --offline wasmer/artifacts/compass-decide.wasm`
+   with `--volume "$PWD/wasmer:/wasmer"` (audit §A6 item 11). Same mapping as
+   `scripts/wasmer_parity.py`, which still shells `wasmer run` on the raw wasm
+   itself.
 
 ```bash
-# from repo root
+# from repo root — default = local .webc
 ./wasmer/desktop/run-decide.sh
 # fail-open demos:
 COMPASS_FAIL_OPEN_DEMO=missing ./wasmer/desktop/run-decide.sh
 COMPASS_FAIL_OPEN_DEMO=corrupt ./wasmer/desktop/run-decide.sh
+# isolate a hop:
+COMPASS_DECIDE_SOURCE=webc ./wasmer/desktop/run-decide.sh
+COMPASS_DECIDE_SOURCE=wasm ./wasmer/desktop/run-decide.sh
+# opt-in registry (expected to fail until PUBLISH-NOT_RUN.md flips):
+COMPASS_WASMER_USE_REGISTRY=1 ./wasmer/desktop/run-decide.sh --registry
 ```
 
 Requires Wasmer CLI on PATH. No provider keys; snapshot is host-supplied JSON only.
 
 ## Grade
 
-**PARTIAL → FULL** for desktop packaging when `run-decide.sh` + artifact + parity green.
+**PARTIAL** for published-by-name (`wasmer run compass/decide@0.1.0`) — implementation
+is in the script; runtime is NOT_RUN. **FULL** for local packaged `.webc` and
+loose-artifact fallback when `scripts/wasmer_desktop_packaged.py` and
+`scripts/wasmer_parity.py` are green.
+
 Mobile device farm remains separate (`wasmer/mobile/NOT_RUN.md`).
diff --git a/wasmer/desktop/run-decide.sh b/wasmer/desktop/run-decide.sh
index a9a5631..5419f58 100755
--- a/wasmer/desktop/run-decide.sh
+++ b/wasmer/desktop/run-decide.sh
@@ -1,36 +1,229 @@
 #!/usr/bin/env bash
-# Packaged desktop Wasmer entrypoint for compass-decide.wasm (Track J).
-# Beyond raw wasm bytes: volume map, fixture default, fail-open demos, exit codes.
+# Packaged desktop Wasmer entrypoint for compass-decide (Track J / plan B3).
+#
+# Execution order:
+#   1. Registry by name/version — only when requested (COMPASS_WASMER_USE_REGISTRY=1
+#      or --registry) AND a package name/version is set. Today this fails:
+#      publish is NOT_RUN (wasmer/PUBLISH-NOT_RUN.md). Catch, log honestly, fall through.
+#   2. Local .webc by path (default). Build with `wasmer package build` if missing.
+#   3. Air-gap fallback: wasmer/artifacts/compass-decide.wasm (audit §A6 item 11).
+#
+# Guest args and `--volume "$ROOT/wasmer:/wasmer"` stay compatible with the
+# raw-wasm invocation in scripts/wasmer_parity.py. That script still shells
+# `wasmer run` on the loose artifact; this script's step 3 is the same mapping.
+# Isolate a hop with COMPASS_DECIDE_SOURCE=registry|webc|wasm.
 set -euo pipefail
+
 ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
-WASM="${COMPASS_DECIDE_WASM:-$ROOT/wasmer/artifacts/compass-decide.wasm}"
+MANIFEST="$ROOT/wasmer.toml"
+WASM_REL="wasmer/artifacts/compass-decide.wasm"
+WASM="${COMPASS_DECIDE_WASM:-$ROOT/$WASM_REL}"
 FIXTURE="${COMPASS_SNAPSHOT:-$ROOT/wasmer/fixtures/snapshot_min.json}"
 REQUEST="${COMPASS_REQUEST:-implement a function}"
 NOW="${COMPASS_NOW:-2026-09-05T00:00:00Z}"
 DEMO="${COMPASS_FAIL_OPEN_DEMO:-}"
+SOURCE="${COMPASS_DECIDE_SOURCE:-}"
+USE_REGISTRY="${COMPASS_WASMER_USE_REGISTRY:-}"
+REGISTRY_ONLY="${COMPASS_WASMER_REGISTRY_ONLY:-}"
+
+log() { printf '%s\n' "$*" >&2; }
+
+truthy() {
+  case "${1:-}" in
+    1|true|TRUE|yes|YES|on|ON) return 0 ;;
+    *) return 1 ;;
+  esac
+}
+
+relpath() {
+  local p="$1"
+  if [[ "$p" == "$ROOT"/* ]]; then
+    printf '%s\n' "${p#"$ROOT"/}"
+  else
+    printf '%s\n' "$p"
+  fi
+}
+
+toml_get() {
+  local key="$1"
+  awk -F'"' -v k="$key" '$0 ~ "^" k " *=" { print $2; exit }' "$MANIFEST"
+}
+
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --registry)
+      USE_REGISTRY=1
+      shift
+      ;;
+    --source)
+      SOURCE="${2:-}"
+      shift 2
+      ;;
+    --source=*)
+      SOURCE="${1#*=}"
+      shift
+      ;;
+    --)
+      shift
+      break
+      ;;
+    -*)
+      log "error: unknown flag $1 (supported: --registry, --source registry|webc|wasm)"
+      exit 2
+      ;;
+    *)
+      break
+      ;;
+  esac
+done
+
+if [[ -n "${1:-}" ]]; then
+  log "error: unexpected argument: $1"
+  exit 2
+fi
 
 if ! command -v wasmer >/dev/null 2>&1; then
-  echo "error: wasmer CLI not on PATH (brew install wasmer)" >&2
+  log "error: wasmer CLI not on PATH (brew install wasmer)"
   exit 127
 fi
-if [[ ! -f "$WASM" ]]; then
-  echo "error: missing wasm: $WASM" >&2
+
+if [[ ! -f "$MANIFEST" ]]; then
+  log "error: missing package manifest: wasmer.toml"
   exit 2
 fi
 
+PKG_NAME="${COMPASS_WASMER_PACKAGE:-$(toml_get name)}"
+PKG_VERSION="${COMPASS_WASMER_VERSION:-$(toml_get version)}"
+PKG_REF="${COMPASS_WASMER_REF:-}"
+if [[ -z "$PKG_REF" && -n "$PKG_NAME" && -n "$PKG_VERSION" ]]; then
+  PKG_REF="${PKG_NAME}@${PKG_VERSION}"
+fi
+
+WEBC_DEFAULT="$ROOT/${PKG_NAME//\//-}-${PKG_VERSION}.webc"
+WEBC="${COMPASS_WASMER_WEBC:-$WEBC_DEFAULT}"
+
 VOL_ARGS=(--volume "$ROOT/wasmer:/wasmer")
 ARGS=(--request "$REQUEST" --now "$NOW")
 if [[ -n "$DEMO" ]]; then
   ARGS+=(--fail-open-demo "$DEMO")
 else
-  # Guest path under preopened /wasmer
   GUEST="/wasmer/fixtures/$(basename "$FIXTURE")"
-  # If fixture is not under wasmer/fixtures, copy hint:
   if [[ ! -f "$ROOT/wasmer/fixtures/$(basename "$FIXTURE")" ]]; then
-    echo "error: snapshot must live under wasmer/fixtures for volume map (got $FIXTURE)" >&2
+    log "error: snapshot must live under wasmer/fixtures for volume map (got $(relpath "$FIXTURE"))"
     exit 2
   fi
   ARGS+=(--snapshot "$GUEST")
 fi
 
-exec wasmer run "$WASM" "${VOL_ARGS[@]}" -- "${ARGS[@]}"
+# stdout = decide envelope only. Diagnostics go to stderr (do not redirect
+# wasmer's stderr to a regular file — 7.4.0 swallows the diagnostic there).
+try_run() {
+  local label="$1"
+  local input="$2"
+  shift 2
+  local extra=("$@")
+  local rc
+  log "[run-decide] source=${label} input=$(relpath "$input")"
+  set +e
+  wasmer --color never run "$input" "${extra[@]}" -- "${ARGS[@]}"
+  rc=$?
+  set -e
+  if [[ $rc -eq 0 ]]; then
+    return 0
+  fi
+  log "[run-decide] source=${label} failed rc=${rc} input=$(relpath "$input")"
+  return "$rc"
+}
+
+ensure_webc() {
+  if [[ -f "$WEBC" ]]; then
+    log "[run-decide] using existing webc $(relpath "$WEBC")"
+    return 0
+  fi
+  log "[run-decide] building local package: wasmer package build -o $(relpath "$WEBC") ."
+  set +e
+  (cd "$ROOT" && wasmer --color never package build --quiet -o "$WEBC" .) >&2
+  local rc=$?
+  set -e
+  if [[ $rc -ne 0 || ! -f "$WEBC" ]]; then
+    log "[run-decide] webc build failed rc=${rc}"
+    return 1
+  fi
+  return 0
+}
+
+run_registry() {
+  if [[ -z "$PKG_REF" ]]; then
+    log "[run-decide] registry requested but package name/version is unset; skipping"
+    return 1
+  fi
+  log "[run-decide] trying registry-by-name: wasmer run ${PKG_REF}"
+  log "[run-decide] publish state is NOT_RUN until wasmer/PUBLISH-NOT_RUN.md flips; this hop is expected to fail until a human wasmer login + namespace claim + wasmer publish ."
+  if try_run registry "$PKG_REF" "${VOL_ARGS[@]}"; then
+    return 0
+  fi
+  log "[run-decide] registry-by-name PARTIAL (code present) / runtime NOT_RUN: ${PKG_REF} is not on the registry. Falling through. See wasmer/PUBLISH-NOT_RUN.md."
+  return 1
+}
+
+run_webc() {
+  if ! ensure_webc; then
+    return 1
+  fi
+  # --offline: never resolve from the registry. Volume overlay keeps host
+  # wasmer/fixtures in sync with scripts/wasmer_parity.py and the bundled [fs] map.
+  try_run webc "$WEBC" --offline "${VOL_ARGS[@]}"
+}
+
+run_wasm() {
+  if [[ ! -f "$WASM" ]]; then
+    log "error: missing wasm: $(relpath "$WASM")"
+    return 2
+  fi
+  try_run wasm "$WASM" --offline "${VOL_ARGS[@]}"
+}
+
+want_registry=0
+if truthy "$USE_REGISTRY" || [[ "$SOURCE" == "registry" ]]; then
+  want_registry=1
+fi
+
+case "$SOURCE" in
+  ""|registry|webc|wasm) ;;
+  *)
+    log "error: COMPASS_DECIDE_SOURCE / --source must be registry, webc, or wasm (got ${SOURCE})"
+    exit 2
+    ;;
+esac
+
+if [[ "$SOURCE" == "wasm" ]]; then
+  run_wasm
+  exit $?
+fi
+
+if [[ "$SOURCE" == "webc" ]]; then
+  run_webc
+  exit $?
+fi
+
+if [[ "$want_registry" -eq 1 ]]; then
+  if run_registry; then
+    exit 0
+  fi
+  if truthy "$REGISTRY_ONLY"; then
+    log "[run-decide] COMPASS_WASMER_REGISTRY_ONLY=1 — not falling through"
+    exit 1
+  fi
+fi
+
+if [[ "$SOURCE" == "registry" ]]; then
+  # SOURCE=registry already attempted above; without REGISTRY_ONLY, fall through.
+  :
+fi
+
+if run_webc; then
+  exit 0
+fi
+log "[run-decide] falling through to air-gap wasm $(relpath "$WASM")"
+run_wasm
+exit $?
diff --git a/wasmer/desktop/wasmer.toml b/wasmer/desktop/wasmer.toml
deleted file mode 100644
index 52bd067..0000000
--- a/wasmer/desktop/wasmer.toml
+++ /dev/null
@@ -1,16 +0,0 @@
-# Wasmer package manifest for desktop decide shell (Track J).
-# Publish is optional; local `wasmer run` via run-decide.sh is the primary path.
-[package]
-name = "compass/decide"
-version = "0.1.0"
-description = "comPASS Route+Graph decide (WASI) — no provider keys"
-license = "Apache-2.0"
-
-[[module]]
-name = "compass-decide"
-source = "../artifacts/compass-decide.wasm"
-abi = "wasi"
-
-[[command]]
-name = "compass-decide"
-module = "compass-decide"
diff --git a/wasmer/mobile/NOT_RUN.md b/wasmer/mobile/NOT_RUN.md
index 4cc3b63..6e4cdf8 100644
--- a/wasmer/mobile/NOT_RUN.md
+++ b/wasmer/mobile/NOT_RUN.md
@@ -1,35 +1,52 @@
-# ADR-quality: Mobile Wasmer device matrix — NOT_RUN
+# ADR-quality: Mobile Wasmer device matrix — PARTIAL (iOS Simulator)
 
-**Status:** NOT_RUN (2026-09-05 PT)  
-**Track:** J (Wasmer browser/mobile)  
-**Module:** reuse `wasmer/artifacts/compass_core_bg.wasm` (build-once; empty import table)
+**Status:** PARTIAL (iOS Simulator, 2026-09-07 PT / 2026-09-08Z)  
+**Track:** J (Wasmer browser/mobile) + plan B4  
+**Module:** reuse `wasmer/artifacts/compass_core_bg.wasm` (build-once; empty import table; SHA-256 `9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db`)
+
+Filename kept as `NOT_RUN.md` so existing CI `test -f wasmer/mobile/NOT_RUN.md` still resolves. The **status field** is the grade.
 
 ## Decision
 
-Do **not** claim mobile host coverage in CI. There is no iOS/Android device farm, TestFlight lane, or emulator job wired to this repo. Desktop Wasmer shell (`wasmer/desktop/run-decide.sh`) and headless browser smoke cover packaged run paths for Track J exit.
+Do **not** claim FULL (physical device) or Android emulator coverage. iOS Simulator ran the shared host glue against the pinned cdylib and matched Python reason codes. Android host sources exist; the SDK/emulator were not present, so Android remains **NOT_RUN**. Fake green CI is still forbidden.
 
-## Context
+## What landed (five steps)
 
-- Route+Graph module is `wasm32-unknown-unknown` cdylib suitable for mobile WASM hosts when one exists.
-- Probe remains native sidecar (never in WASM).
-- App Store submission is an explicit non-goal for Track J.
+1. **Host pick:** iOS `WKWebView` + `WebAssembly.instantiate` (same ABI as `wasmer/browser/sandbox.js`). Android System WebView host tree for the same JS glue (`wasmer/mobile/shared/host.js`). No JNI Wasmer SDK (not a maintained Android product path here).
+2. **Trees:** `wasmer/mobile/ios/` and `wasmer/mobile/android/`. Both load `compass_core_bg.wasm` **by digest** from `SHA256SUMS`, sanitize a keyless snapshot, call `compass_decide_json`.
+3. **CI:** `.github/workflows/wasmer-mobile.yml` — Node glue check + size budget on Ubuntu; iOS Simulator job on macOS; Android runner exits 2 with NOT_RUN unless an SDK/device is provisioned. Logs upload from `test-results/s-desktop-mobile/`.
+4. **Grade:** **PARTIAL** (iOS Simulator evidence). **FULL** still requires a physical device log. Android **NOT_RUN** (no SDK).
+5. **Size budget:** browser cdylib 103980 bytes (≤150000). Mobile hosts share the same `SHA256SUMS` hash.
+
+## Parity observed (iOS Simulator)
+
+| Case | Result | Evidence |
+|---|---|---|
+| fixture_min | `urn:mg:model:cheap` | `test-results/s-desktop-mobile/mobile-ios-simulator.json` |
+| missing snapshot | `snapshot_missing` | same |
+| digest | matches `SHA256SUMS` (`compass_core_bg.wasm`) | CryptoKit pin + report `actual_sha256` |
 
-## Consequences
+Shared JS glue also passes in Node (not a device run): `mobile-glue-check.json`.
 
-- Release notes may advertise **browser-tested** + **desktop Wasmer shell** wasm.
-- Mobile remains **NOT_RUN** until a host integration lands.
+## Human follow-up (blockers for FULL / Android PARTIAL)
+
+- Install Android SDK (API 34), accept licenses, run `./wasmer/mobile/android/run-emulator.sh`.
+- For `xcodebuild test` destinations under Xcode 26.2: install the **iOS 26.2 Simulator runtime** (Components). This machine used `simctl` install/launch because scheme destinations did not list simulators.
+- Physical device: development team / signing; do not claim FULL without that log.
+- Connected iPad was visible to xcodebuild but ineligible (`iOS 26.2 is not installed` device support).
+
+## Context
+
+- Route+Graph module is `wasm32-unknown-unknown` cdylib.
+- Probe remains native sidecar (never in WASM).
+- App Store submission remains a non-goal.
 
-## Exact next steps (when unblocking)
+## Exact next steps (remaining)
 
-1. Pick host: Wasmer SDK on Android (JNI/Kotlin) **or** iOS `WKWebView` + `WebAssembly.instantiate` (same browser glue as `wasmer/browser/sandbox.js`) **or** third-party mobile Wasmer embed.
-2. Add `wasmer/mobile//` with a minimal host that:
-   - loads `compass_core_bg.wasm` from app assets;
-   - feeds a sanitized snapshot (no keys);
-   - calls `compass_decide_json`;
-   - surfaces fail-open reason codes identical to Python/`wasmer_parity.py`.
-3. CI: emulator job (Android API 34) **or** macOS runner + iOS Simulator; no provider secrets; upload `test-results/j-wasmer-packaging/mobile-*.log.txt`.
-4. Flip this file’s status to PARTIAL (emulator) or FULL (device) and update `docs/WASMER.md` matrix.
-5. Size budget: keep browser cdylib under documented threshold; mobile hosts share the same artifact hash from `SHA256SUMS`.
+1. Provision Android SDK + API 34 emulator or a device; run `connectedDebugAndroidTest`.
+2. Optional: install iOS 26.2 simulator runtime so `xcodebuild -destination` test works without the simctl fallback.
+3. Device signing + a device log → flip this file to **FULL**.
+4. Keep `docs/WASMER.md` matrix in sync with the status field here.
 
 ## Alternatives considered
 
@@ -37,4 +54,5 @@ Do **not** claim mobile host coverage in CI. There is no iOS/Android device farm
 |---|---|
 | Browser-only on mobile Safari | Useful smoke, not a native packaging path |
 | Publish to App Store | Out of scope |
-| Fake green CI without device | Forbidden — keep honest NOT_RUN |
+| Fake green CI without device/emulator | Forbidden |
+| Wasmer JNI on Android | No maintained Android JNI SDK in-tree; WebView instantiate matches the iOS ABI |
diff --git a/wasmer/mobile/README.md b/wasmer/mobile/README.md
index e74e1a0..38a7839 100644
--- a/wasmer/mobile/README.md
+++ b/wasmer/mobile/README.md
@@ -1,5 +1,20 @@
 # Mobile packaging
 
-**Status: NOT_RUN** — see [`NOT_RUN.md`](NOT_RUN.md) for ADR-quality rationale and exact next steps.
+**Status: PARTIAL** (iOS Simulator) — Android **NOT_RUN**. See [`NOT_RUN.md`](NOT_RUN.md) (filename kept; the status field is the grade).
 
-Artifact to embed when a host exists: `../artifacts/compass_core_bg.wasm`.
+Both hosts load the same `../artifacts/compass_core_bg.wasm` **by digest** (`SHA256SUMS`), feed a sanitized keyless snapshot, and call `compass_decide_json`. Reason codes match Python/`scripts/wasmer_parity.py`: fixture_min → `urn:mg:model:cheap`; missing snapshot → `snapshot_missing`.
+
+| Tree | Role |
+|---|---|
+| [`shared/host.js`](shared/host.js) | Shared instantiate + decide glue |
+| [`ios/`](ios/) | WKWebView host; `./ios/run-simulator.sh` |
+| [`android/`](android/) | System WebView host; `./android/run-emulator.sh` |
+| [`sync-assets.sh`](sync-assets.sh) | Copy wasm after verifying `SHA256SUMS` |
+| [`run-hosts.sh`](run-hosts.sh) | Glue + size budget + iOS + Android (honest skip) |
+
+```bash
+./wasmer/mobile/run-hosts.sh
+# or: ./scripts/validate-wasmer-mobile.sh
+```
+
+Do not treat Node glue success as a device run. Do not claim FULL without a physical-device log.
diff --git a/wasmer/mobile/android/README.md b/wasmer/mobile/android/README.md
new file mode 100644
index 0000000..c87133c
--- /dev/null
+++ b/wasmer/mobile/android/README.md
@@ -0,0 +1,16 @@
+# Android WebView host
+
+Minimal API-34-targeted app that:
+
+1. Reads `compass_core_bg.wasm` from APK assets.
+2. Verifies SHA-256 against `EXPECTED_SHA256` (from `wasmer/artifacts/SHA256SUMS`).
+3. Loads shared `index.html` / `host.js` via `WebViewAssetLoader`.
+4. Instantiates the module and calls `compass_decide_json`.
+
+```bash
+# Requires Android SDK + licenses + an API 34 emulator or a device.
+export ANDROID_HOME=...   # do not commit this path
+./wasmer/mobile/android/run-emulator.sh
+```
+
+Without an SDK this tree still exists; `run-emulator.sh` exits 2 and writes an honest **NOT_RUN** log. Do not treat assemble-only as PARTIAL.
diff --git a/wasmer/mobile/android/app/build.gradle.kts b/wasmer/mobile/android/app/build.gradle.kts
new file mode 100644
index 0000000..91abd4b
--- /dev/null
+++ b/wasmer/mobile/android/app/build.gradle.kts
@@ -0,0 +1,64 @@
+plugins {
+    id("com.android.application")
+    id("org.jetbrains.kotlin.android")
+}
+
+android {
+    namespace = "org.compass.wasmer.mobile"
+    compileSdk = 34
+
+    defaultConfig {
+        applicationId = "org.compass.wasmer.mobile"
+        minSdk = 26
+        targetSdk = 34
+        versionCode = 1
+        versionName = "0.1.0"
+        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+    }
+
+    buildTypes {
+        release {
+            isMinifyEnabled = false
+        }
+        debug {
+            isMinifyEnabled = false
+        }
+    }
+
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_17
+        targetCompatibility = JavaVersion.VERSION_17
+    }
+}
+
+kotlin {
+    jvmToolchain(17)
+}
+
+dependencies {
+    implementation("androidx.appcompat:appcompat:1.7.0")
+    implementation("androidx.webkit:webkit:1.12.1")
+    androidTestImplementation("androidx.test.ext:junit:1.2.1")
+    androidTestImplementation("androidx.test:core:1.6.1")
+    androidTestImplementation("androidx.test:runner:1.6.2")
+    androidTestImplementation("androidx.test:rules:1.6.1")
+}
+
+afterEvaluate {
+    tasks.named("preBuild").configure {
+        dependsOn("syncCompassAssets")
+    }
+}
+
+tasks.register("syncCompassAssets") {
+    doLast {
+        val script = rootProject.projectDir.resolve("../sync-assets.sh")
+        val pb = ProcessBuilder("bash", script.absolutePath)
+            .directory(rootProject.projectDir)
+            .inheritIO()
+        val code = pb.start().waitFor()
+        if (code != 0) {
+            throw GradleException("sync-assets.sh exited $code")
+        }
+    }
+}
diff --git a/wasmer/mobile/android/app/src/androidTest/java/org/compass/wasmer/mobile/DecideParityTest.kt b/wasmer/mobile/android/app/src/androidTest/java/org/compass/wasmer/mobile/DecideParityTest.kt
new file mode 100644
index 0000000..dcc472a
--- /dev/null
+++ b/wasmer/mobile/android/app/src/androidTest/java/org/compass/wasmer/mobile/DecideParityTest.kt
@@ -0,0 +1,36 @@
+package org.compass.wasmer.mobile
+
+import androidx.test.core.app.ActivityScenario
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.util.concurrent.atomic.AtomicReference
+
+@RunWith(AndroidJUnit4::class)
+class DecideParityTest {
+    @Test
+    fun fixtureMinAndMissingSnapshot() {
+        val scenario = ActivityScenario.launch(MainActivity::class.java)
+        val activityRef = AtomicReference()
+        scenario.onActivity { activityRef.set(it) }
+        val json = activityRef.get().awaitResult(90_000L)
+        assertNotNull(
+            "WebView did not report compassResult (emulator/device required; this is not a host-side fake)",
+            json
+        )
+        val obj = JSONObject(json!!)
+        assertTrue(obj.optString("error"), obj.getBoolean("ok"))
+        assertEquals(
+            "urn:mg:model:cheap",
+            obj.getJSONObject("fixture_min").getString("selected_model_version_id")
+        )
+        assertEquals(
+            "snapshot_missing",
+            obj.getJSONObject("missing").getString("default_reason")
+        )
+    }
+}
diff --git a/wasmer/mobile/android/app/src/main/AndroidManifest.xml b/wasmer/mobile/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..eee6121
--- /dev/null
+++ b/wasmer/mobile/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,17 @@
+
+
+    
+        
+            
+                
+                
+            
+        
+    
+
diff --git a/wasmer/mobile/android/app/src/main/assets/.gitkeep b/wasmer/mobile/android/app/src/main/assets/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/wasmer/mobile/android/app/src/main/java/org/compass/wasmer/mobile/Digest.kt b/wasmer/mobile/android/app/src/main/java/org/compass/wasmer/mobile/Digest.kt
new file mode 100644
index 0000000..a733770
--- /dev/null
+++ b/wasmer/mobile/android/app/src/main/java/org/compass/wasmer/mobile/Digest.kt
@@ -0,0 +1,13 @@
+package org.compass.wasmer.mobile
+
+import java.security.MessageDigest
+
+object Digest {
+    fun sha256Hex(bytes: ByteArray): String {
+        val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
+        return digest.joinToString("") { b -> "%02x".format(b) }
+    }
+
+    fun loadExpected(text: String): String =
+        text.trim().lowercase()
+}
diff --git a/wasmer/mobile/android/app/src/main/java/org/compass/wasmer/mobile/MainActivity.kt b/wasmer/mobile/android/app/src/main/java/org/compass/wasmer/mobile/MainActivity.kt
new file mode 100644
index 0000000..5a0c3f0
--- /dev/null
+++ b/wasmer/mobile/android/app/src/main/java/org/compass/wasmer/mobile/MainActivity.kt
@@ -0,0 +1,96 @@
+package org.compass.wasmer.mobile
+
+import android.annotation.SuppressLint
+import android.os.Bundle
+import android.webkit.JavascriptInterface
+import android.webkit.WebResourceRequest
+import android.webkit.WebResourceResponse
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.appcompat.app.AppCompatActivity
+import androidx.webkit.WebViewAssetLoader
+import org.json.JSONObject
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicReference
+
+class MainActivity : AppCompatActivity() {
+    private val latch = CountDownLatch(1)
+    private val resultJson = AtomicReference(null)
+    private lateinit var status: TextView
+
+    fun awaitResult(timeoutMs: Long): String? {
+        latch.await(timeoutMs, TimeUnit.MILLISECONDS)
+        return resultJson.get()
+    }
+
+    @SuppressLint("SetJavaScriptEnabled")
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        status = TextView(this).apply { text = "verifying digest…" }
+        val web = WebView(this)
+        val root = LinearLayout(this).apply {
+            orientation = LinearLayout.VERTICAL
+            addView(status, LinearLayout.LayoutParams(
+                LinearLayout.LayoutParams.MATCH_PARENT,
+                LinearLayout.LayoutParams.WRAP_CONTENT
+            ))
+            addView(web, LinearLayout.LayoutParams(
+                LinearLayout.LayoutParams.MATCH_PARENT,
+                0,
+                1f
+            ))
+        }
+        setContentView(root)
+
+        val expected = assets.open("EXPECTED_SHA256").bufferedReader().use { Digest.loadExpected(it.readText()) }
+        val wasm = assets.open("compass_core_bg.wasm").use { it.readBytes() }
+        val actual = Digest.sha256Hex(wasm)
+        if (actual != expected) {
+            val err = JSONObject()
+                .put("ok", false)
+                .put("error", "digest mismatch expected=$expected actual=$actual")
+                .put("expected_sha256", expected)
+                .put("actual_sha256", actual)
+                .toString()
+            finishWith(err)
+            return
+        }
+        status.text = "digest match $actual — loading WebView"
+
+        val assetLoader = WebViewAssetLoader.Builder()
+            .setDomain("appassets.androidplatform.net")
+            .addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(this))
+            .build()
+
+        web.settings.javaScriptEnabled = true
+        web.settings.allowFileAccess = false
+        web.settings.allowContentAccess = false
+        web.addJavascriptInterface(Bridge(), "CompassNative")
+        web.webViewClient = object : WebViewClient() {
+            override fun shouldInterceptRequest(
+                view: WebView,
+                request: WebResourceRequest
+            ): WebResourceResponse? = assetLoader.shouldInterceptRequest(request.url)
+        }
+        web.loadUrl("https://appassets.androidplatform.net/assets/index.html")
+    }
+
+    private fun finishWith(json: String) {
+        resultJson.set(json)
+        latch.countDown()
+        runOnUiThread {
+            status.text = json
+            status.contentDescription = json
+        }
+    }
+
+    inner class Bridge {
+        @JavascriptInterface
+        fun report(json: String) {
+            finishWith(json)
+        }
+    }
+}
diff --git a/wasmer/mobile/android/build.gradle.kts b/wasmer/mobile/android/build.gradle.kts
new file mode 100644
index 0000000..d176100
--- /dev/null
+++ b/wasmer/mobile/android/build.gradle.kts
@@ -0,0 +1,4 @@
+plugins {
+    id("com.android.application") version "8.7.2" apply false
+    id("org.jetbrains.kotlin.android") version "2.0.21" apply false
+}
diff --git a/wasmer/mobile/android/gradle.properties b/wasmer/mobile/android/gradle.properties
new file mode 100644
index 0000000..2318707
--- /dev/null
+++ b/wasmer/mobile/android/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
diff --git a/wasmer/mobile/android/gradle/wrapper/gradle-wrapper.jar b/wasmer/mobile/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..8bdaf60
Binary files /dev/null and b/wasmer/mobile/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/wasmer/mobile/android/gradle/wrapper/gradle-wrapper.properties b/wasmer/mobile/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..e2847c8
--- /dev/null
+++ b/wasmer/mobile/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/wasmer/mobile/android/gradlew b/wasmer/mobile/android/gradlew
new file mode 100755
index 0000000..ef07e01
--- /dev/null
+++ b/wasmer/mobile/android/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+#   Gradle start up script for POSIX generated by Gradle.
+#
+#   Important for running:
+#
+#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+#       noncompliant, but you have some other compliant shell such as ksh or
+#       bash, then to run this script, type that shell name before the whole
+#       command line, like:
+#
+#           ksh Gradle
+#
+#       Busybox and similar reduced shells will NOT work, because this script
+#       requires all of these POSIX shell features:
+#         * functions;
+#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+#         * compound commands having a testable exit status, especially «case»;
+#         * various built-in commands including «command», «set», and «ulimit».
+#
+#   Important for patching:
+#
+#   (2) This script targets any POSIX shell, so it avoids extensions provided
+#       by Bash, Ksh, etc; in particular arrays are avoided.
+#
+#       The "traditional" practice of packing multiple parameters into a
+#       space-separated string is a well documented source of bugs and security
+#       problems, so this is (mostly) avoided, by progressively accumulating
+#       options in "$@", and eventually passing that to Java.
+#
+#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+#       see the in-line comments for details.
+#
+#       There are tweaks for specific operating systems such as AIX, CygWin,
+#       Darwin, MinGW, and NonStop.
+#
+#   (3) This script is generated from the Groovy template
+#       https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+#       within the Gradle project.
+#
+#       You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+    APP_HOME=${app_path%"${app_path##*/}"}  # leaves a trailing /; empty if no leading path
+    [ -h "$app_path" ]
+do
+    ls=$( ls -ld "$app_path" )
+    link=${ls#*' -> '}
+    case $link in             #(
+      /*)   app_path=$link ;; #(
+      *)    app_path=$APP_HOME$link ;;
+    esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+    echo "$*"
+} >&2
+
+die () {
+    echo
+    echo "$*"
+    echo
+    exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in                #(
+  CYGWIN* )         cygwin=true  ;; #(
+  Darwin* )         darwin=true  ;; #(
+  MSYS* | MINGW* )  msys=true    ;; #(
+  NONSTOP* )        nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+        # IBM's JDK on AIX uses strange locations for the executables
+        JAVACMD=$JAVA_HOME/jre/sh/java
+    else
+        JAVACMD=$JAVA_HOME/bin/java
+    fi
+    if [ ! -x "$JAVACMD" ] ; then
+        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+else
+    JAVACMD=java
+    if ! command -v java >/dev/null 2>&1
+    then
+        die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+    case $MAX_FD in #(
+      max*)
+        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC2039,SC3045
+        MAX_FD=$( ulimit -H -n ) ||
+            warn "Could not query maximum file descriptor limit"
+    esac
+    case $MAX_FD in  #(
+      '' | soft) :;; #(
+      *)
+        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC2039,SC3045
+        ulimit -n "$MAX_FD" ||
+            warn "Could not set maximum file descriptor limit to $MAX_FD"
+    esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+#   * args from the command line
+#   * the main class name
+#   * -classpath
+#   * -D...appname settings
+#   * --module-path (only if needed)
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+    APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+    CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+    JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
+    for arg do
+        if
+            case $arg in                                #(
+              -*)   false ;;                            # don't mess with options #(
+              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath
+                    [ -e "$t" ] ;;                      #(
+              *)    false ;;
+            esac
+        then
+            arg=$( cygpath --path --ignore --mixed "$arg" )
+        fi
+        # Roll the args list around exactly as many times as the number of
+        # args, so each arg winds up back in the position where it started, but
+        # possibly modified.
+        #
+        # NB: a `for` loop captures its iteration list before it begins, so
+        # changing the positional parameters here affects neither the number of
+        # iterations, nor the values presented in `arg`.
+        shift                   # remove old arg
+        set -- "$@" "$arg"      # push replacement arg
+    done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+#     and any embedded shellness will be escaped.
+#   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+#     treated as '${Hostname}' itself on the command line.
+
+set -- \
+        "-Dorg.gradle.appname=$APP_BASE_NAME" \
+        -classpath "$CLASSPATH" \
+        -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+        "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+    die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+#   readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+#   set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+        printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+        xargs -n1 |
+        sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+        tr '\n' ' '
+    )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/wasmer/mobile/android/gradlew.bat b/wasmer/mobile/android/gradlew.bat
new file mode 100644
index 0000000..db3a6ac
--- /dev/null
+++ b/wasmer/mobile/android/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem      https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem  Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/wasmer/mobile/android/run-emulator.sh b/wasmer/mobile/android/run-emulator.sh
new file mode 100755
index 0000000..e4384ac
--- /dev/null
+++ b/wasmer/mobile/android/run-emulator.sh
@@ -0,0 +1,119 @@
+#!/usr/bin/env bash
+# Android emulator/device run. Exit 2 = NOT_RUN (no SDK). Exit 1 = tests failed.
+set -euo pipefail
+
+HERE="$(cd "$(dirname "$0")" && pwd)"
+REPO="$(cd "$HERE/../../.." && pwd)"
+OUT_DIR="${ARTIFACTS_DIR:-$REPO/test-results/s-desktop-mobile}"
+mkdir -p "$OUT_DIR"
+TS="$(date -u +%Y%m%d-%H%M%S)"
+LOG="$OUT_DIR/mobile-android-$TS.log.txt"
+JSON="$OUT_DIR/mobile-android.json"
+
+{
+  echo "=== comPASS Android host ==="
+  echo "utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+} | tee "$LOG"
+
+SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
+if [[ -z "$SDK" && -d "${HOME}/Library/Android/sdk" ]]; then
+  SDK="${HOME}/Library/Android/sdk"
+fi
+
+if [[ -z "$SDK" || ! -d "$SDK" ]]; then
+  echo "[NOT_RUN] Android SDK not installed (ANDROID_HOME/ANDROID_SDK_ROOT unset; no Library/Android/sdk)" | tee -a "$LOG"
+  echo "[NOT_RUN] Host sources exist at wasmer/mobile/android/ — assembleDebug and emulator API 34 are human follow-up." | tee -a "$LOG"
+  python3 - "$JSON" <<'PY'
+import json, sys
+open(sys.argv[1], "w").write(json.dumps({
+    "ok": False,
+    "grade": "NOT_RUN",
+    "reason": "Android SDK missing",
+    "host": "android-webview",
+    "device": False,
+    "emulator": False,
+    "compile": False,
+}, indent=2) + "\n")
+PY
+  exit 2
+fi
+
+echo "ANDROID_SDK=$SDK" | tee -a "$LOG"
+export ANDROID_HOME="$SDK"
+export ANDROID_SDK_ROOT="$SDK"
+
+if [[ ! -f "$HERE/local.properties" ]]; then
+  printf 'sdk.dir=%s\n' "$SDK" > "$HERE/local.properties"
+fi
+
+"$HERE/../sync-assets.sh" 2>&1 | tee -a "$LOG"
+
+if [[ ! -x "$HERE/gradlew" ]]; then
+  echo "[NOT_RUN] gradlew missing" | tee -a "$LOG"
+  echo '{"ok":false,"grade":"NOT_RUN","reason":"gradlew missing"}' > "$JSON"
+  exit 2
+fi
+
+set +e
+"$HERE/gradlew" --no-daemon assembleDebug 2>&1 | tee -a "$LOG"
+ASM_RC=${PIPESTATUS[0]}
+set -e
+if [[ $ASM_RC -ne 0 ]]; then
+  echo "[FAIL] assembleDebug exit=$ASM_RC" | tee -a "$LOG"
+  echo "{\"ok\":false,\"grade\":\"FAIL\",\"reason\":\"assembleDebug\",\"exit\":$ASM_RC}" > "$JSON"
+  exit 1
+fi
+echo "[PASS] assembleDebug" | tee -a "$LOG"
+
+ADB="$SDK/platform-tools/adb"
+if [[ ! -x "$ADB" ]]; then
+  ADB="$(command -v adb || true)"
+fi
+DEVICES=""
+if [[ -n "$ADB" ]]; then
+  DEVICES="$("$ADB" devices 2>/dev/null | awk 'NR>1 && $2=="device"{print $1}')"
+fi
+if [[ -z "$DEVICES" ]]; then
+  echo "[NOT_RUN] assembleDebug succeeded; no emulator/device online (adb devices empty)" | tee -a "$LOG"
+  python3 - "$JSON" <<'PY'
+import json, sys
+open(sys.argv[1], "w").write(json.dumps({
+    "ok": False,
+    "grade": "NOT_RUN",
+    "reason": "no emulator or device online after assembleDebug",
+    "host": "android-webview",
+    "device": False,
+    "emulator": False,
+    "compile": True,
+}, indent=2) + "\n")
+PY
+  exit 2
+fi
+
+set +e
+"$HERE/gradlew" --no-daemon connectedDebugAndroidTest 2>&1 | tee -a "$LOG"
+TEST_RC=${PIPESTATUS[0]}
+set -e
+GRADE="FAIL"
+OK=false
+if [[ $TEST_RC -eq 0 ]]; then
+  GRADE="PARTIAL"
+  OK=true
+  echo "[PASS] connectedDebugAndroidTest (emulator/device)" | tee -a "$LOG"
+else
+  echo "[FAIL] connectedDebugAndroidTest exit=$TEST_RC" | tee -a "$LOG"
+fi
+python3 - "$JSON" "$OK" "$GRADE" "$TEST_RC" <<'PY'
+import json, sys
+ok = sys.argv[2].lower() == "true"
+open(sys.argv[1], "w").write(json.dumps({
+    "ok": ok,
+    "grade": sys.argv[3],
+    "connectedDebugAndroidTest_exit": int(sys.argv[4]),
+    "host": "android-webview",
+    "device": False,
+    "emulator": True,
+    "compile": True,
+}, indent=2) + "\n")
+PY
+exit $TEST_RC
diff --git a/wasmer/mobile/android/settings.gradle.kts b/wasmer/mobile/android/settings.gradle.kts
new file mode 100644
index 0000000..7b93849
--- /dev/null
+++ b/wasmer/mobile/android/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+    repositories {
+        google()
+        mavenCentral()
+        gradlePluginPortal()
+    }
+}
+
+dependencyResolutionManagement {
+    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+    repositories {
+        google()
+        mavenCentral()
+    }
+}
+
+rootProject.name = "CompassMobileHost"
+include(":app")
diff --git a/wasmer/mobile/ios/CompassMobileHost.xcodeproj/project.pbxproj b/wasmer/mobile/ios/CompassMobileHost.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..85516de
--- /dev/null
+++ b/wasmer/mobile/ios/CompassMobileHost.xcodeproj/project.pbxproj
@@ -0,0 +1,487 @@
+// !$*UTF8*$!
+{
+	archiveVersion = 1;
+	classes = {
+	};
+	objectVersion = 77;
+	objects = {
+
+/* Begin PBXBuildFile section */
+		095921C58727006C7BDF9ACB /* CompassMobileHostApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5A9159568227A1939753798 /* CompassMobileHostApp.swift */; };
+		6DC7FFB2E3A39A42BFA2E286 /* CompassWasmHarness.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE174EB8480D1EEDA62F3A08 /* CompassWasmHarness.swift */; };
+		73C790E9276656490B473B00 /* compass_core_bg.wasm in Resources */ = {isa = PBXBuildFile; fileRef = 0FA34AB03A9B4CBB0146C7EC /* compass_core_bg.wasm */; };
+		811BD141002EFDA0B1539978 /* EXPECTED_SHA256 in Resources */ = {isa = PBXBuildFile; fileRef = 9E7543E5EB425A7047BF22E1 /* EXPECTED_SHA256 */; };
+		93BA8E1BA2A016D0CBC51B47 /* snapshot_min.json in Resources */ = {isa = PBXBuildFile; fileRef = 3064F55732DDE578BDED6FE1 /* snapshot_min.json */; };
+		984769C2EE438007333EDB55 /* index.html in Resources */ = {isa = PBXBuildFile; fileRef = 818A581CC4C29B8E54274887 /* index.html */; };
+		AA36C3991E2321F5B0E387BD /* CompassMobileHostTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD884D64AB469F3504A58528 /* CompassMobileHostTests.swift */; };
+		D0B3E5A15235194ECD9AB9B7 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E688C1692D3DE030C9C96F4F /* ContentView.swift */; };
+		D1317A14B1660813415148B7 /* host.js in Resources */ = {isa = PBXBuildFile; fileRef = A6B8CAAFF8BA2AE87991710A /* host.js */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+		B82F5A0527571A5205C2F57A /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = A25801BAAA8EC4CB8A694E02 /* Project object */;
+			proxyType = 1;
+			remoteGlobalIDString = D5FC46BF06B193E1BEC7CEAA;
+			remoteInfo = CompassMobileHost;
+		};
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXFileReference section */
+		0FA34AB03A9B4CBB0146C7EC /* compass_core_bg.wasm */ = {isa = PBXFileReference; path = compass_core_bg.wasm; sourceTree = ""; };
+		106FF5BC72EFB10D52122078 /* CompassMobileHost.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = CompassMobileHost.app; sourceTree = BUILT_PRODUCTS_DIR; };
+		3064F55732DDE578BDED6FE1 /* snapshot_min.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = snapshot_min.json; sourceTree = ""; };
+		36825D53552E2DC1EA326F44 /* CompassMobileHostTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = CompassMobileHostTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+		818A581CC4C29B8E54274887 /* index.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = index.html; sourceTree = ""; };
+		9E7543E5EB425A7047BF22E1 /* EXPECTED_SHA256 */ = {isa = PBXFileReference; path = EXPECTED_SHA256; sourceTree = ""; };
+		A6B8CAAFF8BA2AE87991710A /* host.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = host.js; sourceTree = ""; };
+		AD884D64AB469F3504A58528 /* CompassMobileHostTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompassMobileHostTests.swift; sourceTree = ""; };
+		DE174EB8480D1EEDA62F3A08 /* CompassWasmHarness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompassWasmHarness.swift; sourceTree = ""; };
+		E688C1692D3DE030C9C96F4F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
+		F5A9159568227A1939753798 /* CompassMobileHostApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompassMobileHostApp.swift; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXGroup section */
+		2D953D72B2FAA836AD9889D4 /* Products */ = {
+			isa = PBXGroup;
+			children = (
+				106FF5BC72EFB10D52122078 /* CompassMobileHost.app */,
+				36825D53552E2DC1EA326F44 /* CompassMobileHostTests.xctest */,
+			);
+			name = Products;
+			sourceTree = "";
+		};
+		326F3802F485C293F3FC42E3 /* Resources */ = {
+			isa = PBXGroup;
+			children = (
+				0FA34AB03A9B4CBB0146C7EC /* compass_core_bg.wasm */,
+				9E7543E5EB425A7047BF22E1 /* EXPECTED_SHA256 */,
+				A6B8CAAFF8BA2AE87991710A /* host.js */,
+				818A581CC4C29B8E54274887 /* index.html */,
+				3064F55732DDE578BDED6FE1 /* snapshot_min.json */,
+			);
+			path = Resources;
+			sourceTree = "";
+		};
+		6F896F55C6539142937BEB5F /* Sources */ = {
+			isa = PBXGroup;
+			children = (
+				F5A9159568227A1939753798 /* CompassMobileHostApp.swift */,
+				DE174EB8480D1EEDA62F3A08 /* CompassWasmHarness.swift */,
+				E688C1692D3DE030C9C96F4F /* ContentView.swift */,
+			);
+			path = Sources;
+			sourceTree = "";
+		};
+		CBACE98D192E2ABF8D973E8C = {
+			isa = PBXGroup;
+			children = (
+				326F3802F485C293F3FC42E3 /* Resources */,
+				6F896F55C6539142937BEB5F /* Sources */,
+				CE4D967393EF57B994F8EDAF /* Tests */,
+				2D953D72B2FAA836AD9889D4 /* Products */,
+			);
+			sourceTree = "";
+		};
+		CE4D967393EF57B994F8EDAF /* Tests */ = {
+			isa = PBXGroup;
+			children = (
+				AD884D64AB469F3504A58528 /* CompassMobileHostTests.swift */,
+			);
+			path = Tests;
+			sourceTree = "";
+		};
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+		8F28080E2293A57D1D9895AC /* CompassMobileHostTests */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = 800F7C90A291D5EDF3D17E75 /* Build configuration list for PBXNativeTarget "CompassMobileHostTests" */;
+			buildPhases = (
+				1E6B357E443BB3C10E3D6D21 /* Sources */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+				E2E722BBF3D7BB51E8CDB0F4 /* PBXTargetDependency */,
+			);
+			name = CompassMobileHostTests;
+			packageProductDependencies = (
+			);
+			productName = CompassMobileHostTests;
+			productReference = 36825D53552E2DC1EA326F44 /* CompassMobileHostTests.xctest */;
+			productType = "com.apple.product-type.bundle.unit-test";
+		};
+		D5FC46BF06B193E1BEC7CEAA /* CompassMobileHost */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = F7A9BCA1C37D7635B8B8EA00 /* Build configuration list for PBXNativeTarget "CompassMobileHost" */;
+			buildPhases = (
+				01801DAC958D712495434F7B /* Sync compass_core_bg.wasm from SHA256SUMS */,
+				6B1F52F4303FCD5867D78378 /* Sources */,
+				6C1C745695927DDA9F9B715D /* Resources */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+			);
+			name = CompassMobileHost;
+			packageProductDependencies = (
+			);
+			productName = CompassMobileHost;
+			productReference = 106FF5BC72EFB10D52122078 /* CompassMobileHost.app */;
+			productType = "com.apple.product-type.application";
+		};
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+		A25801BAAA8EC4CB8A694E02 /* Project object */ = {
+			isa = PBXProject;
+			attributes = {
+				BuildIndependentTargetsInParallel = YES;
+				LastUpgradeCheck = 1430;
+				TargetAttributes = {
+					8F28080E2293A57D1D9895AC = {
+						DevelopmentTeam = "";
+					};
+					D5FC46BF06B193E1BEC7CEAA = {
+						DevelopmentTeam = "";
+					};
+				};
+			};
+			buildConfigurationList = 058243CF31AA42978D6197CD /* Build configuration list for PBXProject "CompassMobileHost" */;
+			compatibilityVersion = "Xcode 14.0";
+			developmentRegion = en;
+			hasScannedForEncodings = 0;
+			knownRegions = (
+				Base,
+				en,
+			);
+			mainGroup = CBACE98D192E2ABF8D973E8C;
+			minimizedProjectReferenceProxies = 1;
+			preferredProjectObjectVersion = 77;
+			projectDirPath = "";
+			projectRoot = "";
+			targets = (
+				D5FC46BF06B193E1BEC7CEAA /* CompassMobileHost */,
+				8F28080E2293A57D1D9895AC /* CompassMobileHostTests */,
+			);
+		};
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+		6C1C745695927DDA9F9B715D /* Resources */ = {
+			isa = PBXResourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				811BD141002EFDA0B1539978 /* EXPECTED_SHA256 in Resources */,
+				73C790E9276656490B473B00 /* compass_core_bg.wasm in Resources */,
+				D1317A14B1660813415148B7 /* host.js in Resources */,
+				984769C2EE438007333EDB55 /* index.html in Resources */,
+				93BA8E1BA2A016D0CBC51B47 /* snapshot_min.json in Resources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+		01801DAC958D712495434F7B /* Sync compass_core_bg.wasm from SHA256SUMS */ = {
+			isa = PBXShellScriptBuildPhase;
+			alwaysOutOfDate = 1;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputFileListPaths = (
+			);
+			inputPaths = (
+			);
+			name = "Sync compass_core_bg.wasm from SHA256SUMS";
+			outputFileListPaths = (
+			);
+			outputPaths = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "set -euo pipefail\n\"${SRCROOT}/../sync-assets.sh\"\n";
+		};
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+		1E6B357E443BB3C10E3D6D21 /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				AA36C3991E2321F5B0E387BD /* CompassMobileHostTests.swift in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		6B1F52F4303FCD5867D78378 /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				095921C58727006C7BDF9ACB /* CompassMobileHostApp.swift in Sources */,
+				6DC7FFB2E3A39A42BFA2E286 /* CompassWasmHarness.swift in Sources */,
+				D0B3E5A15235194ECD9AB9B7 /* ContentView.swift in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+		E2E722BBF3D7BB51E8CDB0F4 /* PBXTargetDependency */ = {
+			isa = PBXTargetDependency;
+			target = D5FC46BF06B193E1BEC7CEAA /* CompassMobileHost */;
+			targetProxy = B82F5A0527571A5205C2F57A /* PBXContainerItemProxy */;
+		};
+/* End PBXTargetDependency section */
+
+/* Begin XCBuildConfiguration section */
+		6513980F3401877D32AD13A8 /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				BUNDLE_LOADER = "$(TEST_HOST)";
+				CODE_SIGNING_ALLOWED = NO;
+				GENERATE_INFOPLIST_FILE = YES;
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+					"@loader_path/Frameworks",
+				);
+				PRODUCT_BUNDLE_IDENTIFIER = org.compass.wasmer.mobilehost.tests;
+				SDKROOT = iphoneos;
+				TARGETED_DEVICE_FAMILY = "1,2";
+				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CompassMobileHost.app/CompassMobileHost";
+			};
+			name = Release;
+		};
+		7165D7A415BEE55C79B472B5 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				CODE_SIGNING_ALLOWED = NO;
+				CODE_SIGNING_REQUIRED = NO;
+				CODE_SIGN_IDENTITY = "-";
+				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+				);
+				PRODUCT_BUNDLE_IDENTIFIER = org.compass.wasmer.mobilehost;
+				PRODUCT_NAME = CompassMobileHost;
+				SDKROOT = iphoneos;
+				TARGETED_DEVICE_FAMILY = "1,2";
+			};
+			name = Debug;
+		};
+		732D79F527BCFAC5B567DE0A /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				BUNDLE_LOADER = "$(TEST_HOST)";
+				CODE_SIGNING_ALLOWED = NO;
+				GENERATE_INFOPLIST_FILE = YES;
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+					"@loader_path/Frameworks",
+				);
+				PRODUCT_BUNDLE_IDENTIFIER = org.compass.wasmer.mobilehost.tests;
+				SDKROOT = iphoneos;
+				TARGETED_DEVICE_FAMILY = "1,2";
+				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CompassMobileHost.app/CompassMobileHost";
+			};
+			name = Debug;
+		};
+		83311337B836FAF67F8497C0 /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				CODE_SIGNING_ALLOWED = NO;
+				CODE_SIGNING_REQUIRED = NO;
+				CODE_SIGN_IDENTITY = "-";
+				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+				);
+				PRODUCT_BUNDLE_IDENTIFIER = org.compass.wasmer.mobilehost;
+				PRODUCT_NAME = CompassMobileHost;
+				SDKROOT = iphoneos;
+				TARGETED_DEVICE_FAMILY = "1,2";
+			};
+			name = Release;
+		};
+		EEE3F0F66D167ED6F6524E5F /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ALWAYS_SEARCH_USER_PATHS = NO;
+				CLANG_ANALYZER_NONNULL = YES;
+				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+				CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+				CLANG_CXX_LIBRARY = "libc++";
+				CLANG_ENABLE_MODULES = YES;
+				CLANG_ENABLE_OBJC_ARC = YES;
+				CLANG_ENABLE_OBJC_WEAK = YES;
+				CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+				CLANG_WARN_BOOL_CONVERSION = YES;
+				CLANG_WARN_COMMA = YES;
+				CLANG_WARN_CONSTANT_CONVERSION = YES;
+				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+				CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+				CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+				CLANG_WARN_EMPTY_BODY = YES;
+				CLANG_WARN_ENUM_CONVERSION = YES;
+				CLANG_WARN_INFINITE_RECURSION = YES;
+				CLANG_WARN_INT_CONVERSION = YES;
+				CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+				CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+				CLANG_WARN_STRICT_PROTOTYPES = YES;
+				CLANG_WARN_SUSPICIOUS_MOVE = YES;
+				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+				CLANG_WARN_UNREACHABLE_CODE = YES;
+				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+				CODE_SIGNING_ALLOWED = NO;
+				CODE_SIGNING_REQUIRED = NO;
+				CODE_SIGN_IDENTITY = "-";
+				COPY_PHASE_STRIP = NO;
+				DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+				DEVELOPMENT_TEAM = "";
+				ENABLE_NS_ASSERTIONS = NO;
+				ENABLE_STRICT_OBJC_MSGSEND = YES;
+				GCC_C_LANGUAGE_STANDARD = gnu11;
+				GCC_NO_COMMON_BLOCKS = YES;
+				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+				GCC_WARN_UNDECLARED_SELECTOR = YES;
+				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+				GCC_WARN_UNUSED_FUNCTION = YES;
+				GCC_WARN_UNUSED_VARIABLE = YES;
+				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_KEY_CFBundleDisplayName = "Compass Host";
+				INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+				INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
+				IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+				MTL_ENABLE_DEBUG_INFO = NO;
+				MTL_FAST_MATH = YES;
+				PRODUCT_BUNDLE_IDENTIFIER = org.compass.wasmer.mobilehost;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SDKROOT = iphoneos;
+				SWIFT_COMPILATION_MODE = wholemodule;
+				SWIFT_OPTIMIZATION_LEVEL = "-O";
+				SWIFT_VERSION = 5.0;
+				TARGETED_DEVICE_FAMILY = "1,2";
+			};
+			name = Release;
+		};
+		F6A44112606A82A66B67F1C5 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ALWAYS_SEARCH_USER_PATHS = NO;
+				CLANG_ANALYZER_NONNULL = YES;
+				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+				CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+				CLANG_CXX_LIBRARY = "libc++";
+				CLANG_ENABLE_MODULES = YES;
+				CLANG_ENABLE_OBJC_ARC = YES;
+				CLANG_ENABLE_OBJC_WEAK = YES;
+				CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+				CLANG_WARN_BOOL_CONVERSION = YES;
+				CLANG_WARN_COMMA = YES;
+				CLANG_WARN_CONSTANT_CONVERSION = YES;
+				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+				CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+				CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+				CLANG_WARN_EMPTY_BODY = YES;
+				CLANG_WARN_ENUM_CONVERSION = YES;
+				CLANG_WARN_INFINITE_RECURSION = YES;
+				CLANG_WARN_INT_CONVERSION = YES;
+				CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+				CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+				CLANG_WARN_STRICT_PROTOTYPES = YES;
+				CLANG_WARN_SUSPICIOUS_MOVE = YES;
+				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+				CLANG_WARN_UNREACHABLE_CODE = YES;
+				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+				CODE_SIGNING_ALLOWED = NO;
+				CODE_SIGNING_REQUIRED = NO;
+				CODE_SIGN_IDENTITY = "-";
+				COPY_PHASE_STRIP = NO;
+				DEBUG_INFORMATION_FORMAT = dwarf;
+				DEVELOPMENT_TEAM = "";
+				ENABLE_STRICT_OBJC_MSGSEND = YES;
+				ENABLE_TESTABILITY = YES;
+				GCC_C_LANGUAGE_STANDARD = gnu11;
+				GCC_DYNAMIC_NO_PIC = NO;
+				GCC_NO_COMMON_BLOCKS = YES;
+				GCC_OPTIMIZATION_LEVEL = 0;
+				GCC_PREPROCESSOR_DEFINITIONS = (
+					"$(inherited)",
+					"DEBUG=1",
+				);
+				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+				GCC_WARN_UNDECLARED_SELECTOR = YES;
+				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+				GCC_WARN_UNUSED_FUNCTION = YES;
+				GCC_WARN_UNUSED_VARIABLE = YES;
+				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_KEY_CFBundleDisplayName = "Compass Host";
+				INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+				INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
+				IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+				MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+				MTL_FAST_MATH = YES;
+				ONLY_ACTIVE_ARCH = YES;
+				PRODUCT_BUNDLE_IDENTIFIER = org.compass.wasmer.mobilehost;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SDKROOT = iphoneos;
+				SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+				SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+				SWIFT_VERSION = 5.0;
+				TARGETED_DEVICE_FAMILY = "1,2";
+			};
+			name = Debug;
+		};
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+		058243CF31AA42978D6197CD /* Build configuration list for PBXProject "CompassMobileHost" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				F6A44112606A82A66B67F1C5 /* Debug */,
+				EEE3F0F66D167ED6F6524E5F /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Debug;
+		};
+		800F7C90A291D5EDF3D17E75 /* Build configuration list for PBXNativeTarget "CompassMobileHostTests" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				732D79F527BCFAC5B567DE0A /* Debug */,
+				6513980F3401877D32AD13A8 /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Debug;
+		};
+		F7A9BCA1C37D7635B8B8EA00 /* Build configuration list for PBXNativeTarget "CompassMobileHost" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				7165D7A415BEE55C79B472B5 /* Debug */,
+				83311337B836FAF67F8497C0 /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Debug;
+		};
+/* End XCConfigurationList section */
+	};
+	rootObject = A25801BAAA8EC4CB8A694E02 /* Project object */;
+}
diff --git a/wasmer/mobile/ios/CompassMobileHost.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/wasmer/mobile/ios/CompassMobileHost.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/wasmer/mobile/ios/CompassMobileHost.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+   
+   
+
diff --git a/wasmer/mobile/ios/CompassMobileHost.xcodeproj/xcshareddata/xcschemes/CompassMobileHost.xcscheme b/wasmer/mobile/ios/CompassMobileHost.xcodeproj/xcshareddata/xcschemes/CompassMobileHost.xcscheme
new file mode 100644
index 0000000..eeff306
--- /dev/null
+++ b/wasmer/mobile/ios/CompassMobileHost.xcodeproj/xcshareddata/xcschemes/CompassMobileHost.xcscheme
@@ -0,0 +1,106 @@
+
+
+   
+      
+         
+            
+            
+         
+      
+   
+   
+      
+         
+         
+      
+      
+         
+            
+            
+         
+      
+      
+      
+   
+   
+      
+         
+         
+      
+      
+      
+   
+   
+      
+         
+         
+      
+      
+      
+   
+   
+   
+   
+   
+
diff --git a/wasmer/mobile/ios/README.md b/wasmer/mobile/ios/README.md
new file mode 100644
index 0000000..df1b072
--- /dev/null
+++ b/wasmer/mobile/ios/README.md
@@ -0,0 +1,27 @@
+# iOS WKWebView host
+
+Minimal iOS 17+ app that:
+
+1. Reads `compass_core_bg.wasm` from the app bundle.
+2. Verifies SHA-256 against `EXPECTED_SHA256` (copied from `wasmer/artifacts/SHA256SUMS` by `../sync-assets.sh`).
+3. Serves the shared `index.html` / `host.js` over a `compasshost://` scheme.
+4. Instantiates the module (`WebAssembly.instantiate`, empty import table) and calls `compass_decide_json`.
+
+Parity (same as `scripts/wasmer_parity.py`):
+
+- fixture snapshot → `selected_model_version_id = urn:mg:model:cheap`
+- missing snapshot → `default_reason = snapshot_missing`
+
+```bash
+# from repo root
+./wasmer/mobile/sync-assets.sh
+./wasmer/mobile/ios/run-simulator.sh
+```
+
+Requires Xcode and an available iPhone simulator. `CODE_SIGNING_ALLOWED=NO` is for simulator only; a physical device needs a development team (not claimed FULL without a device log).
+
+Regenerate the Xcode project after editing `project.yml`:
+
+```bash
+cd wasmer/mobile/ios && xcodegen generate
+```
diff --git a/wasmer/mobile/ios/Resources/.gitkeep b/wasmer/mobile/ios/Resources/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/wasmer/mobile/ios/Sources/CompassMobileHostApp.swift b/wasmer/mobile/ios/Sources/CompassMobileHostApp.swift
new file mode 100644
index 0000000..5ebe8ba
--- /dev/null
+++ b/wasmer/mobile/ios/Sources/CompassMobileHostApp.swift
@@ -0,0 +1,10 @@
+import SwiftUI
+
+@main
+struct CompassMobileHostApp: App {
+    var body: some Scene {
+        WindowGroup {
+            ContentView()
+        }
+    }
+}
diff --git a/wasmer/mobile/ios/Sources/CompassWasmHarness.swift b/wasmer/mobile/ios/Sources/CompassWasmHarness.swift
new file mode 100644
index 0000000..1c102fe
--- /dev/null
+++ b/wasmer/mobile/ios/Sources/CompassWasmHarness.swift
@@ -0,0 +1,229 @@
+import CryptoKit
+import Foundation
+import UIKit
+import WebKit
+
+enum CompassHostError: Error, LocalizedError {
+    case missingResource(String)
+    case digestMismatch(expected: String, actual: String)
+    case jsError(String)
+
+    var errorDescription: String? {
+        switch self {
+        case let .missingResource(name):
+            return "missing bundle resource: \(name)"
+        case let .digestMismatch(expected, actual):
+            return "digest mismatch expected=\(expected) actual=\(actual)"
+        case let .jsError(msg):
+            return msg
+        }
+    }
+}
+
+struct CompassParityReport {
+    var ok: Bool
+    var expectedSha256: String
+    var actualSha256: String
+    var digestMatch: Bool
+    var selectedModelVersionId: String?
+    var missingReason: String?
+    var error: String?
+    var raw: [String: Any]
+
+    static func fromJS(_ obj: [String: Any], nativeActual: String) -> CompassParityReport {
+        let fixture = obj["fixture_min"] as? [String: Any]
+        let missing = obj["missing"] as? [String: Any]
+        return CompassParityReport(
+            ok: obj["ok"] as? Bool ?? false,
+            expectedSha256: obj["expected_sha256"] as? String ?? "",
+            actualSha256: (obj["actual_sha256"] as? String) ?? nativeActual,
+            digestMatch: obj["digest_match"] as? Bool ?? false,
+            selectedModelVersionId: fixture?["selected_model_version_id"] as? String,
+            missingReason: missing?["default_reason"] as? String,
+            error: obj["error"] as? String,
+            raw: obj
+        )
+    }
+}
+
+/// iOS host: SHA-256 pin from SHA256SUMS, then WKWebView + WebAssembly.instantiate.
+final class CompassWasmHarness {
+    static let expectedResource = "EXPECTED_SHA256"
+    static let wasmResource = "compass_core_bg"
+    static let wasmExt = "wasm"
+
+    let bundle: Bundle
+
+    init(bundle: Bundle = .main) {
+        self.bundle = bundle
+    }
+
+    func expectedDigest() throws -> String {
+        guard let url = bundle.url(forResource: Self.expectedResource, withExtension: nil)
+                ?? bundle.url(forResource: Self.expectedResource, withExtension: "txt") else {
+            throw CompassHostError.missingResource(Self.expectedResource)
+        }
+        return try String(contentsOf: url, encoding: .utf8)
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+            .lowercased()
+    }
+
+    func wasmData() throws -> Data {
+        guard let url = bundle.url(forResource: Self.wasmResource, withExtension: Self.wasmExt) else {
+            throw CompassHostError.missingResource("\(Self.wasmResource).\(Self.wasmExt)")
+        }
+        return try Data(contentsOf: url)
+    }
+
+    func sha256Hex(_ data: Data) -> String {
+        SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+    }
+
+    @discardableResult
+    func verifyNativeDigest() throws -> String {
+        let expected = try expectedDigest()
+        let actual = sha256Hex(try wasmData())
+        if actual != expected {
+            throw CompassHostError.digestMismatch(expected: expected, actual: actual)
+        }
+        return actual
+    }
+
+    func makeWebView(messageHandler: WKScriptMessageHandler) throws -> WKWebView {
+        _ = try verifyNativeDigest()
+        let config = WKWebViewConfiguration()
+        config.defaultWebpagePreferences.allowsContentJavaScript = true
+        config.userContentController.addUserScript(try makeInjectScript())
+        config.userContentController.addUserScript(try makeHostScript())
+        config.userContentController.add(messageHandler, name: "compassResult")
+        let wv = WKWebView(frame: CGRect(x: 0, y: 0, width: 390, height: 844), configuration: config)
+        wv.accessibilityIdentifier = "compassWebView"
+        if #available(iOS 16.4, *) {
+            wv.isInspectable = true
+        }
+        return wv
+    }
+
+    func makeInjectScript() throws -> WKUserScript {
+        let wasm = try wasmData()
+        let expected = try expectedDigest()
+        let actual = sha256Hex(wasm)
+        var snapshot = "{}"
+        if let url = bundle.url(forResource: "snapshot_min", withExtension: "json") {
+            snapshot = try String(contentsOf: url, encoding: .utf8)
+        }
+        let payload: [String: Any] = [
+            "expectedSha256": expected,
+            "wasmB64": wasm.base64EncodedString(),
+            "snapshotJson": snapshot,
+            "nowIso": "2026-09-05T00:00:00Z",
+            "nativeDigestMatch": actual == expected,
+            "nativeActualSha256": actual,
+        ]
+        let data = try JSONSerialization.data(withJSONObject: payload)
+        guard let json = String(data: data, encoding: .utf8) else {
+            throw CompassHostError.jsError("inject JSON utf-8")
+        }
+        let source = "window.__COMPASS_INJECT = \(json);"
+        return WKUserScript(source: source, injectionTime: .atDocumentStart, forMainFrameOnly: true)
+    }
+
+    func makeHostScript() throws -> WKUserScript {
+        let js = try String(contentsOf: try resourceURL(name: "host", ext: "js"), encoding: .utf8)
+        return WKUserScript(source: js, injectionTime: .atDocumentStart, forMainFrameOnly: true)
+    }
+
+    func resourceURL(name: String, ext: String) throws -> URL {
+        guard let url = bundle.url(forResource: name, withExtension: ext) else {
+            throw CompassHostError.missingResource("\(name).\(ext)")
+        }
+        return url
+    }
+
+    func load(into webView: WKWebView) {
+        // Injected host.js + wasm bytes; no custom-scheme fetch (WKWebView fetch status 0).
+        let html = """
+        
+        
booting
+ """ + webView.loadHTMLString(html, baseURL: nil) + } + + /// Run decide parity inside WKWebView and return the JS report. + func runParity(timeoutSeconds: TimeInterval = 45) async throws -> CompassParityReport { + let nativeActual = try verifyNativeDigest() + let box = MessageBox() + let wv = try makeWebView(messageHandler: box) + box.retainWebView = wv + let window = UIWindow(frame: UIScreen.main.bounds) + let root = UIViewController() + root.view.backgroundColor = .white + wv.frame = root.view.bounds + wv.autoresizingMask = [.flexibleWidth, .flexibleHeight] + root.view.addSubview(wv) + window.rootViewController = root + window.makeKeyAndVisible() + box.retainWindow = window + load(into: wv) + let raw = try await box.awaitMessage(timeout: timeoutSeconds) + let report = CompassParityReport.fromJS(raw, nativeActual: nativeActual) + print("COMPASS_MOBILE_RESULT_BEGIN") + if let data = try? JSONSerialization.data(withJSONObject: raw, options: [.prettyPrinted]), + let text = String(data: data, encoding: .utf8) { + print(text) + } + print("COMPASS_MOBILE_RESULT_END") + print("COMPASS_MOBILE_NATIVE_SHA256=\(nativeActual)") + CompassResultStore.write(raw) + return report + } +} + +final class CompassResultStore { + static let fileName = "compass-parity.json" + + static func write(_ obj: [String: Any]) { + guard JSONSerialization.isValidJSONObject(obj), + let data = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]) else { + return + } + let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + try? data.write(to: dir.appendingPathComponent(fileName), options: .atomic) + } +} + +final class MessageBox: NSObject, WKScriptMessageHandler { + var retainWebView: WKWebView? + var retainWindow: UIWindow? + private var continuation: CheckedContinuation<[String: Any], Error>? + private var timer: Timer? + + func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) { + timer?.invalidate() + if let dict = message.body as? [String: Any] { + continuation?.resume(returning: dict) + } else if let s = message.body as? String, + let data = s.data(using: .utf8), + let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + continuation?.resume(returning: dict) + } else { + continuation?.resume(throwing: CompassHostError.jsError("unexpected message \(message.body)")) + } + continuation = nil + } + + func awaitMessage(timeout: TimeInterval) async throws -> [String: Any] { + try await withCheckedThrowingContinuation { cont in + self.continuation = cont + DispatchQueue.main.async { + self.timer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { [weak self] _ in + self?.continuation?.resume(throwing: CompassHostError.jsError("timeout waiting for compassResult")) + self?.continuation = nil + } + } + } + } +} diff --git a/wasmer/mobile/ios/Sources/ContentView.swift b/wasmer/mobile/ios/Sources/ContentView.swift new file mode 100644 index 0000000..d626902 --- /dev/null +++ b/wasmer/mobile/ios/Sources/ContentView.swift @@ -0,0 +1,77 @@ +import SwiftUI +import WebKit + +struct ContentView: View { + @State private var status = "verifying digest…" + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("comPASS mobile host") + .font(.headline) + Text(status) + .font(.caption) + .accessibilityIdentifier("compassStatus") + CompassWebView(status: $status) + } + .padding() + } +} + +struct CompassWebView: UIViewRepresentable { + @Binding var status: String + + func makeCoordinator() -> Coordinator { + Coordinator(status: $status) + } + + func makeUIView(context: Context) -> WKWebView { + context.coordinator.makeWebView() + } + + func updateUIView(_ uiView: WKWebView, context: Context) {} + + final class Coordinator: NSObject, WKScriptMessageHandler { + var status: Binding + var harness: CompassWasmHarness? + var webView: WKWebView? + + init(status: Binding) { + self.status = status + } + + func makeWebView() -> WKWebView { + let harness = CompassWasmHarness() + self.harness = harness + do { + try harness.verifyNativeDigest() + let wv = try harness.makeWebView(messageHandler: self) + self.webView = wv + harness.load(into: wv) + status.wrappedValue = "module loading" + return wv + } catch { + status.wrappedValue = "digest/load failed: \(error)" + return WKWebView() + } + } + + func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) { + if let body = message.body as? [String: Any], + let ok = body["ok"] as? Bool { + let selected = (body["fixture_min"] as? [String: Any])?["selected_model_version_id"] as? String ?? "?" + let missing = (body["missing"] as? [String: Any])?["default_reason"] as? String ?? "?" + status.wrappedValue = ok + ? "ok fixture=\(selected) missing=\(missing)" + : "fail \(body["error"] ?? "unknown")" + } else { + status.wrappedValue = String(describing: message.body) + } + if let body = message.body as? [String: Any] { + CompassResultStore.write(body) + } + } + } +} diff --git a/wasmer/mobile/ios/Tests/CompassMobileHostTests.swift b/wasmer/mobile/ios/Tests/CompassMobileHostTests.swift new file mode 100644 index 0000000..3c10560 --- /dev/null +++ b/wasmer/mobile/ios/Tests/CompassMobileHostTests.swift @@ -0,0 +1,25 @@ +import XCTest +@testable import CompassMobileHost + +final class CompassMobileHostTests: XCTestCase { + @MainActor + func testWasmDigestMatchesSHA256SUMSPin() throws { + let harness = CompassWasmHarness() + let actual = try harness.verifyNativeDigest() + XCTAssertEqual(actual.count, 64) + XCTAssertEqual(actual, try harness.expectedDigest()) + } + + @MainActor + func testDecideParityFixtureMinAndMissingSnapshot() async throws { + let harness = CompassWasmHarness() + let report = try await harness.runParity(timeoutSeconds: 60) + XCTAssertTrue(report.digestMatch || report.actualSha256 == (try harness.expectedDigest()), + "digest must match SHA256SUMS pin") + XCTAssertEqual(report.selectedModelVersionId, "urn:mg:model:cheap", + "fixture_min must match scripts/wasmer_parity.py") + XCTAssertEqual(report.missingReason, "snapshot_missing", + "missing snapshot must match Python fail-open") + XCTAssertTrue(report.ok, report.error ?? "parity report ok=false") + } +} diff --git a/wasmer/mobile/ios/project.yml b/wasmer/mobile/ios/project.yml new file mode 100644 index 0000000..1173bec --- /dev/null +++ b/wasmer/mobile/ios/project.yml @@ -0,0 +1,71 @@ +name: CompassMobileHost +options: + bundleIdPrefix: org.compass.wasmer + deploymentTarget: + iOS: "17.0" + createIntermediateGroups: true + defaultConfig: Debug +settings: + PRODUCT_BUNDLE_IDENTIFIER: org.compass.wasmer.mobilehost + GENERATE_INFOPLIST_FILE: YES + INFOPLIST_KEY_UILaunchScreen_Generation: YES + INFOPLIST_KEY_CFBundleDisplayName: Compass Host + INFOPLIST_KEY_UISupportedInterfaceOrientations: UIInterfaceOrientationPortrait + CODE_SIGN_IDENTITY: "-" + CODE_SIGNING_REQUIRED: NO + CODE_SIGNING_ALLOWED: NO + DEVELOPMENT_TEAM: "" + SWIFT_VERSION: "5.0" + TARGETED_DEVICE_FAMILY: "1,2" + IPHONEOS_DEPLOYMENT_TARGET: "17.0" +targets: + CompassMobileHost: + type: application + platform: iOS + sources: + - path: Sources + - path: Resources + optional: true + buildPhase: resources + excludes: + - ".gitkeep" + preBuildScripts: + - name: Sync compass_core_bg.wasm from SHA256SUMS + basedOnDependencyAnalysis: false + script: | + set -euo pipefail + "${SRCROOT}/../sync-assets.sh" + settings: + PRODUCT_BUNDLE_IDENTIFIER: org.compass.wasmer.mobilehost + GENERATE_INFOPLIST_FILE: YES + INFOPLIST_KEY_UILaunchScreen_Generation: YES + CODE_SIGN_IDENTITY: "-" + CODE_SIGNING_REQUIRED: NO + CODE_SIGNING_ALLOWED: NO + PRODUCT_NAME: CompassMobileHost + scheme: + testTargets: + - CompassMobileHostTests + CompassMobileHostTests: + type: bundle.unit-test + platform: iOS + sources: + - path: Tests + dependencies: + - target: CompassMobileHost + settings: + GENERATE_INFOPLIST_FILE: YES + PRODUCT_BUNDLE_IDENTIFIER: org.compass.wasmer.mobilehost.tests + CODE_SIGNING_ALLOWED: NO + TEST_HOST: "$(BUILT_PRODUCTS_DIR)/CompassMobileHost.app/CompassMobileHost" + BUNDLE_LOADER: "$(TEST_HOST)" +schemes: + CompassMobileHost: + build: + targets: + CompassMobileHost: all + CompassMobileHostTests: [test] + test: + targets: + - CompassMobileHostTests + gatherCoverageData: false diff --git a/wasmer/mobile/ios/run-simulator.sh b/wasmer/mobile/ios/run-simulator.sh new file mode 100755 index 0000000..5e675b7 --- /dev/null +++ b/wasmer/mobile/ios/run-simulator.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# Boot an available iPhone simulator and run CompassMobileHostTests. +# Exit 2 if Xcode/simulator is missing (honest NOT_RUN). Exit 1 on test fail. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO="$(cd "$HERE/../../.." && pwd)" +OUT_DIR="${ARTIFACTS_DIR:-$REPO/test-results/s-desktop-mobile}" +mkdir -p "$OUT_DIR" + +stamp() { date -u +%Y%m%d-%H%M%S; } +TS="$(stamp)" +LOG="$OUT_DIR/mobile-ios-simulator-$TS.log.txt" +JSON="$OUT_DIR/mobile-ios-simulator.json" + +{ + echo "=== comPASS iOS simulator host ===" + echo "utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "repo-relative=wasmer/mobile/ios" +} | tee "$LOG" + +if ! command -v xcodebuild >/dev/null 2>&1; then + echo "[NOT_RUN] xcodebuild not on PATH" | tee -a "$LOG" + echo '{"ok":false,"grade":"NOT_RUN","reason":"xcodebuild missing"}' > "$JSON" + exit 2 +fi +if ! command -v xcrun >/dev/null 2>&1; then + echo "[NOT_RUN] xcrun not on PATH" | tee -a "$LOG" + echo '{"ok":false,"grade":"NOT_RUN","reason":"xcrun missing"}' > "$JSON" + exit 2 +fi + +echo "xcodebuild=$(xcodebuild -version 2>/dev/null | tr '\n' ' ')" | tee -a "$LOG" + +"$HERE/../sync-assets.sh" 2>&1 | tee -a "$LOG" + +if ! command -v xcodegen >/dev/null 2>&1; then + if [[ ! -f "$HERE/CompassMobileHost.xcodeproj/project.pbxproj" ]]; then + echo "[NOT_RUN] xcodegen missing and no generated xcodeproj" | tee -a "$LOG" + echo '{"ok":false,"grade":"NOT_RUN","reason":"xcodegen missing"}' > "$JSON" + exit 2 + fi +else + (cd "$HERE" && xcodegen generate) 2>&1 | tee -a "$LOG" +fi + +pick_udid() { + python3 - <<'PY' +import json, subprocess, sys +raw = subprocess.check_output(["xcrun", "simctl", "list", "devices", "available", "-j"], text=True) +data = json.loads(raw) +preferred = [] +fallback = [] +for runtime, devices in data.get("devices", {}).items(): + for dev in devices: + if not dev.get("isAvailable"): + continue + name = dev.get("name") or "" + if "iPhone" not in name: + continue + rec = (runtime, name, dev.get("udid")) + if "iOS-18" in runtime or "iOS 18" in runtime: + preferred.append(rec) + else: + fallback.append(rec) +order = preferred + fallback +if not order: + sys.exit(1) +runtime, name, udid = order[0] +print(udid) +print(name, file=sys.stderr) +print(runtime, file=sys.stderr) +PY +} + +set +e +PICK="$(pick_udid 2>"$OUT_DIR/mobile-ios-sim-pick.err.txt")" +PICK_RC=$? +set -e +if [[ $PICK_RC -ne 0 || -z "${PICK:-}" ]]; then + echo "[NOT_RUN] no available iPhone simulator" | tee -a "$LOG" + cat "$OUT_DIR/mobile-ios-sim-pick.err.txt" >> "$LOG" || true + echo '{"ok":false,"grade":"NOT_RUN","reason":"no iPhone simulator runtime"}' > "$JSON" + exit 2 +fi +UDID="$PICK" +echo "simulator_udid=$UDID" | tee -a "$LOG" +cat "$OUT_DIR/mobile-ios-sim-pick.err.txt" | tee -a "$LOG" || true + +xcrun simctl boot "$UDID" 2>&1 | tee -a "$LOG" || true +xcrun simctl bootstatus "$UDID" -b 2>&1 | tee -a "$LOG" + +BUNDLE_ID="org.compass.wasmer.mobilehost" +CFG_DIR="$HERE/build/Debug-iphonesimulator" +mkdir -p "$CFG_DIR" + +echo "--- build -sdk iphonesimulator (no scheme destination; Xcode 26.2 may lack a matching runtime dest) ---" | tee -a "$LOG" +set +e +xcodebuild \ + -project "$HERE/CompassMobileHost.xcodeproj" \ + -target CompassMobileHost \ + -sdk iphonesimulator \ + -arch arm64 \ + -configuration Debug \ + CONFIGURATION_BUILD_DIR="$CFG_DIR" \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + build \ + 2>&1 | tee -a "$LOG" +BUILD_RC=${PIPESTATUS[0]} +set -e +if [[ $BUILD_RC -ne 0 ]]; then + echo "[FAIL] simulator SDK build exit=$BUILD_RC" | tee -a "$LOG" + echo "{\"ok\":false,\"grade\":\"FAIL\",\"reason\":\"xcodebuild build\",\"exit\":$BUILD_RC,\"simulator_udid\":\"$UDID\"}" > "$JSON" + exit 1 +fi + +APP="$CFG_DIR/CompassMobileHost.app" +if [[ ! -d "$APP" ]]; then + echo "[FAIL] missing $APP" | tee -a "$LOG" + echo '{"ok":false,"grade":"FAIL","reason":"app bundle missing after build"}' > "$JSON" + exit 1 +fi +echo "[PASS] built $APP" | tee -a "$LOG" + +xcrun simctl uninstall "$UDID" "$BUNDLE_ID" 2>/dev/null || true +xcrun simctl install "$UDID" "$APP" 2>&1 | tee -a "$LOG" +xcrun simctl launch "$UDID" "$BUNDLE_ID" 2>&1 | tee -a "$LOG" + +echo "--- poll Documents/compass-parity.json ---" | tee -a "$LOG" +REPORT="" +for i in $(seq 1 45); do + DATA="$(xcrun simctl get_app_container "$UDID" "$BUNDLE_ID" data 2>/dev/null || true)" + CAND="${DATA:-}/Documents/compass-parity.json" + if [[ -n "$DATA" && -f "$CAND" ]]; then + REPORT="$CAND" + echo "[PASS] result file after ${i}s: $CAND" | tee -a "$LOG" + break + fi + sleep 2 +done + +python3 - "$LOG" "$JSON" "$UDID" "${REPORT:-}" <<'PY' +import json, os, sys +log_path, json_path, udid, report_path = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] +report = None +if report_path and os.path.isfile(report_path): + report = json.loads(open(report_path, encoding="utf-8").read()) +ok = False +reason = None +if not report: + reason = "WKWebView did not write compass-parity.json (timeout). Xcode 26.2 scheme destinations were unavailable; simctl install/launch was used." + ok = False +else: + cheap = (report.get("fixture_min") or {}).get("selected_model_version_id") == "urn:mg:model:cheap" + miss = (report.get("missing") or {}).get("default_reason") == "snapshot_missing" + ok = bool(report.get("ok")) and cheap and miss + if not ok: + reason = report.get("error") or "parity mismatch" +out = { + "ok": ok, + "grade": "PARTIAL" if ok else "FAIL", + "simulator_udid": udid, + "host": "ios-wkwebview-simulator", + "device": False, + "emulator": True, + "run_path": "simctl-install-launch", + "js_report": report, + "reason": reason, +} +open(json_path, "w", encoding="utf-8").write(json.dumps(out, indent=2) + "\n") +print(json.dumps({"ok": out["ok"], "grade": out["grade"]})) +sys.exit(0 if ok else 1) +PY +PY_RC=$? +echo "evidence_log=$LOG" | tee -a "$LOG" +echo "evidence_json=$JSON" | tee -a "$LOG" +exit "$PY_RC" diff --git a/wasmer/mobile/run-hosts.sh b/wasmer/mobile/run-hosts.sh new file mode 100755 index 0000000..e18b33c --- /dev/null +++ b/wasmer/mobile/run-hosts.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Orchestrate B4 mobile hosts: glue check, size budget, iOS simulator, Android. +# Does not fake green. Missing emulator/SDK → NOT_RUN (exit 2 from child is allowed). +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" +OUT_DIR="${ARTIFACTS_DIR:-$REPO/test-results/s-desktop-mobile}" +mkdir -p "$OUT_DIR" +TS="$(date -u +%Y%m%d-%H%M%S)" +LOG="$OUT_DIR/mobile-hosts-$TS.log.txt" + +exec > >(tee "$LOG") 2>&1 + +echo "=== comPASS mobile hosts ===" +echo "utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +cd "$REPO" +chmod +x "$HERE/sync-assets.sh" \ + "$HERE/ios/run-simulator.sh" \ + "$HERE/android/run-emulator.sh" \ + "$REPO/scripts/validate-wasmer-mobile.sh" \ + "$REPO/scripts/wasmer_mobile_glue_check.mjs" 2>/dev/null || true + +echo "--- size budget ---" +python3 "$REPO/scripts/wasmer_size_budget.py" +echo "[PASS] size budget (browser cdylib shared with mobile)" + +echo "--- shared glue (Node; not a device run) ---" +node "$REPO/scripts/wasmer_mobile_glue_check.mjs" + +IOS_GRADE="NOT_RUN" +AND_GRADE="NOT_RUN" +set +e +"$HERE/ios/run-simulator.sh" +IOS_RC=$? +set -e +if [[ $IOS_RC -eq 0 ]]; then + IOS_GRADE="PARTIAL" + echo "[PASS] iOS simulator" +elif [[ $IOS_RC -eq 2 ]]; then + echo "[NOT_RUN] iOS simulator skipped/unavailable" +else + echo "[FAIL] iOS simulator tests" +fi + +set +e +"$HERE/android/run-emulator.sh" +AND_RC=$? +set -e +if [[ $AND_RC -eq 0 ]]; then + AND_GRADE="PARTIAL" + echo "[PASS] Android emulator/device" +elif [[ $AND_RC -eq 2 ]]; then + echo "[NOT_RUN] Android SDK/emulator unavailable" +else + echo "[FAIL] Android host" +fi + +python3 - "$OUT_DIR/mobile-hosts-summary.json" "$IOS_GRADE" "$AND_GRADE" "$IOS_RC" "$AND_RC" <<'PY' +import json, sys +ios_g, and_g, ios_rc, and_rc = sys.argv[2], sys.argv[3], int(sys.argv[4]), int(sys.argv[5]) +# FULL requires a physical device log — this runner never claims FULL. +overall = "NOT_RUN" +if ios_g == "PARTIAL" or and_g == "PARTIAL": + overall = "PARTIAL" +if ios_rc not in (0, 2) or and_rc not in (0, 2): + overall = "FAIL" +open(sys.argv[1], "w").write(json.dumps({ + "overall": overall, + "full_requires_physical_device": True, + "ios": {"grade": ios_g, "exit": ios_rc}, + "android": {"grade": and_g, "exit": and_rc}, + "shared_glue": "see mobile-glue-check.json", +}, indent=2) + "\n") +print("overall", overall) +PY + +# Fail the script only on hard FAIL (not NOT_RUN). +if [[ $IOS_RC -ne 0 && $IOS_RC -ne 2 ]]; then + exit "$IOS_RC" +fi +if [[ $AND_RC -ne 0 && $AND_RC -ne 2 ]]; then + exit "$AND_RC" +fi +echo "log=$LOG" +exit 0 diff --git a/wasmer/mobile/shared/EXPECTED_SHA256 b/wasmer/mobile/shared/EXPECTED_SHA256 new file mode 100644 index 0000000..71d3010 --- /dev/null +++ b/wasmer/mobile/shared/EXPECTED_SHA256 @@ -0,0 +1 @@ +9ad58acccd85e361baf9a789cdd82e95cb264dd9ddc9691236200c6ceb2507db diff --git a/wasmer/mobile/shared/host.js b/wasmer/mobile/shared/host.js new file mode 100644 index 0000000..db71e9c --- /dev/null +++ b/wasmer/mobile/shared/host.js @@ -0,0 +1,288 @@ +/* comPASS mobile host glue — same ABI as wasmer/browser/sandbox.js. + * Loads compass_core_bg.wasm by SHA-256, sanitizes a keyless snapshot, + * calls compass_decide_json, reports reason codes for Python parity. + * No provider keys. Empty import table. Fail-open on trap. + * + * Works in: iOS WKWebView, Android WebView, Node (scripts/wasmer_mobile_glue_check.mjs). + */ +(function (global) { + "use strict"; + + var NOW_ISO = "2026-09-05T00:00:00Z"; + var FIXTURE_REQUEST = "implement a function"; + var MISSING_REQUEST = "x"; + var KEY_FIELD = /api[_-]?key|token|secret|authorization|password|credential/i; + + function hexFromBytes(u8) { + var out = ""; + for (var i = 0; i < u8.length; i++) { + var h = u8[i].toString(16); + out += h.length === 1 ? "0" + h : h; + } + return out; + } + + function sha256Hex(bytes) { + var buf = bytes instanceof ArrayBuffer ? bytes : bytes.buffer; + if (bytes instanceof Uint8Array) { + buf = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + } + if (global.crypto && global.crypto.subtle && typeof global.crypto.subtle.digest === "function") { + return global.crypto.subtle.digest("SHA-256", buf).then(function (hash) { + return hexFromBytes(new Uint8Array(hash)); + }); + } + if (typeof process !== "undefined" && process.versions && process.versions.node) { + return Promise.resolve().then(function () { + var nodeCrypto = require("crypto"); + var u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(buf); + return nodeCrypto.createHash("sha256").update(u8).digest("hex"); + }); + } + return Promise.resolve(null); + } + + function sanitizeValue(value) { + if (Array.isArray(value)) { + return value.map(sanitizeValue); + } + if (value && typeof value === "object") { + var out = {}; + var keys = Object.keys(value); + for (var i = 0; i < keys.length; i++) { + var k = keys[i]; + if (KEY_FIELD.test(k)) continue; + out[k] = sanitizeValue(value[k]); + } + return out; + } + return value; + } + + function sanitizeSnapshotText(text) { + if (text == null || text === "") return null; + var parsed; + try { + parsed = typeof text === "string" ? JSON.parse(text) : text; + } catch (e) { + return typeof text === "string" ? text : JSON.stringify(text); + } + return JSON.stringify(sanitizeValue(parsed)); + } + + function writeUtf8(memory, alloc, text) { + var bytes = new TextEncoder().encode(text); + var ptr = alloc(bytes.length); + new Uint8Array(memory.buffer, ptr, bytes.length).set(bytes); + return { ptr: ptr, len: bytes.length }; + } + + function instantiateModule(wasmBytes) { + return WebAssembly.instantiate(wasmBytes, {}).then(function (result) { + var exp = result.instance.exports; + if (!exp.compass_decide_json || !exp.compass_alloc || !exp.compass_last_len || !exp.memory) { + throw new Error("missing compass_* exports"); + } + var compiled = result.module || (wasmBytes instanceof WebAssembly.Module ? wasmBytes : null); + var importPromise = compiled + ? Promise.resolve(WebAssembly.Module.imports(compiled)) + : WebAssembly.compile(wasmBytes instanceof ArrayBuffer ? wasmBytes : wasmBytes.buffer || wasmBytes).then(function (mod) { + return WebAssembly.Module.imports(mod); + }); + return importPromise.then(function (imports) { + for (var i = 0; i < imports.length; i++) { + var im = imports[i]; + if (im.module === "keys" || im.name === "fetch" || String(im.name).indexOf("fetch") !== -1) { + throw new Error("forbidden import: " + im.module + "." + im.name); + } + } + return { + memory: exp.memory, + alloc: exp.compass_alloc, + free: exp.compass_free, + decide: exp.compass_decide_json, + lastLen: exp.compass_last_len, + }; + }); + }); + } + + function decide(api, request, snapshotText, nowIso) { + try { + var r = writeUtf8(api.memory, api.alloc, request || ""); + var s = snapshotText == null + ? { ptr: 0, len: 0 } + : writeUtf8(api.memory, api.alloc, snapshotText); + var n = writeUtf8(api.memory, api.alloc, nowIso || NOW_ISO); + var outPtr = api.decide(r.ptr, r.len, s.ptr, s.len, n.ptr, n.len); + var outLen = api.lastLen(); + var jsonBytes = new Uint8Array(api.memory.buffer, outPtr, outLen); + return JSON.parse(new TextDecoder().decode(jsonBytes)); + } catch (e) { + return { + fail_open: true, + default_reason: "module_trap", + selected_model_version_id: "default", + rationale: "fail-open: module_trap", + error: String(e), + }; + } + } + + function b64ToBytes(b64) { + var bin = global.atob(b64); + var u8 = new Uint8Array(bin.length); + for (var i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i); + return u8; + } + + function runFromBytes(wasmBytes, opts) { + opts = opts || {}; + var expected = String(opts.expectedSha256 || "").trim().toLowerCase(); + var snapshotJson = opts.snapshotJson; + var nowIso = opts.nowIso || NOW_ISO; + var bytes = wasmBytes instanceof Uint8Array ? wasmBytes : new Uint8Array(wasmBytes); + var report = { + ok: false, + expected_sha256: expected, + actual_sha256: opts.nativeActualSha256 || null, + digest_match: false, + js_digest: null, + import_table: "pending", + fixture_min: null, + missing: null, + error: null, + }; + + return sha256Hex(bytes) + .then(function (got) { + report.js_digest = got == null ? "skipped_no_subtle" : "sha-256"; + if (got) report.actual_sha256 = got; + if (got) { + report.digest_match = got.toLowerCase() === expected; + if (!report.digest_match) { + report.error = "digest mismatch: expected " + expected + " got " + got; + return report; + } + } else if (opts.requireJsDigest) { + report.error = "js digest unavailable and requireJsDigest=true"; + return report; + } else { + report.digest_match = opts.nativeDigestMatch === true; + if (opts.nativeDigestMatch === false) { + report.error = "native digest mismatch; refusing instantiate"; + return report; + } + } + return instantiateModule(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)).then(function (api) { + report.import_table = "empty"; + var sanitized = sanitizeSnapshotText(snapshotJson); + report.fixture_min = decide(api, FIXTURE_REQUEST, sanitized, nowIso); + report.missing = decide(api, MISSING_REQUEST, null, nowIso); + var cheap = report.fixture_min && report.fixture_min.selected_model_version_id === "urn:mg:model:cheap"; + var miss = report.missing && report.missing.default_reason === "snapshot_missing"; + report.ok = !!(cheap && miss && (report.digest_match || report.js_digest === "skipped_no_subtle")); + if (!cheap) report.error = (report.error || "") + " fixture_min did not select urn:mg:model:cheap"; + if (!miss) report.error = (report.error || "") + " missing snapshot did not return snapshot_missing"; + return report; + }); + }) + .catch(function (e) { + report.error = String(e && e.stack ? e.stack : e); + report.ok = false; + return report; + }); + } + + function reportToHost(payload) { + var json = typeof payload === "string" ? payload : JSON.stringify(payload); + try { + if (global.webkit && global.webkit.messageHandlers && global.webkit.messageHandlers.compassResult) { + global.webkit.messageHandlers.compassResult.postMessage(payload); + } + } catch (e1) { /* ignore */ } + try { + if (global.CompassNative && typeof global.CompassNative.report === "function") { + global.CompassNative.report(json); + } + } catch (e2) { /* ignore */ } + if (typeof document !== "undefined") { + var el = document.getElementById("out"); + if (el) el.textContent = JSON.stringify(payload, null, 2); + document.documentElement.setAttribute("data-compass-ok", payload && payload.ok ? "1" : "0"); + } + return payload; + } + + function bootInjected() { + var inj = global.__COMPASS_INJECT; + if (!inj || !inj.wasmB64) return Promise.reject(new Error("no __COMPASS_INJECT.wasmB64")); + var bytes = b64ToBytes(inj.wasmB64); + return runFromBytes(bytes, { + expectedSha256: inj.expectedSha256, + snapshotJson: inj.snapshotJson, + nowIso: inj.nowIso || NOW_ISO, + nativeDigestMatch: inj.nativeDigestMatch, + nativeActualSha256: inj.nativeActualSha256, + requireJsDigest: false, + }).then(reportToHost); + } + + function fetchText(url) { + return fetch(url).then(function (res) { + if (!res.ok) throw new Error("fetch failed " + res.status + " " + url); + return res.text(); + }); + } + + function bootFetched() { + return Promise.all([ + fetch("EXPECTED_SHA256").then(function (res) { + if (!res.ok) throw new Error("EXPECTED_SHA256 fetch " + res.status); + return res.text(); + }), + fetch("compass_core_bg.wasm").then(function (res) { + if (!res.ok) throw new Error("wasm fetch " + res.status); + return res.arrayBuffer(); + }), + fetchText("snapshot_min.json"), + ]).then(function (parts) { + var expected = String(parts[0]).trim(); + var wasm = parts[1]; + var snapshot = parts[2]; + return runFromBytes(new Uint8Array(wasm), { + expectedSha256: expected, + snapshotJson: snapshot, + nowIso: NOW_ISO, + requireJsDigest: false, + }); + }).then(reportToHost); + } + + function bootAuto() { + if (global.__COMPASS_INJECT && global.__COMPASS_INJECT.wasmB64) return bootInjected(); + if (typeof fetch === "function") return bootFetched(); + return Promise.reject(new Error("no inject payload and no fetch")); + } + + var api = { + NOW_ISO: NOW_ISO, + sanitizeSnapshotText: sanitizeSnapshotText, + runFromBytes: runFromBytes, + bootInjected: bootInjected, + bootFetched: bootFetched, + bootAuto: bootAuto, + reportToHost: reportToHost, + }; + global.CompassMobileHost = api; + + if (typeof document !== "undefined" && !global.__COMPASS_NO_AUTOBOOT) { + var start = function () { + bootAuto().catch(function (e) { + reportToHost({ ok: false, error: String(e) }); + }); + }; + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start); + else start(); + } +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/wasmer/mobile/shared/index.html b/wasmer/mobile/shared/index.html new file mode 100644 index 0000000..12063e6 --- /dev/null +++ b/wasmer/mobile/shared/index.html @@ -0,0 +1,18 @@ + + + + + + comPASS mobile host + + + +

comPASS mobile host

+

Loads compass_core_bg.wasm by digest, calls compass_decide_json.

+
booting
+ + + diff --git a/wasmer/mobile/sync-assets.sh b/wasmer/mobile/sync-assets.sh new file mode 100755 index 0000000..7a8acc9 --- /dev/null +++ b/wasmer/mobile/sync-assets.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Copy compass_core_bg.wasm into mobile host asset dirs after verifying SHA256SUMS. +# No machine-absolute paths. Safe to re-run. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO="$(cd "$HERE/../.." && pwd)" +ART="$REPO/wasmer/artifacts" +SUMS="$ART/SHA256SUMS" +WASM="$ART/compass_core_bg.wasm" +FIXTURE="$REPO/wasmer/fixtures/snapshot_min.json" +PIN="$HERE/shared/EXPECTED_SHA256" + +if [[ ! -f "$WASM" ]]; then + echo "[FAIL] missing $WASM (repo-relative wasmer/artifacts/compass_core_bg.wasm)" >&2 + exit 1 +fi +if [[ ! -f "$SUMS" ]]; then + echo "[FAIL] missing $SUMS" >&2 + exit 1 +fi +if [[ ! -f "$FIXTURE" ]]; then + echo "[FAIL] missing $FIXTURE" >&2 + exit 1 +fi + +EXPECTED="$(awk '$2 == "compass_core_bg.wasm" { print $1; exit }' "$SUMS")" +if [[ -z "$EXPECTED" ]]; then + echo "[FAIL] SHA256SUMS has no compass_core_bg.wasm line" >&2 + exit 1 +fi + +if command -v shasum >/dev/null 2>&1; then + ACTUAL="$(shasum -a 256 "$WASM" | awk '{print $1}')" +else + ACTUAL="$(sha256sum "$WASM" | awk '{print $1}')" +fi + +if [[ "$ACTUAL" != "$EXPECTED" ]]; then + echo "[FAIL] digest mismatch compass_core_bg.wasm expected=$EXPECTED actual=$ACTUAL" >&2 + exit 1 +fi + +PINNED="$(tr -d '[:space:]' < "$PIN")" +if [[ "$PINNED" != "$EXPECTED" ]]; then + echo "[FAIL] shared/EXPECTED_SHA256 ($PINNED) != SHA256SUMS ($EXPECTED)" >&2 + exit 1 +fi + +SIZE="$(wc -c < "$WASM" | tr -d ' ')" +echo "[PASS] compass_core_bg.wasm sha256=$ACTUAL size=$SIZE" + +# shared/ already holds host.js, index.html, EXPECTED_SHA256 — only drop wasm + fixture. +mkdir -p "$HERE/shared" +cp "$WASM" "$HERE/shared/compass_core_bg.wasm" +cp "$FIXTURE" "$HERE/shared/snapshot_min.json" + +copy_one() { + local dest_dir="$1" + mkdir -p "$dest_dir" + cp "$WASM" "$dest_dir/compass_core_bg.wasm" + cp "$FIXTURE" "$dest_dir/snapshot_min.json" + cp "$PIN" "$dest_dir/EXPECTED_SHA256" + cp "$HERE/shared/host.js" "$dest_dir/host.js" + cp "$HERE/shared/index.html" "$dest_dir/index.html" +} + +copy_one "$HERE/ios/Resources" +copy_one "$HERE/android/app/src/main/assets" + +echo "[PASS] synced wasm + fixture into shared/, ios/Resources/, android/app/src/main/assets/"