Skip to content

Add a Java SDK layer - #26

Merged
jbwinters merged 49 commits into
mainfrom
task/java-sdk
Oct 29, 2026
Merged

jbwinters merged 49 commits into
mainfrom
task/java-sdk

Conversation

@jbwinters

Copy link
Copy Markdown
Collaborator

Adds a Java SDK layer on top of the Java protocol adapter in java/, matching the Rust, Go, Python and TypeScript SDKs. It adds org.jacquardlang.host.sdk and a carrier.Command that runs Core's commands.

  • Prepare: Runtime.prepare checks, hashes and installs sources through Core's public check/hash/run commands. They go into an owner-only, verified cache keyed on the source bytes, the Core executable, every prelude file and the protocol. A changed entry is refused, and bindings are derived again from Core's cached output. Core's --version and prelude discovery run once per key across instances and threads.
  • Call: encoded arguments are checked against the checked signature, the limits and the frame size before any worker starts. call, invoke and mapBatches (every batch validated first, no retries) each have a Cancel variant. Invocation supports decode, value, render and owner-only save. Every failure is one exception type, and a failed result mapping keeps the raw result.
  • Operations: handlers are bound to exact operation identities. Every operation of each checked effect must be bound, and non-operations are refused. HandlerException categories map as in Rust, and any other exception becomes completion unknown.
  • Data: codecs are derived for records, sealed hierarchies, List and Optional, plus Dynamic. Messages, names, paths, signature parsing and type nesting are all bounded. Walks over caller-built structures are iterative, so deep input cannot overflow the stack.
  • Core commands: each one is bounded in time and output. On Linux, each runs in its own process group through setsid, and that group is killed when the command ends or is stopped.
  • Examples: the four example projects run through the Java command, with the same --inspect, --fault and --deliver-pending paths as the Rust command. CI runs them.

CI runs the offline suite on Temurin 21 and 25. The live suite runs against the pinned Core with live tests required: 176 tests on each JDK, none skipped.

Java-specific limits are recorded in docs/LIMITS.md and java/README.md: Core command descendants, the cache trust boundary, signature depth, and record component order. docs/EVIDENCE.md lists a regression test for each hardening fix, each confirmed to fail on the code before it.

Move the worker's output FIFO setup into OutputPipes so a second user can
share it, and add carrier.Command, which runs one short command with host-owned
FIFOs, a timeout, bounded output, and cancellation and interruption that kill
its process tree. The SDK will run Core's public commands through it.
Codec objects map Java values to Jacquard values: integers, double, text,
Hash, boolean, List, Optional, unit, tuples, protocol values, records and
enums and sealed hierarchies as constructors, and Dynamic by public name.
Codecs can be derived from declarations through record components. Every
encoding is bounded as it goes by the selected limits, value nodes and text
included, so a small shared value cannot cost more than one frame.
Signature and identity parsing follow the Rust SDK.
Runtime.prepare checks, hashes, and installs sources with Core's public
commands into a verified cache, running each command through the bounded
carrier command runner, with one version check and prelude discovery per key
in the JVM. Operations binds typed handlers, Program binds checked functions
with complete services, and Function calls, invokes, and maps explicit
batches, with Invocation keeping the raw evidence. HandlerContext.of makes a
context for calling a handler outside an invocation.
Port the Rust SDK's live suite: quickstart calls and saved inspection files,
the 64-row triage oracle in explicit batches, local refusals before any
worker, binding completeness, unknown completion with raw evidence, the
25-case planner oracle, cache reuse and invalidation, the full Int range and
Real bits, cancellation, output overflow, and result-mapping failures. A
further test shows no handler starts once a real invocation stops while a
request's arguments are decoded.
JacquardExamples runs quickstart, planner, triage, and fulfillment from the
rules in examples/, with the same --inspect, --fault, and --deliver-pending
recovery paths as the Rust and Go commands. Fulfillment's warehouse commits
bookings and outbox entries as one atomic JSON snapshot.
Data.decode now refuses a value with more nodes than max_value_nodes, or
nested past the JSON depth, before decoding it, so a shared or deep value
built in Java cannot make decoding unbounded. Every value Core sends is
within these limits.
Document preparation, codecs and the reason for them, services, errors,
batches, and the example projects, and keep the protocol-level host,
handler, carrier, and kit sections. The README's Java examples are compiled
test sources under readme/, checked by ReadmeTest and run by the live
tests.
Update the root and examples READMEs, CONTRIBUTING, LIMITS (Java SDK rows
replace the protocol-level-only row), and the changelog. The live CI job now
names the Java SDK and runs the four example projects, with duplicate
fulfillment and explicit recovery, through the Java command.
The node-budget and decode tests now use a shared value small enough to
build in full, so removing either guard fails an assertion rather than
exhausting the heap. Test children now outlive the kill checks by minutes,
so a command runner that does not kill them fails within ten seconds. Two
README sentences are made exact.
Add the Java SDK and projects lane: offline, live, and project test counts
on JDK 21 and 25 against jacquard-core-0.3.0, the mutation checks behind
each regression, and the unchanged lanes.
JacquardException.batch copied the completed values with List.copyOf,
which throws on the nulls Codec.UNIT decodes to, so a failure after a
successful List () batch escaped as a NullPointerException.
Each batch is encoded and checked, then discarded, and encoded again
just before it runs. Between batches the validation observes the
Cancel, the caller's interrupt, and the runtime deadline.
A tuple codec now applies max_collection_items before it encodes any
item. A raw Value text is charged like an encoded one: by UTF-8 bytes,
refusing a lone surrogate. Document that a codec returning a Value it
built itself bypasses the encoder's budget and is measured again by the
invoke frame.
Each source must be a regular file (a symbolic link to one is followed)
and all sources together at most 16 MiB, checked from their sizes before
they are read. The prelude and cache walks read regular files only, at
most 256 MiB under one root. Both observe the Cancel and the interrupt
between files.
A cache entry now keeps Core's raw check --print-sigs output and the
hash output for the source and each prelude file as verified files.
Both the cold and the warm path derive names, constructors, signatures,
and groups from those outputs, and a load refuses a program.json whose
bindings differ. The program schema is now jacquard-java-program-v2.
After a command exits, the host waits up to one second for its output to
close: on Linux, until no other process of this user holds either FIFO,
found through /proc/<pid>/fd. A holder then fails the command with "its
output did not close", as in the Rust SDK, and is killed if it started
no earlier than the command. Descendants recorded while the command ran
(by pid and start time) are killed when it returns.
On Linux with util-linux setsid, each Core command runs as setsid -- core,
which execs without forking, so the command keeps its pid and leads a new
process group. The group is killed when the command exits and on every
stop: each /proc entry's ProcessHandle is acquired first, then its group id
read from /proc/<pid>/stat, and only a match is killed through that handle.
The /proc FIFO-holder search and kill are removed: holding the output did
not prove a process belonged to the command, and JVM-parented orphans under
a subreaper were missed. Elsewhere the recorded-descendant kill remains. A
stop during the exit cleanup is reported instead of success.
…dation

Rust, Go, Python, and TypeScript bound batch validation by no deadline, and
the runtime deadline made the refusal depend on the machine's speed. The
Cancel and the interrupt are still observed between batches, and now once
more after the last batch is validated, before the first worker starts.
Validation returns nothing, so no batch's encoding is kept; the weak-
reference test that depended on System.gc() is replaced by exact counts.
A custom codec can return a small shared graph that stands for
exponentially many values, and the type check walked it unbounded. Every
encoded argument is now measured under one shared node, depth, text, and
collection budget before the type check runs, whatever codec built it.
No anchor inside a cache entry resists someone who can rewrite it, so only
its owner may: on POSIX, a cache root or entry, or any file or directory in
it, that the current user does not own or that its group or others may
write is refused on create and load, and new entries are made owner-only.

The Core executable is refused over 512 MiB from its size and again while
it is read, observing the Cancel and the interrupt between pieces. Tree
walks stream each directory with stop checks, refuse more than 100,000
entries (empty files count), and enforce the byte ceiling as they go.
… reads

Also test that a descendant that left the group and floods stderr holds
neither the caller nor the heap: the FIFOs, not the group kill, cover it.
A signature nesting types deeper than 64 levels, the protocol's value
depth, naming more than 4,096 types, or longer than 16,384 tokens is
refused before the parser recurses past it. Such a function is recorded
as unbound with its reason, so binding it fails with a BINDING exception
naming the limit while the program's other functions still bind.
…d cache root

A source list over 1,024 is refused from its size before any element is
read, and validation observes the Cancel and the interrupt. The failure
cleanup is a streamed walk of at most 100,000 entries that ends at a
stop, leaving the unpublished staging directory; a later preparation
removes staging directories over an hour old within the same bounds.
The cache root is resolved to its real directory before it is checked,
and the writable-root refusal says how to fix it.
Also tests the signature parser's depth, type-count, and token bounds.
Exit status 126 or 127 with stderr starting "setsid: " means the command
never ran. Also states the pid-wraparound window of the group check.
…nd keep a file the example store did not create
… leave the staging path alone once published
…ning it, cap messages exactly, and refuse an impossible executable name
…reparation failure quoted as an excerpt, and note a message's length once
…lers, and Dynamic values; join a null message safely
…, measure paths as the file system sees them, and distrust an impossible noted length
… flatten, and keep a failed absolute-path lookup a refusal
…olved, by one iterative walk over the case graph
… List and Optional types iteratively within the v0 depth
@jbwinters
jbwinters merged commit 34a8f1d into main Oct 29, 2026
12 checks passed
@jbwinters
jbwinters deleted the task/java-sdk branch October 29, 2026 04:33
@jbwinters jbwinters mentioned this pull request Oct 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant