@epa-wg/cem-ml-cli

Universal host adapters for the common @epa-wg/cem-ml WebAssembly runtime. The current Phase 2.5 slice exposes bounded browser dedicated-worker and Node worker-thread hosts plus one generated, versioned command grammar shared by both APIs.

The schema is generated from the built native Clap command graph and joined to the common Rust runtime-capability matrix. Options, defaults, enum values, required groups, conflicts, and runtime availability are therefore not copied into a separate TypeScript flag table. The same parser and serializer are exported from ./browser and ./node.

The authored-resource helpers use application/vnd.cem.cli-command+json with schema https://cem.dev/ns/cli/command/1. The resource stores generated command-schema/common versions, the cem-ml binary identity, and ordered literal argv. It intentionally excludes lowered requests, effective config, resolver snapshots, results, and normalized run plans.

The command-text helpers add a literal argv projection for editable command surfaces. They preserve quoted and empty arguments with canonical, POSIX-compatible quoting, but never perform variable expansion, substitution, redirection, pipelines, globbing, or command execution. Parsed options still flow through the same generated command grammar.

Every worker owns one isolated WASM runtime. Stable slot/generation identities, strict initialization envelopes, Rust-derived protocol limits, exact runtime versioning, bounded startup, and explicit one-worker modes are established here. The browser host falls back from the bounded pool to one dedicated worker and then to one main-thread WASM runtime when workers cannot initialize; it does not require shared-memory WASM or cross-origin isolation.

The browser command-service API is a separate strict boundary: it always owns exactly one dedicated worker and never falls back to pool scheduling or main-thread execution. Its trusted bootstrap structured-clones the installed factory's immutable compiled WASM module, avoiding a duplicate code download when an owned invocation already loaded it. The worker still creates its own instance and memory and validates native headroom, protocol and ABI as before. The entire startup, including code acquisition, remains inside the existing 10-second default budget; cancellation or timeout prevents a late bootstrap. Callers provide explicit asynchronous revision, read, and transactional-write capabilities; URLs and virtual resources have no implicit filesystem behavior.

The request, result, callback, progress, cancellation, and artifact types are re-exported directly from the Rust-generated @epa-wg/cem-ml/wasm declarations. Pre-terminal command errors reject with BrowserCommandServiceError; canonical terminal statuses remain typed results.

Both command-service clients accept optional constructor-owned hostConfiguration: CommandHostConfigurationV1. createNodeCommandService forwards the same option to its worker client:

Trusted host setup also accepts optional rootQueueSize (a positive integer). It bounds the operation root; omission retains 64. An authored command budget can constrain that ceiling but cannot raise it. For a larger bounded query, the host can admit rootQueueSize: 1024 and the command can request --scope-budget queueSize=1024. Studio uses that combination for the Login CEM-QL comparison. Request/config data does not supply host configuration.

Configuration is snapshotted before worker startup. Later mutation of the caller's object, request fields and execute options cannot replace the snapshot. Each execution passes it separately to the Rust-owned host configuration decoder; invalid setup rejects before host callbacks with cem.command.host_configuration_invalid. Omitting setup supplies no grants. Grants do not load manifests; the common engine still checks exact ownership. A new client takes a new snapshot. Worker failure closes the existing client; there is no implicit restart or authority change.

The optional schema-asset profile also exposes the browser client's original packageResourceBinding() for CemSchemaAssetPackLoader from @epa-wg/cem-elements/schema-assets. This shared byte transport sends the pinned index and original source bodies, never native owner IDs or source ASTs. The worker uses its startup hostConfiguration for activation. Command and rendering workers vendor the same bounded original-byte/control protocol validator. Demand URLs and loader configuration cannot supply replacement grants. The default complete WASM profile does not acquire optional packages implicitly.

Use CemSchemaAssetPackRecoveryBinding with the same original worker participant and prepare complete host resume tickets before closure. With attached packages, client.closeNativeAdmission(owners, timeout, handoff) requires the original complete embedding acknowledgment and a current checkpoint handoff within the existing 1 MiB per-command budget. It closes raw/managed native admission, awaits package-owner release and validates the empty native inventory. Cleanup replies never grant retirement independently; timeouts, unknown work, stale ownership and late replies refuse. Native package owners remain worker-local, and the shared complete binding transaction restores packages on the fresh worker before publishing command routes. Entered password/OTP controls continue to block recovery; automatic whole-Studio recovery remains disabled. See the shared loader contract.

The Node command service adds explicit filesystem, local file://, HTTPS, and application-stream resolution plus prepared file/stream writes. Parsed command lowering, resource discovery, canonical requests, diagnostics, reports, terminal presentation, and exit codes remain owned by the common Rust runtime.

The package also installs cem-ml. It uses one worker-thread runtime, maps SIGINT/SIGTERM to cooperative cancellation, publishes stdout/stderr and report files through the Node host, and preserves the stable common exit policy.

inspect --format json keeps its explicit presentation choice across browser, Node and worker command transport. Omitted format and --format cem retain the normal CEM-ML inspector output. The common native request/preparation owns that choice and the JSON output identity; hosts do not serialize an intermediate AST or infer JSON from the source format.

pool.run(request) returns an awaitable operation handle with result(), subscribe(), cancel(), pause(), continue(), step(), and dispose(). The common Rust coordinator retains continuation and deterministic commit order; workers receive bounded stateless transform/query packets. Dedicated workers are terminated and replaced when cancellation exceeds the negotiated hard-cancel grace. Main-thread browser fallback uses the same bounded packets and cooperative controls, while truthfully reporting hard cancellation as unavailable.

The aggregate package checks run the shared parse, validate, check, inspect, convert, query, transform, trace, and version/capabilities matrix through the browser and Node command-service workers and the executable. Packaging then installs both archives into a clean consumer, proves one resolved common runtime, and repeats the executable matrix through the installed cem-ml bin.

The package build writes its runtime/capability projection and complete SHA-256 integrity manifest. Its Nx package target adds the version-qualified npm tarball, SPDX 2.3 SBOM, provenance, checksums, signing state, and release index entry, then validates those artifacts against the common platform contract before returning success.

The uncached Nx sign target verifies and records a supplied CEM_ML_GITHUB_ATTESTATION_BUNDLE; CEM_ML_RELEASE_SIGNING=required turns a missing bundle into a hard release failure. Unsigned development packages remain explicitly non-publishable.

The @epa-wg/cem-ml-cli/browser-commands entry point exposes command parsing, invocation and dedicated-worker command execution for Studio. await createBrowserTransformConfigAuthor() initializes the same shared runtime and returns its native authorTransformConfigV1 JSON function for graph description and edits. Applications can use the CLI facade without importing the runtime package directly; validation and source writing remain native.

Native admission and checkpoint boundary

CLI native calls share an admission adapter for their actual linked module; command workers, main-thread authoring and rendering keep separate heap coverage. Soft/hard native pressure or unavailable telemetry permanently closes fresh command work. Cancellation/disposal remain available, and waiting host callbacks stay enrolled until completion and terminal publication. Pressure never restarts or replays a command. Cached graph-author functions check admission on each call.

await client.checkpointNative() explicitly closes ordinary command admission and waits for native work and host callbacks. Retained artifacts and resumable hosts refuse capture until disposed; release artifacts after copying any bytes you need. A failed capture leaves cleanup available. The default timeout is five seconds; positive integer options up to 60 seconds cannot extend the native or host five-second safety deadlines. Timeout permanently fences the adapter. client.isNativeCheckpointCurrent(witness) accepts only its current original witness; copying it, later work/cleanup, client closure or worker failure makes it unusable.

Witnesses have scope: "managed-cli-only" and replacementAllowed: false. Raw @epa-wg/cem-ml/wasm calls and direct/generated WASM instantiation remain unmanaged. This API does not authorize heap replacement, preserve arbitrary raw native handles or replay external actions. See the checkpoint contracts.

Native inventories and embedding ownership

For an explicit native census, claim a lease for each embedding owner, then pass all originals to the checkpoint call:

The Rust census includes raw retained resources and pending commands that the CLI ledger cannot see. Dispose live resources explicitly before retrying; refusal keeps cleanup available. Copies, foreign leases and partial owner sets are rejected. Main-thread authoring has equivalent claimNodeCommandNativeEmbedding and claimBrowserCommandNativeEmbedding families, keyed by actual WASM memory.

This is snapshot evidence with replacementAllowed: false. Worker currentness covers parent activity/ownership; raw calls after acknowledgement require a fresh native check. Raw callers must opt in. Inventory-only capture leaves ordinary generated exports open; coordinated closure below closes them in the native gate. See the complete inventory and ownership contract.

Coordinated native closure and revalidation

Inventory-only checkpoints keep their existing behavior. Use coordinated closure when all embedding owners can consent to stopping new work in that memory:

Closure is permanent for that native generation. Every ordinary generated-WASM entrypoint checks the native gate, so a raw alias also refuses new work. Previously admitted commands remain enrolled through callbacks. Cleanup, cancellation, existing resource settlement and bounded checkpoint controls remain available. A retained root or raw pending command refuses acknowledgement; settle or dispose it explicitly, then request a fresh coordinated close witness.

Revalidation requires the current original witness and every original owner lease. Native validation checks the fresh complete inventory and its exact revision under one admission lock; the host requires unchanged ownership across that call. Cleanup invalidates the native revision even when live counts stay zero. A copied, foreign, timed-out or owner-stale witness cannot be revalidated. Worker synchronous isNativeCheckpointCurrent observes host activity only; use the asynchronous revalidation endpoint for native freshness.

Main-thread authoring provides closeNodeCommandNativeAdmission / closeBrowserCommandNativeAdmission and revalidateNodeCommandNativeCheckpoint / revalidateBrowserCommandNativeCheckpoint, with the same original-lease rules. Witnesses still have replacementAllowed: false. Native confirmation describes one validation instant; explicit retirement below consumes a new native barrier. Semantic resume still requires new generation authority. Unregistered embedders, direct memory writes and independent initialization remain outside the ownership contract. No command or resource writer is replayed.

Retiring a command worker with checkpoint handoff

Retirement is explicit on browser and Node command-service clients. A trusted host supplies its current native-sealed checkpoint and terminal external-operation journal through a lifecycle adapter. For a shared cem-elements publication coordinator, use its handoff() directly:

The adapter's retain() returns native-sealed bytes; current() must reject new edits, publications, policy changes and journal changes. This is trusted host code, never an authored callback, receipt flag or serialized checkpoint label. The CLI transports opaque bytes, bounded to 1 MiB, and does not parse or validate an application AST. The host must cover all affected owners and heaps; a rendering checkpoint does not establish command-heap coverage.

The client checks the original coordinated witness and all original embedding leases, retains an independent byte copy, then asks the worker to freeze its exact native inventory revision. Stale raw cleanup cancels preparation. Only the original native RAII permit can reopen cleanup; its copyable confirmation cannot. While frozen, every mutating entrypoint, including cleanup, refuses work. Final handoff and ownership checks and transport termination run in one host turn. Successful retirement revokes old ownership and returns the retained bytes.

A changed handoff/owner, refusal, timeout or worker loss never starts replacement or retries an operation. An aborted preparation drops its native permit, restores cleanup under closed admission and invalidates the previous witness. Unknown external effects must be reconciled under current policy before handoff. Native artifact-disposal tombstones are discarded only with the retired heap, rather than imported into another generation. The existing host journal remains in the checkpoint; no external writer or command is replayed. Callers retain last-good bytes after failure, but they cannot authorize rollback of newer edits.

The returned record retains replacementAllowed: false. Main-thread memories are not physically retired by this API. Automatic replacement/resume, rendering worker retirement and declarative recovery status remain separate work. Raw initialization, direct memory mutation, unregistered embeddings and direct transport-control injection remain unsupported retirement boundaries.

The prepared retirement permit remains visible as one live native-entries root until abort or physical worker termination; a held barrier never produces a misleading empty resource census.

Dedicated command-service workers can join CemNativeGenerationGroup through client.generationParticipant() before retirement. Enroll every managed physical heap in the group, retire each with its original complete embedding ownership and current checkpoint/terminal-journal handoff, then pass the original retirement results to group.allocate(results). The returned command runtimes are new clients; previous clients remain closed. Allocation admits the complete set only after fresh workers initialize with native memory headroom. Failed or expired startup fences all candidates and retains independent handoff bytes. admission.revoke() fences the entire set and requests cleanup. Native command semantics, checkpoints and external actions are not automatically resumed. See the coordinated allocation contract.

Recovery group enrollment

Use the shared CemNativeRecoveryScope and client.enrollRecovery(scope, id, hostWitness) to account for command workers alongside rendering/preview domains. createEnrolledBrowserCommandServiceClient(options, scope, id, hostWitness) reserves membership before worker startup and returns { client, enrollment }; failed startup removes the reservation after terminating its worker. Include host ledgers, staged writes and unknown outcomes in the original witness. Active work and uncertain writes refuse preparation; native ownership, retained artifacts and closure are still checked separately. Fresh workers retain the original snapshotted host services, while old aliases/handles are never retargeted or replayed. Explicitly enroll the fresh client after coordinated binding.

Use createBrowserCommandInvocationBinding({ recoveryScope, id, hostWitness }) for owned invocation discovery, synchronous authorTransformConfigV1(json) and projectPresentation(plan, result). Startup reserves membership before loading WASM. Each binding owns a lexical wasm-bindgen generation with separate memory, caches and finalizers; it never initializes or retargets the legacy singleton. binding.runtime exposes the original generation's ownership, closure and retirement controls. Discovery holds admission across asynchronous resolution; active discovery or changed host ledgers refuse recovery preparation.

For a complete pure-authoring group, claim original embedding ownership, close native admission, retain the explicit terminal host handoff, retire, allocate and call binding.resume(originalAdmission). Only the stable binding changes target; captured methods on an old binding.runtime stay fenced. Resume consumes the original admission once and refuses copied, foreign or mixed-group admissions. Revocation fences the binding and disposes the admitted generation. No operation, resolver, command, write or presentation is replayed during allocation/resume. For combined rendering/preview/command handoff, both the invocation binding and createBrowserCommandServiceBinding(options, { recoveryScope, id, hostWitness }) expose prepareResume() tickets. Capture those before retirement and pass every original binding ticket to the shared cem-elements owner transaction. It claims one complete admission and binds all routes before provider renewal; raw aliases and artifact handles remain attached to their original workers. See the combined handoff contract.

Legacy buildBrowserCommandInvocation, createBrowserTransformConfigAuthor and projectBrowserCommandPresentation retain their original singleton behavior. Consumers retaining those aliases must still call enrollBrowserCommandInvocationRecovery(scope, id) to record its unsupported allocator boundary. See the shared enrollment contract.