diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b12329d..d20d1a8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -71,6 +71,26 @@ jobs:
- name: Offline tests
run: cargo test --locked
+ java:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ java: ["21", "25"]
+ defaults:
+ run:
+ working-directory: java
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: ${{ matrix.java }}
+ cache: maven
+ cache-dependency-path: java/pom.xml
+ - name: Offline tests
+ run: mvn -B -ntp verify
+
bootstrap:
runs-on: ubuntu-latest
strategy:
@@ -137,3 +157,14 @@ jobs:
working-directory: rust
env:
JACQUARD_REQUIRE_LIVE: "1"
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "21"
+ cache: maven
+ cache-dependency-path: java/pom.xml
+ - name: Run independent Java adapter against Core
+ run: mvn -B -ntp verify
+ working-directory: java
+ env:
+ JACQUARD_REQUIRE_LIVE: "1"
diff --git a/AGENTS.md b/AGENTS.md
index f5307e5..decc05c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -47,9 +47,9 @@ invent messages that Core has not specified.
- Keep the first carrier serial, bounded, versioned, and fail-closed.
- Preserve transport independence: process framing is one carrier, not the
semantic definition of the boundary.
-- The Python, TypeScript, Go, and Rust adapters each implement the pinned
- fixtures independently. Keep pin constants and kit copies generated by
- `scripts/core_pin.py`.
+- The Python, TypeScript, Go, Rust, and Java adapters each implement the
+ pinned fixtures independently. Keep pin constants and kit copies generated
+ by `scripts/core_pin.py`.
- Add tests with behavior. Use the standard library until a reviewed need
justifies a dependency.
- Work on branches and merge through pull requests. The empty repository seed
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3d28122..90ade36 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,21 @@
handler reports completion unknown. As a consequence `HandlerError` cannot
implement `std::error::Error`, and a downstream `From` impl for a type that
implements `Error` conflicts with it.
+- Java adapter: an independent Maven project in `java/`
+ (`org.jacquardlang:jacquard-host`, Java 21+) with no third-party runtime
+ dependencies (on Unix it runs the system `mkfifo`).
+ It provides strict codecs, the session state machine, a bounded process
+ carrier for `jacquard host worker`, and a `Host` with a closed operation
+ registry. It carries the pinned kit as class-path resources and replays all
+ 21 recordings. It is protocol-level only, with no SDK layer. Java cannot
+ start the worker in its own process group or interrupt a pipe read, so the
+ carrier never blocks on a read, reads the worker's output through FIFOs it
+ creates with `mkfifo` on Unix (so the JDK's exit-time pipe drain cannot hold
+ the caller), kills the worker's process tree from a `ProcessHandle`
+ snapshot, and documents what that misses in `docs/LIMITS.md`. `scripts/core_pin.py` now generates
+ `java/src/main/java/org/jacquardlang/host/Compat.java` and keeps the Java kit
+ copy in sync, and CI runs the offline tests on Java 21 and 25 and the live
+ tests against the pinned Core.
## 0.2.0 — 2026-10-25
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f1f5727..a84494c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -17,8 +17,9 @@ Contributions are licensed under [Apache 2.0](LICENSE), matching this repository
`core-pin.json` names the one Core release all adapters target. Do not edit
`src/jacquard_host/compat.py`, `typescript/src/compat.ts`, `go/protocol/compat.go`,
-`rust/src/compat.rs`, the Go and Rust kit copies under `go/kit/data/` and
-`rust/kit/`, or the generated block
+`rust/src/compat.rs`, `java/src/main/java/org/jacquardlang/host/Compat.java`,
+the Go, Rust, and Java kit copies under `go/kit/data/`, `rust/kit/`, and
+`java/src/main/resources/org/jacquardlang/host/kit/`, or the generated block
in `docs/COMPATIBILITY.md` by hand: change the pin with
`python3 scripts/core_pin.py update --tag ...`, which also vendors the kit, and
`python3 scripts/core_pin.py check` (run in CI) reports any drift.
@@ -111,6 +112,30 @@ kit. `scripts/core_pin.py` maintains both; do not edit them by hand. The
`jacquard-examples` Cargo example reads the rules and inputs from `examples/`
directly, so there is no copy to keep in sync.
+## Java
+
+The independent Java adapter lives in `java/` as Maven artifact
+`org.jacquardlang:jacquard-host`. It has no third-party runtime dependencies
+and does not call Python, Node.js, Go, or Rust; on Unix it runs the system
+`mkfifo`. JUnit 5 is its only test dependency. Java 21+ and Maven 3.9+ are
+required.
+
+```sh
+cd java
+mvn -B verify
+# With the pinned Core configured (eval "$(python3 ../scripts/core_pin.py install)"):
+JACQUARD_REQUIRE_LIVE=1 mvn -B verify
+```
+
+CI runs the offline tests on Java 21 and 25, and the live tests on Java 21 in
+the shared pinned-Core job. The compiler runs with `-Xlint:all -Werror`.
+`java/src/main/java/org/jacquardlang/host/Compat.java` is generated and the
+kit under `java/src/main/resources/` is a byte copy of the pinned kit.
+`scripts/core_pin.py` maintains both; do not edit them by hand. The `StandIn`
+main class in the test sources is a fixture for the offline tests. Copy the
+repository `NOTICE` to `java/NOTICE` when it changes (a test fails if they
+differ).
+
## Releasing
A release is one commit on `main` with green CI. To cut one:
@@ -118,7 +143,7 @@ A release is one commit on `main` with green CI. To cut one:
1. Open a pull request that dates the CHANGELOG entry and updates any version
references in the docs. Keep the Python (`src/jacquard_host/__init__.py`),
npm (`typescript/package.json`, `typescript/src/version.ts`), Rust
- (`rust/Cargo.toml`), and release versions equal.
+ (`rust/Cargo.toml`), Java (`java/pom.xml`), and release versions equal.
2. After it merges, tag the merge commit twice, with annotated tags
(`git tag -a`):
`jacquard-host-X.Y.Z` for the repository and its assets, and `go/vX.Y.Z`
@@ -131,8 +156,8 @@ A release is one commit on `main` with green CI. To cut one:
4. Publish a GitHub release for `jacquard-host-X.Y.Z` with the CHANGELOG
entry as its notes and the assets attached.
-Nothing is published to PyPI, npm, or crates.io. The Rust crate is used from
-the repository tag.
+Nothing is published to PyPI, npm, crates.io, or Maven Central. The Rust
+crate is used from the repository tag, and the Java artifact is built from it.
## TypeScript
diff --git a/MANIFEST.in b/MANIFEST.in
index ebfedd3..581e7b4 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -14,3 +14,6 @@ recursive-include go *.go *.md *.json *.jac
include rust/Cargo.toml rust/Cargo.lock rust/rustfmt.toml rust/LICENSE rust/NOTICE
recursive-include rust *.rs *.md *.json *.jac
prune rust/target
+include java/pom.xml java/LICENSE java/NOTICE
+recursive-include java *.java *.md *.json *.jac
+prune java/target
diff --git a/NOTICE b/NOTICE
index cccb047..09bc7d2 100644
--- a/NOTICE
+++ b/NOTICE
@@ -4,9 +4,10 @@ Copyright 2026 Jacquard Host contributors
This product includes conformance artifacts from Jacquard Core,
https://github.com/jbwinters/jacquard-lang, licensed under Apache License 2.0.
The artifacts under src/jacquard_host/data/conformance/host-protocol-v0/,
-and their byte-identical copies under go/kit/data/host-protocol-v0/ and
-rust/kit/host-protocol-v0/, are copied verbatim from the commits recorded in
-docs/COMPATIBILITY.md.
+and their byte-identical copies under go/kit/data/host-protocol-v0/,
+rust/kit/host-protocol-v0/, and
+java/src/main/resources/org/jacquardlang/host/kit/host-protocol-v0/, are
+copied verbatim from the commits recorded in docs/COMPATIBILITY.md.
Upstream notice:
Jacquard
diff --git a/README.md b/README.md
index 538ffa3..da51585 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,10 @@ third-party runtime dependencies. The [TypeScript SDK](typescript/README.md),
the [Go SDK](go/README.md), and the [Rust SDK](rust/README.md) implement the
pinned protocol independently. The TypeScript SDK supports async service
methods, the Go SDK maps native Go types and context cancellation, and the Rust
-SDK maps native Rust types through `record!` and `variants!` declarations.
+SDK maps native Rust types through `record!` and `variants!` declarations. A
+protocol-level [Java adapter](java/README.md) (Java 21+) also implements the
+pinned protocol independently. It has a closed operation registry and kit
+replay, but no SDK layer yet.
**Experimental:** the SDK targets Core's pinned `jacquard-host-v0` protocol.
Core is installed separately. Each invocation runs one fresh, serial worker;
@@ -113,8 +116,8 @@ participant collection require separate application work.
See [CONTRIBUTING.md](CONTRIBUTING.md) for offline tests, installed-distribution
checks, and live runs. CI tests Python 3.11–3.13, Node.js 22/24, Go 1.24 and
-stable, and Rust 1.88 and stable. It separately installs the pinned Core release
-for every adapter's live suite and the public examples.
+stable, Rust 1.88 and stable, and Java 21 and 25. It separately installs the
+pinned Core release for every adapter's live suite and the public examples.
[Evidence](docs/EVIDENCE.md) records the checks and platform claims;
[development experiments](docs/development/README.md) preserve the usability
diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md
index 26fe6f1..6de1279 100644
--- a/docs/COMPATIBILITY.md
+++ b/docs/COMPATIBILITY.md
@@ -6,11 +6,13 @@ Every adapter in this repository is pinned to one published Jacquard Core
release, recorded once in [`core-pin.json`](../core-pin.json). The Python
constants (`src/jacquard_host/compat.py`), the TypeScript constants
(`typescript/src/compat.ts`), the Go constants (`go/protocol/compat.go`), the
-Rust constants (`rust/src/compat.rs`), and the block below are generated from
-that file by `scripts/core_pin.py sync`. `scripts/core_pin.py check` fails CI
-when any of them drifts from the pin. It also fails when any vendored kit copy
-drifts: the Python package data, Go's embedded `go/kit/data/`, or the Rust
-crate's embedded `rust/kit/`. Adapters verify the kit's four
+Rust constants (`rust/src/compat.rs`), the Java constants
+(`java/src/main/java/org/jacquardlang/host/Compat.java`), and the block below
+are generated from that file by `scripts/core_pin.py sync`.
+`scripts/core_pin.py check` fails CI when any of them drifts from the pin. It
+also fails when any vendored kit copy drifts: the Python package data, Go's
+embedded `go/kit/data/`, the Rust crate's embedded `rust/kit/`, or the Java
+class-path copy under `java/src/main/resources/`. Adapters verify the kit's four
digests independently at load time, and the SDKs refuse a Core whose
`--version` differs from the pinned version.
@@ -41,7 +43,7 @@ Release assets verified by `scripts/core_pin.py install`:
| macos-x86_64 | `jacquard-macos-x86_64.tar.gz` | `8efc54bcf7573cb7cfd35fa3e77a584680ce68de872bdb212ed8369a974ff967` |
-Python, TypeScript, Go, and Rust replay the same 21 recordings against the pinned
+Python, TypeScript, Go, Rust, and Java replay the same 21 recordings against the pinned
worker, including Core's recorded `noncanonical-target-hash` divergence: the
HB.1 vector expects E1603 while the shipped preflight classifies an uppercase
target hash as a malformed scalar (E1601). The transcript is recorded as
@@ -52,8 +54,8 @@ independent v0 evidence; it does not change the protocol version or declare v1.
The adapters decode boundary descriptors in Core's order: the kind, then the
exact fields, then one node of `max_value_nodes`, then any identity, then the
list count. Malformed input therefore gets the same error code from every
-adapter and from Core. For recording comparisons, Python, Go, and Rust sort object keys
-by code point, while TypeScript sorts them by UTF-16 code unit. The two orders
+adapter and from Core. For recording comparisons, Python, Go, Rust, and Java sort object
+keys by code point, while TypeScript sorts them by UTF-16 code unit. The two orders
differ only for keys outside the Basic Multilingual Plane. Every protocol key is
ASCII, so the comparisons agree.
@@ -114,6 +116,7 @@ python3 -m pip install . && python3 scripts/check_examples.py
(cd typescript && npm ci && npm test && npm run test:live && npm run check:package)
(cd go && go test -race ./... && go test -count=1 ./...)
(cd rust && cargo test)
+(cd java && JACQUARD_REQUIRE_LIVE=1 mvn -B verify)
```
`update` resolves the tag to its commit, copies the kit files from
diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md
index a963f20..849544c 100644
--- a/docs/EVIDENCE.md
+++ b/docs/EVIDENCE.md
@@ -92,6 +92,21 @@ uses only the standard library.
| Rust projects live | 3 further tests pass: all four `jacquard-examples` commands, including duplicate fulfillment, `--inspect`, explicit delivery, a failed delivery printing no count, and usage errors; the built command exiting 1 with E1613 for a failed publish; and the three fulfillment faults (E1612 after commit, E1613 on publish, E1612 after delivery) recovered explicitly across restart, each receipt delivered once |
| Unchanged lanes | The adapter's 28 offline, descriptor, fallback, NOTICE, and 4 live tests, the 21-recording kit replay, `scripts/core_pin.py check`, the offline Python suite, and `scripts/check_distribution.py` pass |
+## Independent Java adapter (2026-10-27)
+
+Recorded against the same `jacquard-core-0.3.0` release asset (linux-x86_64),
+with Temurin 21.0.13 and 25.0.5 and Maven 3.9.16 on Linux 7.0. The adapter has
+no third-party runtime dependencies (on Unix it runs the system `mkfifo`);
+JUnit 5.14.4 is its only test dependency.
+
+| Lane | Result |
+|---|---|
+| Java offline (`mvn -B -ntp verify` without Core) | 60 tests pass on both JDKs, and the 5 live tests skip: 36 codec, session, replay, and host-fault tests, 6 process-tree tests, 8 descriptor tests (Linux only), 3 setup tests (Unix only), 5 writer-gate tests, and the NOTICE and README-example copy checks. Coverage: strict JSON, Unicode, and duplicate keys, including encoded surrogates and non-ASCII `\u` digits; code-point key order; exact Int and Real codecs; limits; Core-order descriptor checks; hello and selection; invoke-frame validation; session ordering and one response slot; the shared outcome node budget. Fault cases run against a stand-in JVM: malformed, extra, and noncanonical Core frames; EOF, truncated, silent, stalled, and nonzero workers; bounded stderr; write backpressure; frames sent before selection read under the selected limits (E1602); cancellation; interruption of the calling thread, reported as an abort with the interrupt status restored; local refusal before spawn; null arguments refused before spawn; registry ordering; frame encoding that stops at max_frame_bytes, so a handler result of 256 references to one 256 KiB text (within every value limit, over 384 MiB once escaped) is refused as an outside failure within 3 s, and a cancellation from another thread 100 ms after the handler starts does not hold the call past 3 s (before the fix the encoder built the whole payload first: several seconds past a 500 ms deadline, or an OutOfMemoryError; on the old encoder both tests exhaust the test JVM's heap); no handler starts when the call is cancelled, or the caller interrupted, while a 256 KiB effect request is being decoded, or on the handler thread just before its own check (a test seam stops the call there): the lifetime is checked before dispatch and again on the handler thread, the caller's interrupt included (before the fix the handler started 2 to 12 ms after a stop during decoding; without the thread's check, or without the interrupt in it, the seam tests fail); the caller records its interrupt for other threads before consuming it, and waits without clearing it, so a handler check that runs after the caller has consumed the interrupt still stops (before the fix the handler ran); validation charges every text against the frame and refuses a text longer in chars than max_text_bytes before scanning it, so 4 x 1022 references to one 256 KiB emoji text are refused as an outside failure within 500 ms of the handler's return (before the fix about 1.1 s, and longer for larger texts), and validation itself refuses that value with "payload exceeds max_frame_bytes" (on the old Budget it accepted it); a 32 Mi-char text is refused as an outside failure; replay stops reading, and reports a difference, once a worker sends more frames than the recording, so 300 frames of 600 KB after the terminal neither hold the replay past 3 s nor grow the heap by 64 MiB (before the fix replay kept every frame and exhausted the heap); handler answers (structured failures, checked and unchecked exceptions, errors, `null`, an out-of-range Int, and a message cut at a code point with a lone surrogate replaced). Process tree: a child, and a grandchild under a waiting shell, holding the pipes are killed at the deadline; a descendant that daemonized while the worker ran, and one left by a launcher that exited, are not found and survive; the exit wait obeys the deadline and reports SIGKILL; a slow clean exit succeeds; cleanup after an early protocol error is not held by an escaped descendant; a descendant holding stderr after a successful exit is not observed (the result is ok), while a nonzero exit is a carrier loss with status 74. Descriptors: no descriptor or adapter-thread growth across successful invocations, an escaped descendant, handler calls, and repeated expiry, cancellation, interruption, and truncated-frame failures; with the worker still alive and its child holding stdin without reading, expiry and cancellation each return within 3 s, kill the child, and end the blocked write and the stdin thread (both tests hang for the child's 30 s lifetime when the worker is killed with `Process.destroyForcibly()`); a reparented `yes` flooding stderr at the worker's own exit, at a 500 ms deadline, and at cancellation after 500 ms, and one flooding stdout, each return within 3 s of the deadline or cancellation, grow the heap by less than 64 MiB, and leave the writer without a reader so it exits (on the JDK's own pipes the same four return after 6.4 to 7.1 s, and the heap grows by up to 320 MiB); a write blocked by an escaped descendant holding stdin returns at the deadline, keeps exactly the stdin thread until the descendant is killed, then releases it and its descriptors. Setup: with a `mkfifo` on the `PATH` that never finishes, cancellation and interruption after 100 ms are reported as `aborted` (the interrupt status kept) and a 500 ms deadline as `deadline`, each within 2 s, leaving no temporary files and no `mkfifo` running (before the fix all three waited for `mkfifo` and reported `worker_spawn_failed`). Writer gate: a closed gate, a passed deadline, a cancellation, or an interrupted caller stops it, and it writes nothing then, and a gate closed during a frame stops it after the first 4096-byte chunk. Kit: all 21 recordings replay through the stand-in, absent expectations diverge as in the reference, and prose drift is reported without normalization |
+| Java live (`JACQUARD_REQUIRE_LIVE=1 mvn -B -ntp verify` with `JACQUARD_BIN` and `JACQUARD_PRELUDE` set) | 65 tests pass on both JDKs, none skipped: the 60 offline tests and 5 live ones. All 21 recordings match against Core with no prose drift; pure and effectful invocations keep exact evidence, and a shutdown probe is acknowledged; the README example returns `ping!`; the four failure pairs, and checked exceptions, unchecked exceptions, and errors, are reported without retry or private detail; cooperative and abandoned handlers at a 500 ms deadline send no late response, and both see the context cancelled. With `JACQUARD_REQUIRE_LIVE=1` and no Core configured, the 5 live tests fail rather than skip |
+| Compiler | `-Xlint:all -Werror` is clean on both JDKs |
+| Pin consistency | `java/src/main/java/org/jacquardlang/host/Compat.java` is generated, and the class-path kit copy matches the pinned digests and the Python copy (`scripts/core_pin.py check`); `tests/test_core_pin.py` covers drift in both, and the offline Python suite passes |
+| Distribution | `scripts/check_distribution.py` passes; the source archive carries `java/pom.xml`, `java/NOTICE`, the Java sources, and the kit copy |
+
## Outcome frame node budget (2026-10-25)
Core's `outcome` encodes the evidence's interface types and the result value
@@ -180,6 +195,7 @@ reaches persistence has request ordinal 1 and one accepted `ok` observation.
| Linux, Node.js 22 and 24 | CI lanes | Offline TypeScript tests and npm packaging on both; real-worker TypeScript tests and installed examples on Node 24 in the shared pinned-Core job |
| Linux, Go 1.24 and stable | CI lanes | Offline Go tests with the race detector on both; live Go tests in the shared pinned-Core job |
| Linux, Rust 1.88 and stable | CI lanes | fmt, clippy, and offline Rust tests on both; live Rust tests in the shared pinned-Core job |
+| Linux, Java 21 and 25 | CI lanes | Offline Java tests on both; live Java tests on Java 21 in the shared pinned-Core job; offline and live suites also run locally on Temurin 21.0.13 and 25.0.5 |
| Linux, Node.js 26.7 | exercised | TypeScript offline/live suites and tarball installation exercised locally; no broader runtime or OS claim |
| macOS | development only | no CI; the descriptor-count assertions are skipped without `/proc` (the socket host tests skip, the kit and adapter tests skip only the count); SQLite WAL mode is recorded, not assumed |
| Windows and others | no claim | see LIMITS.md |
diff --git a/docs/LIMITS.md b/docs/LIMITS.md
index 4889ae6..c2b5bfe 100644
--- a/docs/LIMITS.md
+++ b/docs/LIMITS.md
@@ -9,7 +9,7 @@ also updates this document.
| First-party Jacquard programs only | Capability checks do not make hostile uploaded code safe. | A separate-process or equivalent isolation design, adversarial review, resource ceilings, and a narrower public threat model. |
| Trusted host and operating system | A malicious or buggy adapter can misreport or perform actions outside Jacquard. | An authenticated, independently enforced host boundary and external security review; canonical hashes alone are insufficient. |
| Application capability boundary, not OS sandbox | Declared and checked effects are review and application enforcement, not kernel isolation. | An explicit OS/WebAssembly/container isolation layer with its own evidence and deployment contract. |
-| Experimental process carrier (Core's `v0`, evidence pack published) | Message framing may change and is not a permanent ABI promise; Python, TypeScript, Go, and Rust replay the pinned kit independently, while a stable protocol decision remains Core's. | Two independent adapters and the serial application pass the same frozen fixtures before Core declares protocol v1 stable. |
+| Experimental process carrier (Core's `v0`, evidence pack published) | Message framing may change and is not a permanent ABI promise; Python, TypeScript, Go, Rust, and Java replay the pinned kit independently, while a stable protocol decision remains Core's. | Two independent adapters and the serial application pass the same frozen fixtures before Core declares protocol v1 stable. |
| Handlers run inside the adapter's read loop | A Python operation handler that blocks holds the worker until it returns. The host deadline is one lifetime budget from spawn, enforced on pipe reads, writes, and exit waits; a slow handler spends the budget and the next carrier operation expires. It cannot interrupt Python handler code. | A handler timeout policy with its own cancel/unknown-completion evidence, once an application needs one. |
| Go worker descendants that leave the process group | The Go host stops the worker's process group at expiry, and on Linux also after exit while a descendant still holds stderr; elsewhere, and for a descendant that called `setsid` (or otherwise left the group), it only stops reading the pipe. | An OS-level containment mechanism (for example a cgroup or job object) with its own evidence. |
| Go handler cancellation is cooperative | At the deadline or on context cancellation the Go host kills the worker's process group (while the worker is unreaped, which Linux guarantees), closes its pipes, and reaps it, cancels the handler's context, and sends no late response; a handler that ignores its context keeps running in its goroutine until it returns. No outside action is stopped or rolled back. | A separately enforced operation lifetime with explicit completion and reconciliation evidence. |
@@ -17,6 +17,10 @@ also updates this document.
| Rust handler cancellation is cooperative | Each Rust handler runs on its own thread. At the deadline or on cancellation the host kills the worker's process group, closes its pipes, reaps it, and sends no late response; `HandlerContext::is_cancelled` turns true, and stays true once the invocation ends. A handler that ignores it keeps running on its thread until it returns. No outside action is stopped or rolled back. | A separately enforced operation lifetime with explicit completion and reconciliation evidence. |
| Rust SDK field order is declared, not exported | `record!` and `variants!` list fields in the order the Rust code declares; the pinned CLI exports no field types, so a wrong order is not detected locally, and Core refuses it only where the swapped fields' types differ. | Core's structured host interface exporter, so bindings can be generated or checked. |
| Rust SDK preparation is not cancellable | Each Core `--version`, `check`, `hash`, and `run` command runs in its own process group with a 30-second timeout and bounded output; `prepare` takes no `Cancel`. On Linux the group is also stopped when the command exits. A descendant that left the group, or on other platforms any descendant, that keeps Core's output open fails preparation after a one-second grace and keeps running; on Unix the reader threads stop and the pipes close, while on non-Unix targets a reader blocked on such a pipe stays until the descendant closes it. | A cancellable preparation API, once an application needs one. |
+| Java worker process tree, not group | Java cannot start the worker in its own process group, so the Java host kills a process tree. At expiry, cancellation, or disposal of a running worker, it snapshots `Process.descendants()` and kills the worker and every process in the snapshot. A descendant that was already reparented keeps running: one whose parent exited, or one that daemonized. So does one started after the snapshot. The JDK reaps the worker as soon as it exits. So stdout ends once the worker has exited and its pipe is empty, rather than when the worker closes it, and a descendant still holding a pipe after the exit is neither detected nor stopped. The Go and Rust hosts report a descendant holding stderr after a successful exit as a carrier loss; the Java host reports the worker's own result. The JDK reports a signal as exit status 128 + its number, so only the host's own SIGKILL is reported as a signal. | An OS-level containment mechanism (for example a cgroup or job object) with its own evidence, or a JDK process-group API. |
+| Java pipe I/O cannot be interrupted | On Unix the Java host creates the worker's stdout and stderr as FIFOs (it needs `mkfifo`) and holds their read ends itself, because the JDK's own process pipes drain on its reaper thread at exit, under the lock `available()` takes and into an unbounded buffer, for as long as a descendant writes. On other systems it uses the JDK's pipes, so a descendant that keeps writing after the worker exits holds the caller, and grows the heap, until it stops. The Java host never blocks on a read. It reads stdout and stderr on the caller's thread only after `available()` reports bytes, in polls of 0.1 to 5 ms that recheck the deadline, the `Cancel`, and the interrupt status. Writes run on one stdin thread in chunks of at most 4096 bytes. Each chunk starts only while input is open, the lifetime is live, and the caller is not interrupted; a chunk already started may complete, and reach the worker, until the caller's next poll kills it (at most 5 ms after a cancellation). A descendant that escaped the kill may hold the stdin pipe without reading. Then a blocked write, or the flush and close that end the thread, keeps the stdin thread, and the JDK's process reaper thread for that worker, until the descendant exits or reads. The caller still returns at the deadline. | A JDK API for interruptible or non-blocking process pipes, or a carrier that does not use process pipes. |
+| Java handler cancellation is cooperative | Each Java handler runs on its own daemon thread. At the deadline, on cancellation, or when the calling thread is interrupted, the host kills the worker's process tree, closes its input, and sends no late response. `HandlerContext.isCancelled` turns true, and stays true once the invocation ends. A handler that ignores it keeps running on its thread until it returns. No outside action is stopped or rolled back. | A separately enforced operation lifetime with explicit completion and reconciliation evidence. |
+| Java adapter is protocol-level only | Java callers pass exact identities, boundary types, and protocol values to a prepared store; there is no Java SDK for program preparation, native type mapping, or caching. Linux runs in CI. macOS has no CI; the offline descendant tests need `sh` and `sleep`, and the descriptor test needs `/proc`. Windows is not claimed. | A Java SDK with typed-binding and preparation evidence comparable to the other SDKs, and CI on each added platform. |
| Node handler cancellation is cooperative | The Node adapter can expire while awaiting a Promise, signal the handler, and kill the worker; it cannot stop an outside action or preempt JavaScript that blocks the event loop. No late response or automatic retry follows expiration. | A separately enforced operation lifetime with explicit completion and reconciliation evidence. |
| Socket host closes without a response on its own failures | A ceiling violation, early disconnect, or failed invocation ends the connection silently; clients see a reset, not an HTTP error. | An owner decision that the host may emit fixed minimal status lines, recorded here with the exact bytes and the rule that they carry no application meaning. |
| Request bytes cross as Latin-1 `Text` | v0 has no bytes value; each byte is one codepoint 0..255 and a response codepoint above 255 is refused by the host. | A frozen bytes value in a later protocol version. |
diff --git a/go/NOTICE b/go/NOTICE
index cccb047..09bc7d2 100644
--- a/go/NOTICE
+++ b/go/NOTICE
@@ -4,9 +4,10 @@ Copyright 2026 Jacquard Host contributors
This product includes conformance artifacts from Jacquard Core,
https://github.com/jbwinters/jacquard-lang, licensed under Apache License 2.0.
The artifacts under src/jacquard_host/data/conformance/host-protocol-v0/,
-and their byte-identical copies under go/kit/data/host-protocol-v0/ and
-rust/kit/host-protocol-v0/, are copied verbatim from the commits recorded in
-docs/COMPATIBILITY.md.
+and their byte-identical copies under go/kit/data/host-protocol-v0/,
+rust/kit/host-protocol-v0/, and
+java/src/main/resources/org/jacquardlang/host/kit/host-protocol-v0/, are
+copied verbatim from the commits recorded in docs/COMPATIBILITY.md.
Upstream notice:
Jacquard
diff --git a/java/.gitignore b/java/.gitignore
new file mode 100644
index 0000000..b83d222
--- /dev/null
+++ b/java/.gitignore
@@ -0,0 +1 @@
+/target/
diff --git a/java/LICENSE b/java/LICENSE
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/java/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ 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
+
+ http://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.
diff --git a/java/NOTICE b/java/NOTICE
new file mode 100644
index 0000000..09bc7d2
--- /dev/null
+++ b/java/NOTICE
@@ -0,0 +1,14 @@
+Jacquard Host
+Copyright 2026 Jacquard Host contributors
+
+This product includes conformance artifacts from Jacquard Core,
+https://github.com/jbwinters/jacquard-lang, licensed under Apache License 2.0.
+The artifacts under src/jacquard_host/data/conformance/host-protocol-v0/,
+and their byte-identical copies under go/kit/data/host-protocol-v0/,
+rust/kit/host-protocol-v0/, and
+java/src/main/resources/org/jacquardlang/host/kit/host-protocol-v0/, are
+copied verbatim from the commits recorded in docs/COMPATIBILITY.md.
+
+Upstream notice:
+Jacquard
+Copyright 2026 Josh Winters
diff --git a/java/README.md b/java/README.md
new file mode 100644
index 0000000..f9173ec
--- /dev/null
+++ b/java/README.md
@@ -0,0 +1,227 @@
+# Jacquard Host for Java
+
+An independent Java adapter for Jacquard Core's experimental `jacquard-host-v0`
+protocol. It implements the pinned protocol on its own, without calling Python,
+Node.js, Go, or Rust, and has no third-party runtime dependencies. Java 21+
+is required, and on Unix the `mkfifo` command must be on the JVM's `PATH`
+(see "Process carrier" below).
+
+This is the protocol-level adapter, comparable to the Go `host` package and the
+Rust `host` module. It includes strict codecs, the host session state machine,
+and a bounded process carrier for `jacquard host worker`. It also provides a
+`Host` with a closed operation registry and a replayer for Core's pinned
+conformance kit. There is no SDK layer yet. It does not compile programs, map
+native types, or cache stores. You prepare a store with the Core CLI and pass
+exact identities and boundary types.
+
+**Experimental:** the protocol is Core's pinned `jacquard-host-v0`, and Core is
+installed separately. Each invocation runs one fresh, serial worker. The host
+and operating system are trusted. Linux is exercised; macOS should work but has
+no CI; Windows is not claimed. See [limits](../docs/LIMITS.md).
+
+## Use
+
+The artifact is not published to a package registry. Build and install it from
+a clone (`mvn -B install` in this directory), then depend on it:
+
+```xml
+
+ org.jacquardlang
+ jacquard-host
+ 0.2.0
+
+```
+
+The example below invokes a checked function whose single operation is served
+by a Java handler:
+
+```java
+import java.time.Duration;
+import java.util.List;
+
+import org.jacquardlang.host.HandlerException;
+import org.jacquardlang.host.Host;
+import org.jacquardlang.host.HostOptions;
+import org.jacquardlang.host.InvocationException;
+import org.jacquardlang.host.OperationRegistry;
+import org.jacquardlang.host.carrier.Cancel;
+import org.jacquardlang.host.carrier.Options;
+import org.jacquardlang.host.protocol.BoundaryType;
+import org.jacquardlang.host.protocol.Hash;
+import org.jacquardlang.host.protocol.Interface;
+import org.jacquardlang.host.protocol.Value;
+
+public final class Echo {
+ public static Value echo(String store, Hash target, Hash text, Hash world, Hash send) throws InvocationException {
+ // The command defaults to $JACQUARD_BIN, then `jacquard`.
+ Options carrier = Options.defaults().withDeadline(Duration.ofSeconds(5));
+ Host host = new Host(store, new HostOptions(carrier, null));
+ OperationRegistry registry = new OperationRegistry().bind(world, send, (context, args) -> {
+ if (context.isCancelled()) {
+ throw HandlerException.completionUnknown("cancelled");
+ }
+ if (args.size() == 1 && args.get(0) instanceof Value.Text input) {
+ return new Value.Text(input.value() + "!");
+ }
+ throw HandlerException.outsideFailed("expected one text argument");
+ });
+ BoundaryType textType = new BoundaryType.Nominal(text, List.of());
+ Interface spec = new Interface(List.of(textType), textType, List.of(world));
+ return host.invoke(target, spec, List.of(new Value.Text("ping")), registry, new Cancel()).valueOrThrow();
+ }
+}
+```
+
+`invoke` returns an `InvocationResult` in every case; only a null argument
+throws (`NullPointerException`), before any worker starts. The result keeps
+Core's frames, diagnostics, and evidence. It also keeps the host's frames, exit
+status, bounded stderr, and host evidence (`jacquard-host-java-evidence-v0`).
+`valueOrThrow` returns the value, or throws an `InvocationException`. The
+exception's `failure()` is one of `DEADLINE_EXCEEDED`, `WORKER_LOST`,
+`OPERATION_FAILED`, or `OTHER`.
+
+Values are `Value.Int` (Core's signed 63-bit integer, held in a `long`),
+`Value.Text`, `Value.Real` (the exact binary64 bits, so NaN payloads and signed
+zero survive), `Value.HashValue`, `Value.Tuple` (`Value.UNIT` is the empty
+tuple), and `Value.Constructor`. A `long` outside `-2^62..2^62-1` has no
+encoding and is refused with E1604; a string with a lone surrogate, which Java
+strings can hold, is refused with E1601, as in the TypeScript adapter, unless
+it is already too long for `max_text_bytes` or the frame, which is refused
+with E1602 before it is scanned.
+
+## Handlers and cancellation
+
+A handler returns a value or throws a `HandlerException`. The four structured
+categories are `unsupported`, `refused`, `outsideFailed`, and
+`completionUnknown`. Any other exception or error a handler throws is reported
+to Core as completion unknown, and its message is never sent. A returned value
+with no encoding within the selected limits, including `null`, is reported as
+an outside failure. A message longer than `max_host_message_bytes` is cut at a
+code point, and a lone surrogate in it becomes U+FFFD.
+
+Each handler runs on its own daemon thread, bounded by the worker's lifetime
+deadline. No handler starts once the call is cancelled, interrupted, or past
+its lifetime: the host checks after decoding the request, and the handler
+thread checks again, caller's interrupt included, just before calling the
+handler. A stop that lands after that check is seen by the handler through
+its context. At the deadline, when
+the caller's `Cancel` is triggered, or when the calling thread is interrupted,
+the host kills the worker's process tree and sends no late response. An
+interrupt is reported as an abort, and the caller's interrupt status is
+restored before `invoke` returns. Cancellation is cooperative.
+`HandlerContext.isCancelled` turns true as soon as the call is cancelled, the
+caller interrupted, or the lifetime passed, and stays true once the invocation
+ends, but a handler that ignores it keeps running on its thread until it
+returns. Nothing is retried.
+
+## Process carrier
+
+Java cannot start a process in its own process group, and a blocking read
+from a pipe cannot be interrupted. The carrier is built around both:
+
+- **The host owns the output pipes on Unix.** The JDK's own process pipes
+ drain themselves on its process reaper thread when the process exits, under
+ the stream lock that `available()` and `close()` also take, for as long as a
+ descendant keeps writing, and into a buffer with no bound. So on Unix the
+ host creates two FIFOs with `mkfifo -m 600` in a private temporary
+ directory, opens each read-write (the open never waits, and the host always
+ holds a read end), redirects the worker's stdout and stderr to them, and
+ removes the directory once the worker starts. The JDK never touches those
+ descriptors, and output a descendant keeps writing stays in the kernel's
+ pipe buffer (64 KiB by default on Linux; a writer can enlarge it), where
+ the writer blocks, until the host disposes the worker and closes its ends;
+ the writer's next write then fails. If `mkfifo` is missing, fails, or takes
+ more than 5 s, the worker does not start and the invocation reports a
+ carrier loss (`worker_spawn_failed`). Making the FIFOs obeys the same deadline,
+ `Cancel`, and interrupt status as the rest of the call, which then reports
+ `deadline` or `aborted` and does not start the worker. On other systems the
+ JDK's pipes and their drain are used.
+- **Reads never block.** stdout and stderr are read on the caller's thread,
+ only after `available()` reports bytes in the pipe. Between polls the
+ thread parks for 0.1 to 5 ms. Every wait, whether for a frame, a write, a
+ handler, or the exit, rechecks the one lifetime deadline, the `Cancel`, and
+ the interrupt status. No thread reads stdout or stderr, so none can be
+ left blocked on them, and at most 1 MiB of stderr is read per poll, of which
+ only the bounded prefix is kept.
+- **Writes run on one stdin thread**, because a pipe write can block. The
+ caller waits for each write under the lifetime. Each frame is written in
+ chunks of at most 4096 bytes. Each chunk starts only while input is open,
+ the deadline has not passed, cancellation has not been requested, and the
+ calling thread is not interrupted. A
+ chunk already started may still complete, and the worker may read it,
+ until the caller's next poll kills the worker: at most one poll slice
+ (5 ms) after a cancellation, and less after the deadline.
+- **Expiry kills a process tree, not a group.** At expiry, cancellation, or
+ disposal of a running worker, the host snapshots `Process.descendants()`
+ and calls `destroyForcibly()` (SIGKILL on Unix) on the worker's
+ `ProcessHandle` and on every process in the snapshot. It signals through the
+ handle because `Process.destroyForcibly()` also closes stdin on the calling
+ thread, which would wait behind a blocked write. Killing the worker and a
+ descendant that holds stdin ends such a write. A descendant that was
+ already reparented is not in the snapshot and keeps running: one whose
+ parent exited, or one that daemonized. So is one started after the
+ snapshot.
+- **The JDK owns the exit.** It reaps the worker as soon as it exits. So
+ stdout ends when the worker has exited and its pipe is empty, not when the
+ worker closes stdout. A descendant still holding a pipe after the exit is
+ neither observed nor waited for. The
+ Go and Rust hosts report a descendant that keeps stderr open after a
+ successful exit as a carrier loss and, on Linux, stop it with the worker's
+ process group. The Java host reports the worker's own result, and the
+ descendant keeps running.
+- **Signals are inferred.** The JDK reports a process ended by signal N as
+ exit status 128 + N. The host reports `signal() == "SIGKILL"` only when it
+ killed the worker itself and the status is 137; any other value is
+ reported as the exit status.
+
+What this guarantees: an invocation returns at its deadline plus bounded
+cleanup. Each cleanup wait is capped at one second: for a killed worker's exit
+to be recorded, for a still-running worker to exit at disposal, and for the
+stdin thread to stop. So in the worst case `invoke` returns about three seconds
+after the deadline. On Unix this holds even while a descendant that escaped
+the kill floods stdout or stderr, and the flood does not reach the heap. The
+carrier leaves no thread or pipe descriptor behind, with one exception: a
+stdin write, or the flush and close that end the stdin thread, blocked on a
+pipe whose read end is held by a descendant that escaped the kill and never
+reads. That stdin thread stays blocked until the descendant exits or reads,
+and so does the JDK's process reaper thread for that worker, which drains the
+same stream under its lock. The caller is not held. On non-Unix systems the
+JDK's output pipes are used, so a descendant that keeps writing after the
+worker exits holds the caller, and grows the heap, until it stops. Separately, a handler that ignores cancellation keeps its own thread
+until it returns (see above). Linux tests count descriptors and adapter threads
+across successful and failed invocations, and check that a blocked write ends
+when expiry or cancellation kills a live worker and the child holding its
+stdin, and that a reparented descendant flooding stdout or stderr holds
+neither the caller nor the heap, at a worker's own exit, at expiry, and at
+cancellation.
+
+## Conformance kit
+
+`Kit.load()` verifies the class-path copy of the kit against the pinned
+digests. `Replayer.replayKit` replays all 21 recordings, including the hostile
+host inputs, against a worker. It reports any semantic differences and any
+drift in non-normative diagnostic prose.
+
+## Development
+
+From this directory, with Maven 3.9+ and JDK 21 or later:
+
+```sh
+mvn -B verify
+# With the pinned Core configured (eval "$(python3 ../scripts/core_pin.py install)"):
+JACQUARD_REQUIRE_LIVE=1 mvn -B verify
+```
+
+The live tests in `LiveTest` skip without `JACQUARD_BIN` and
+`JACQUARD_PRELUDE` (set `JACQUARD_REQUIRE_LIVE` to make them fail instead).
+The offline tests drive `StandIn`, a test-source main class that replays
+recordings or simulates faulty workers in a fresh JVM. Its descendant modes
+use `sh` and `sleep`, so those tests run on Unix only, and the descriptor test
+runs on Linux only. Surefire starts a fresh JVM for each test class.
+
+`src/main/java/org/jacquardlang/host/Compat.java` is generated, and
+`src/main/resources/org/jacquardlang/host/kit/host-protocol-v0/` is a byte copy
+of the pinned kit. `scripts/core_pin.py` maintains both; do not edit them by
+hand. `NOTICE` must stay identical to the repository's `NOTICE`, and the
+README example must stay identical to `src/test/java/org/jacquardlang/host/readme/Echo.java`;
+tests check both.
diff --git a/java/pom.xml b/java/pom.xml
new file mode 100644
index 0000000..a1a8661
--- /dev/null
+++ b/java/pom.xml
@@ -0,0 +1,85 @@
+
+
+ 4.0.0
+
+ org.jacquardlang
+ jacquard-host
+ 0.2.0
+ jar
+
+ Jacquard Host for Java
+ Independent Java adapter for Jacquard Core's experimental jacquard-host-v0 protocol
+ https://github.com/jbwinters/jacquard-host
+
+
+ Apache-2.0
+ https://www.apache.org/licenses/LICENSE-2.0
+
+
+
+
+ 21
+ UTF-8
+ UTF-8
+
+ 2026-10-27T00:00:00Z
+
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ 5.14.4
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-resources-plugin
+ 3.5.0
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.16.0
+
+
+ -Xlint:all
+ -Werror
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.6.1
+
+
+ 1
+ false
+ -Xmx512m
+ false
+
+
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+ 3.5.1
+
+
+
+ org.jacquardlang.host
+
+
+
+
+
+
+
diff --git a/java/src/main/java/org/jacquardlang/host/Compat.java b/java/src/main/java/org/jacquardlang/host/Compat.java
new file mode 100644
index 0000000..c37c0d8
--- /dev/null
+++ b/java/src/main/java/org/jacquardlang/host/Compat.java
@@ -0,0 +1,27 @@
+// Generated from core-pin.json by scripts/core_pin.py; do not edit by hand.
+
+package org.jacquardlang.host;
+
+import java.util.List;
+import java.util.Map;
+
+/** The Jacquard Core release and host protocol this adapter is pinned to. */
+public final class Compat {
+ private Compat() {}
+
+ public static final String PROTOCOL = "jacquard-host-v0";
+ public static final String CARRIER = "stdio-u32-json-v0";
+ public static final String CORE_VERSION = "0.3.0";
+ public static final String CORE_TAG = "jacquard-core-0.3.0";
+ public static final String CORE_COMMIT = "daf87fb4837cc69339116746818f64821dc4cb8e";
+
+ /** SHA-256 digests of the pinned conformance kit files. */
+ public static final List> KIT_DIGESTS = List.of(
+ Map.entry("kit/kit.json", "a01b9401d7c2df4e17771b9c0d22f9ea5ce1a923dfb7d3396c27821c0e058a61"),
+ Map.entry("kit/transcripts.json", "ebaf590e3d1b7e8399f56de5ad85043ac3658416dd7f0af7b55b9f9f3ce93240"),
+ Map.entry("kit/fixtures.jac", "037bb8209ca0344ba0a2cd0fcc16e69f13c8f9b91e3404dc3f3161e351831d80"),
+ Map.entry("vectors.json", "9ba1bff8eee0b6dbea6475dc0c7e9cc6313d847ad80bd8d832457115eb1c738a"));
+
+ /** Decisions Core recorded as pending; replay reproduces them. */
+ public static final List PENDING_DECISIONS = List.of("noncanonical-target-hash");
+}
diff --git a/java/src/main/java/org/jacquardlang/host/Failure.java b/java/src/main/java/org/jacquardlang/host/Failure.java
new file mode 100644
index 0000000..78419ad
--- /dev/null
+++ b/java/src/main/java/org/jacquardlang/host/Failure.java
@@ -0,0 +1,13 @@
+package org.jacquardlang.host;
+
+/** How an unsuccessful invocation failed. */
+public enum Failure {
+ /** The worker's lifetime deadline expired. */
+ DEADLINE_EXCEEDED,
+ /** The carrier was lost: spawn failure, closed pipes, bad exit, or abort. */
+ WORKER_LOST,
+ /** An outside operation failed or its completion is unknown. */
+ OPERATION_FAILED,
+ /** Core reported a diagnostic, or a frame was refused. */
+ OTHER
+}
diff --git a/java/src/main/java/org/jacquardlang/host/Handler.java b/java/src/main/java/org/jacquardlang/host/Handler.java
new file mode 100644
index 0000000..44606c6
--- /dev/null
+++ b/java/src/main/java/org/jacquardlang/host/Handler.java
@@ -0,0 +1,16 @@
+package org.jacquardlang.host;
+
+import java.util.List;
+
+import org.jacquardlang.host.protocol.Value;
+
+/** One effect operation's handler. */
+@FunctionalInterface
+public interface Handler {
+ /**
+ * Performs the operation. Throw {@link HandlerException} for a structured
+ * failure; any other exception is reported as completion unknown, without
+ * its message.
+ */
+ Value handle(HandlerContext context, List arguments) throws Exception;
+}
diff --git a/java/src/main/java/org/jacquardlang/host/HandlerContext.java b/java/src/main/java/org/jacquardlang/host/HandlerContext.java
new file mode 100644
index 0000000..f30b753
--- /dev/null
+++ b/java/src/main/java/org/jacquardlang/host/HandlerContext.java
@@ -0,0 +1,26 @@
+package org.jacquardlang.host;
+
+import java.util.function.BooleanSupplier;
+
+import org.jacquardlang.host.carrier.Cancel;
+
+/** What a handler sees: whether the invocation is still live. */
+public final class HandlerContext {
+ private final BooleanSupplier expired;
+ private final BooleanSupplier ended;
+ private final Cancel cancel;
+
+ HandlerContext(BooleanSupplier expired, BooleanSupplier ended, Cancel cancel) {
+ this.expired = expired;
+ this.ended = ended;
+ this.cancel = cancel;
+ }
+
+ /**
+ * Whether the worker expired, the caller cancelled, or the invocation
+ * ended. Handlers should stop; the host no longer waits for them.
+ */
+ public boolean isCancelled() {
+ return expired.getAsBoolean() || ended.getAsBoolean() || cancel.isCancelled();
+ }
+}
diff --git a/java/src/main/java/org/jacquardlang/host/HandlerException.java b/java/src/main/java/org/jacquardlang/host/HandlerException.java
new file mode 100644
index 0000000..f2e141e
--- /dev/null
+++ b/java/src/main/java/org/jacquardlang/host/HandlerException.java
@@ -0,0 +1,47 @@
+package org.jacquardlang.host;
+
+import java.util.Objects;
+
+import org.jacquardlang.host.protocol.FailureCategory;
+
+/**
+ * A handler's structured refusal or failure, reported to Core with its
+ * category and message. Any other exception a handler throws is reported as
+ * completion unknown, without its message.
+ */
+public final class HandlerException extends Exception {
+ private static final long serialVersionUID = 1L;
+
+ private final FailureCategory category;
+
+ /** A failure in one of the four permitted categories, with a message for Core. */
+ public HandlerException(FailureCategory category, String message) {
+ super(Objects.requireNonNull(message, "message"));
+ this.category = Objects.requireNonNull(category, "category");
+ }
+
+ /** The failure category. */
+ public FailureCategory category() {
+ return category;
+ }
+
+ /** The host does not provide the operation (not started). */
+ public static HandlerException unsupported(String message) {
+ return new HandlerException(FailureCategory.UNSUPPORTED_OPERATION, message);
+ }
+
+ /** The host refused to authorize the operation (not started). */
+ public static HandlerException refused(String message) {
+ return new HandlerException(FailureCategory.REFUSED_AUTHORITY, message);
+ }
+
+ /** An outside action failed without effect. */
+ public static HandlerException outsideFailed(String message) {
+ return new HandlerException(FailureCategory.OUTSIDE_FAILURE, message);
+ }
+
+ /** An outside action's completion is unknown. */
+ public static HandlerException completionUnknown(String message) {
+ return new HandlerException(FailureCategory.COMPLETION_UNKNOWN, message);
+ }
+}
diff --git a/java/src/main/java/org/jacquardlang/host/Host.java b/java/src/main/java/org/jacquardlang/host/Host.java
new file mode 100644
index 0000000..754a839
--- /dev/null
+++ b/java/src/main/java/org/jacquardlang/host/Host.java
@@ -0,0 +1,379 @@
+package org.jacquardlang.host;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.jacquardlang.host.carrier.Cancel;
+import org.jacquardlang.host.carrier.CarrierException;
+import org.jacquardlang.host.carrier.Options;
+import org.jacquardlang.host.carrier.Worker;
+import org.jacquardlang.host.json.Json;
+import org.jacquardlang.host.json.JsonCodec;
+import org.jacquardlang.host.protocol.CoreFrame;
+import org.jacquardlang.host.protocol.EffectRequest;
+import org.jacquardlang.host.protocol.FailureCategory;
+import org.jacquardlang.host.protocol.Hash;
+import org.jacquardlang.host.protocol.Interface;
+import org.jacquardlang.host.protocol.Limits;
+import org.jacquardlang.host.protocol.OperationEntry;
+import org.jacquardlang.host.protocol.Protocol;
+import org.jacquardlang.host.protocol.Session;
+import org.jacquardlang.host.protocol.State;
+import org.jacquardlang.host.protocol.Terminal;
+import org.jacquardlang.host.protocol.Value;
+
+/**
+ * Invokes checked Jacquard terms in one prepared store through
+ * {@code jacquard-host-v0} and serves their effect requests from a closed
+ * registry of Java handlers. Each invocation runs one fresh, serial worker.
+ */
+public final class Host {
+ /** Host evidence schema recorded beside Core's frames. */
+ public static final String EVIDENCE_SCHEMA = "jacquard-host-java-evidence-v0";
+
+ private final String store;
+ private final Limits limits;
+ private final Options options;
+
+ /**
+ * Validates options before any worker starts.
+ *
+ * @throws IllegalArgumentException for invalid limits or carrier options
+ */
+ public Host(String store, HostOptions options) {
+ this.store = Objects.requireNonNull(store, "store");
+ Limits selected = options.limits() == null ? Limits.HARD : options.limits();
+ try {
+ selected.validate();
+ } catch (ProtocolException error) {
+ throw new IllegalArgumentException(error.getMessage(), error);
+ }
+ options.carrier().validate();
+ this.limits = selected;
+ this.options = options.carrier();
+ }
+
+ /** The prepared store directory. */
+ public String store() {
+ return store;
+ }
+
+ /** The limits each session selects. */
+ public Limits limits() {
+ return limits;
+ }
+
+ /**
+ * Runs {@code target} with {@code args}. The invoke frame is validated
+ * before a worker starts. Cancelling {@code cancel}, or interrupting the
+ * calling thread, kills the worker; nothing is retried.
+ *
+ * @throws NullPointerException if any argument is null
+ */
+ public InvocationResult invoke(Hash target, Interface spec, List args, OperationRegistry registry, Cancel cancel) {
+ Objects.requireNonNull(target, "target");
+ Objects.requireNonNull(spec, "spec");
+ Objects.requireNonNull(args, "args");
+ Objects.requireNonNull(registry, "registry");
+ Objects.requireNonNull(cancel, "cancel");
+ List entries = registry.entries();
+ try {
+ Map frame = Protocol.invokeFrame(target, spec, args, entries, limits);
+ JsonCodec.frameBytes(frame, limits.maxFrameBytes(), limits.maxJsonDepth());
+ } catch (ProtocolException error) {
+ InvocationResult result = new InvocationResult(ResultKind.HOST_ERROR);
+ result.hostEvidence = Json.object(
+ "code", Json.of(error.code()),
+ "detail", Json.of(error.detail()),
+ "reason", Json.of("invoke_refused_locally"),
+ "schema", Json.of(EVIDENCE_SCHEMA));
+ return result;
+ }
+ return run(session -> session.invoke(target, spec, args, entries), registry, cancel);
+ }
+
+ /**
+ * Negotiates a session and shuts it down without invoking.
+ *
+ * @throws NullPointerException if {@code cancel} is null
+ */
+ public InvocationResult shutdownProbe(Cancel cancel) {
+ Objects.requireNonNull(cancel, "cancel");
+ return run(Session::shutdown, new OperationRegistry(), cancel);
+ }
+
+ @FunctionalInterface
+ private interface Opening {
+ Map render(Session session) throws ProtocolException;
+ }
+
+ /** Whether a host frame was being encoded or sent when a failure occurred. */
+ private static final class Sending {
+ boolean active;
+ }
+
+ private InvocationResult run(Opening first, OperationRegistry registry, Cancel cancel) {
+ Session session = new Session();
+ InvocationResult result = new InvocationResult(ResultKind.CARRIER_LOSS);
+ Worker worker;
+ try {
+ worker = Worker.start(store, options, cancel);
+ } catch (IllegalArgumentException error) {
+ result.hostEvidence = Json.object(
+ "detail", Json.of(error.getMessage()), "reason", Json.of("invalid_options"), "schema", Json.of(EVIDENCE_SCHEMA));
+ return result;
+ }
+ Sending sending = new Sending();
+ AtomicBoolean ended = new AtomicBoolean();
+ try {
+ drive(worker, session, result, sending, first, registry, cancel, ended);
+ } catch (CarrierException error) {
+ ResultKind kind;
+ String code;
+ String reason;
+ if (!error.isLoss()) {
+ code = error.protocolError().code();
+ kind = sending.active ? ResultKind.HOST_ERROR : ResultKind.PROTOCOL_ERROR;
+ reason = sending.active ? "host_frame_refused" : "core_frame_refused";
+ } else {
+ kind = ResultKind.CARRIER_LOSS;
+ code = "E1611";
+ if (error.isTimeout()) {
+ reason = "deadline";
+ } else if (worker.spawnError() != null) {
+ reason = "worker_spawn_failed";
+ } else if (error.isAborted()) {
+ reason = "aborted";
+ } else {
+ reason = "carrier_loss";
+ }
+ }
+ result.kind = kind;
+ Long outstanding = session.pending();
+ result.hostEvidence = Json.object(
+ "code", Json.of(code),
+ "detail", Json.of(error.getMessage()),
+ "outstanding_request", outstanding == null ? Json.NULL : Json.of(outstanding),
+ "reason", Json.of(reason),
+ "schema", Json.of(EVIDENCE_SCHEMA),
+ "state", Json.of(session.state().wireName()));
+ } finally {
+ worker.dispose();
+ ended.set(true);
+ }
+ result.requests = List.copyOf(session.requests());
+ result.exitStatus = worker.exitStatus();
+ result.signal = worker.signal();
+ result.stderr = worker.stderr();
+ return result;
+ }
+
+ private static void send(Worker worker, Map frame, Limits limits, InvocationResult result, Sending sending)
+ throws CarrierException {
+ sending.active = true;
+ worker.writeFrame(frame, limits);
+ result.hostFrames.add(frame);
+ sending.active = false;
+ }
+
+ private static CarrierException protocol(ProtocolException error) {
+ return CarrierException.protocol(error);
+ }
+
+ private void drive(Worker worker, Session session, InvocationResult result, Sending sending, Opening first,
+ OperationRegistry registry, Cancel cancel, AtomicBoolean ended) throws CarrierException {
+ Map hello = worker.readFrame(Limits.HARD);
+ if (hello == null) {
+ throw CarrierException.loss("worker exited before core_hello");
+ }
+ result.coreFrames.add(hello);
+ Map selected;
+ try {
+ session.receive(hello);
+ selected = session.select(limits);
+ } catch (ProtocolException error) {
+ throw protocol(error);
+ }
+ send(worker, selected, Limits.HARD, result, sending);
+ worker.setStderrLimit(limits.maxStderrBytes());
+ Map opening;
+ try {
+ opening = first.render(session);
+ } catch (ProtocolException error) {
+ throw protocol(error);
+ }
+ send(worker, opening, session.limits(), result, sending);
+ while (session.state() != State.CLOSED) {
+ Map frame = worker.readFrame(session.limits());
+ if (frame == null) {
+ throw CarrierException.loss("worker exited before a terminal frame");
+ }
+ result.coreFrames.add(frame);
+ CoreFrame parsed;
+ try {
+ parsed = session.receive(frame);
+ } catch (ProtocolException error) {
+ throw protocol(error);
+ }
+ switch (parsed) {
+ case EffectRequest request -> {
+ Map response = respond(worker, session, registry, request, cancel, ended);
+ send(worker, response, session.limits(), result, sending);
+ }
+ case Terminal.Outcome outcome -> {
+ result.kind = outcome.value() != null ? ResultKind.OK : ResultKind.ERROR;
+ result.value = outcome.value();
+ result.diagnostics = outcome.diagnostics();
+ result.terminal = outcome.terminal();
+ result.evidence = outcome.evidence();
+ }
+ case Terminal.Fatal fatal -> {
+ result.kind = ResultKind.FATAL;
+ result.diagnostics = fatal.diagnostics();
+ }
+ case Terminal.ShutdownAck ignored -> result.kind = ResultKind.SHUTDOWN_ACK;
+ case null -> {
+ // core_hello was handled above.
+ }
+ }
+ }
+ worker.closeInput();
+ Map extra = worker.readFrame(session.limits());
+ if (extra != null) {
+ result.coreFrames.add(extra);
+ try {
+ session.receive(extra);
+ } catch (ProtocolException error) {
+ throw protocol(error);
+ }
+ }
+ worker.awaitExit();
+ if (!Integer.valueOf(0).equals(worker.exitStatus())) {
+ throw CarrierException.loss("worker exited unsuccessfully after its terminal");
+ }
+ }
+
+ private sealed interface Answer {}
+
+ private record Returned(Value value) implements Answer {}
+
+ private record Failed(FailureCategory category, String message) implements Answer {}
+
+ /**
+ * Test seam: runs on the handler thread just before its check, so a test
+ * can stop the call at exactly that point. Null outside tests. It is
+ * global, so tests that set it must not run in parallel in one JVM.
+ */
+ static volatile Runnable handlerCheckHook;
+
+ /** The call stopped before the handler began; the fault check after the wait reports why. */
+ private static final Answer NOT_STARTED = new Failed(FailureCategory.OUTSIDE_FAILURE, "handler was not started");
+
+ /**
+ * Runs the handler on its own thread, bounded by the worker lifetime, and
+ * renders the response. A handler still running at the deadline is
+ * abandoned: no late response is sent and nothing is retried.
+ */
+ private static Map respond(Worker worker, Session session, OperationRegistry registry, EffectRequest request,
+ Cancel cancel, AtomicBoolean ended) throws CarrierException {
+ Handler handler = registry.handler(request);
+ Answer answer;
+ if (handler == null) {
+ answer = new Failed(FailureCategory.UNSUPPORTED_OPERATION, "operation is not configured");
+ } else {
+ // Decoding the request takes time; an outside action must not
+ // begin once the call is cancelled, the caller interrupted, or the
+ // lifetime passed. The caller checks before creating the handler
+ // thread, and the thread checks again before calling the handler.
+ CarrierException early = worker.fault();
+ if (early != null) {
+ throw early;
+ }
+ HandlerContext context = new HandlerContext(worker::stopping, ended::get, cancel);
+ List arguments = request.arguments();
+ CompletableFuture done = new CompletableFuture<>();
+ Thread thread = new Thread(() -> {
+ Runnable hook = handlerCheckHook;
+ if (hook != null) {
+ hook.run();
+ }
+ done.complete(worker.stopping() ? NOT_STARTED : call(handler, context, arguments));
+ }, "jacquard-handler");
+ thread.setDaemon(true);
+ boolean started;
+ try {
+ thread.start();
+ started = true;
+ } catch (OutOfMemoryError error) {
+ started = false;
+ }
+ if (!started) {
+ // The handler never ran.
+ answer = new Failed(FailureCategory.OUTSIDE_FAILURE, "handler thread could not start");
+ } else {
+ Answer awaited = worker.await(done);
+ answer = awaited != null ? awaited : new Failed(FailureCategory.COMPLETION_UNKNOWN, "handler threw an exception");
+ }
+ }
+ CarrierException fault = worker.fault();
+ if (fault != null) {
+ throw fault;
+ }
+ Failed failure;
+ try {
+ switch (answer) {
+ case Returned returned -> {
+ try {
+ session.okFrame(returned.value());
+ return session.respondOk(returned.value());
+ } catch (ProtocolException error) {
+ failure = new Failed(FailureCategory.OUTSIDE_FAILURE, "handler result has no encoding within the selected limits");
+ }
+ }
+ case Failed failed -> failure = failed;
+ }
+ return session.respondFailure(failure.category(), bounded(failure.message(), session.limits().maxHostMessageBytes()));
+ } catch (ProtocolException error) {
+ throw protocol(error);
+ }
+ }
+
+ /** Calls a handler, turning every exception into a failure answer. */
+ private static Answer call(Handler handler, HandlerContext context, List arguments) {
+ try {
+ Value value = handler.handle(context, arguments);
+ return new Returned(value);
+ } catch (HandlerException failure) {
+ return new Failed(failure.category(), failure.getMessage());
+ } catch (Throwable other) {
+ // Its message may carry private detail and is never sent.
+ return new Failed(FailureCategory.COMPLETION_UNKNOWN, "handler threw an exception");
+ }
+ }
+
+ /**
+ * At most {@code limit} UTF-8 bytes of {@code text}, cut at a code point
+ * boundary; a lone surrogate, which has no encoding, becomes U+FFFD.
+ */
+ static String bounded(String text, int limit) {
+ StringBuilder out = new StringBuilder();
+ int used = 0;
+ for (int i = 0; i < text.length(); ) {
+ int point = text.codePointAt(i);
+ i += Character.charCount(point);
+ if (point >= Character.MIN_SURROGATE && point <= Character.MAX_SURROGATE) {
+ point = 0xFFFD;
+ }
+ int size = point < 0x80 ? 1 : point < 0x800 ? 2 : point < 0x10000 ? 3 : 4;
+ if (used + size > limit) {
+ break;
+ }
+ out.appendCodePoint(point);
+ used += size;
+ }
+ return out.toString();
+ }
+}
diff --git a/java/src/main/java/org/jacquardlang/host/HostOptions.java b/java/src/main/java/org/jacquardlang/host/HostOptions.java
new file mode 100644
index 0000000..f75f95e
--- /dev/null
+++ b/java/src/main/java/org/jacquardlang/host/HostOptions.java
@@ -0,0 +1,23 @@
+package org.jacquardlang.host;
+
+import org.jacquardlang.host.carrier.Options;
+import org.jacquardlang.host.protocol.Limits;
+
+/**
+ * Options for a {@link Host}.
+ *
+ * @param carrier the worker command, environment, and lifetime deadline; null means defaults
+ * @param limits the session limits; null selects the hard limits
+ */
+public record HostOptions(Options carrier, Limits limits) {
+ public HostOptions {
+ if (carrier == null) {
+ carrier = Options.defaults();
+ }
+ }
+
+ /** Default carrier options and the hard limits. */
+ public static HostOptions defaults() {
+ return new HostOptions(Options.defaults(), null);
+ }
+}
diff --git a/java/src/main/java/org/jacquardlang/host/InvocationException.java b/java/src/main/java/org/jacquardlang/host/InvocationException.java
new file mode 100644
index 0000000..fe8a18f
--- /dev/null
+++ b/java/src/main/java/org/jacquardlang/host/InvocationException.java
@@ -0,0 +1,35 @@
+package org.jacquardlang.host;
+
+import org.jacquardlang.host.json.Json;
+
+/** An unsuccessful invocation. */
+public final class InvocationException extends Exception {
+ private static final long serialVersionUID = 1L;
+
+ private final transient InvocationResult result;
+
+ InvocationException(InvocationResult result) {
+ super(result.message());
+ this.result = result;
+ }
+
+ /** The result, with Core's frames and the host's evidence. */
+ public InvocationResult result() {
+ return result;
+ }
+
+ /** The classification. */
+ public Failure failure() {
+ if (Json.of("deadline").equals(result.hostEvidence().get("reason"))) {
+ return Failure.DEADLINE_EXCEEDED;
+ }
+ if (result.kind() == ResultKind.CARRIER_LOSS) {
+ return Failure.WORKER_LOST;
+ }
+ String terminal = result.terminal();
+ if ("host_failure".equals(terminal) || "completion_unknown".equals(terminal)) {
+ return Failure.OPERATION_FAILED;
+ }
+ return Failure.OTHER;
+ }
+}
diff --git a/java/src/main/java/org/jacquardlang/host/InvocationResult.java b/java/src/main/java/org/jacquardlang/host/InvocationResult.java
new file mode 100644
index 0000000..39d61b8
--- /dev/null
+++ b/java/src/main/java/org/jacquardlang/host/InvocationResult.java
@@ -0,0 +1,140 @@
+package org.jacquardlang.host;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import org.jacquardlang.host.json.Json;
+import org.jacquardlang.host.protocol.Diagnostic;
+import org.jacquardlang.host.protocol.EffectRequest;
+import org.jacquardlang.host.protocol.Value;
+
+/** Core's frames and evidence beside the host's observations. */
+public final class InvocationResult {
+ ResultKind kind;
+ Value value;
+ List diagnostics = List.of();
+ String terminal;
+ Map evidence;
+ final List