env) {
+ String v = env.get("ANTHROPIC_BASE_URL");
+ if (v != null && !v.isBlank()) {
+ throw new GuardException("primary environment is tainted: ANTHROPIC_BASE_URL="
+ + v + " — the primary must run on the subscription, never a base_url");
+ }
+ }
+
+ private static String hostOf(String baseUrl) {
+ try {
+ return new URI(baseUrl).getHost();
+ } catch (URISyntaxException e) {
+ return null;
+ }
+ }
+}
diff --git a/bridged/src/main/java/dev/ltms/bridged/herdr/HerdrClient.java b/bridged/src/main/java/dev/ltms/bridged/herdr/HerdrClient.java
new file mode 100644
index 0000000..fb215da
--- /dev/null
+++ b/bridged/src/main/java/dev/ltms/bridged/herdr/HerdrClient.java
@@ -0,0 +1,38 @@
+package dev.ltms.bridged.herdr;
+
+import com.fasterxml.jackson.databind.JsonNode;
+
+/**
+ * Client face onto the herdr daemon (protocol 14, herdr 0.7.0).
+ *
+ * This is the ONLY thing in {@code bridged} that speaks to herdr. Every method
+ * maps to a herdr JSON-RPC call over its Unix domain socket. Requests are
+ * newline-delimited JSON with a string id; responses carry either a
+ * {@code result} object (whose {@code type} field discriminates the payload) or an
+ * {@code error} object.
+ *
+ *
Higher layers ({@code bridged}'s policy brain, REST endpoints, MCP adapters)
+ * depend on this interface, not on the socket. Tests substitute a fake; the
+ * {@code contract}-tagged suite exercises the real implementation against a running
+ * herdr to catch protocol drift.
+ */
+public interface HerdrClient extends AutoCloseable {
+
+ /**
+ * Invoke a herdr method and return its {@code result} node.
+ *
+ * @param method herdr method name, e.g. {@code "ping"}, {@code "workspace.list"}
+ * @param params params object (may be {@code null} → sent as {@code {}}); serialized by Jackson
+ * @return the {@code result} node of the response
+ * @throws HerdrException on transport failure or an {@code error} envelope
+ */
+ JsonNode call(String method, Object params) throws HerdrException;
+
+ /** Convenience for parameterless calls. */
+ default JsonNode call(String method) throws HerdrException {
+ return call(method, null);
+ }
+
+ @Override
+ void close();
+}
diff --git a/bridged/src/main/java/dev/ltms/bridged/herdr/HerdrCodec.java b/bridged/src/main/java/dev/ltms/bridged/herdr/HerdrCodec.java
new file mode 100644
index 0000000..f44c816
--- /dev/null
+++ b/bridged/src/main/java/dev/ltms/bridged/herdr/HerdrCodec.java
@@ -0,0 +1,70 @@
+package dev.ltms.bridged.herdr;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Wire codec for herdr's newline-delimited JSON-RPC (protocol 14).
+ *
+ *
Split out from the socket so the framing rules — the ones that actually bit us
+ * during the spike (id MUST be a string; response carries {@code result} or
+ * {@code error}, never {@code jsonrpc}) — are unit-testable without a live daemon.
+ */
+final class HerdrCodec {
+
+ private final ObjectMapper mapper;
+
+ HerdrCodec(ObjectMapper mapper) {
+ this.mapper = mapper;
+ }
+
+ /** Build one request frame: a single JSON object terminated by {@code '\n'}. */
+ byte[] encode(String id, String method, Object params) {
+ ObjectNode req = mapper.createObjectNode();
+ req.put("jsonrpc", "2.0");
+ req.put("id", id); // string id — herdr rejects integer ids with invalid_request
+ req.put("method", method);
+ JsonNode p = params == null ? mapper.createObjectNode() : mapper.valueToTree(params);
+ req.set("params", p);
+ try {
+ String line = mapper.writeValueAsString(req) + "\n";
+ return line.getBytes(StandardCharsets.UTF_8);
+ } catch (JsonProcessingException e) {
+ throw new HerdrException("failed to encode herdr request for method " + method, e);
+ }
+ }
+
+ /**
+ * Parse one response frame and return its {@code result} node.
+ *
+ * @throws HerdrException if the frame is an {@code error} envelope or is malformed
+ */
+ JsonNode decodeResult(String line) {
+ JsonNode root;
+ try {
+ root = mapper.readTree(line);
+ } catch (JsonProcessingException e) {
+ throw new HerdrException("malformed herdr response: " + trim(line), e);
+ }
+ JsonNode error = root.get("error");
+ if (error != null && !error.isNull()) {
+ String code = error.path("code").asText(null);
+ String message = error.path("message").asText("unknown herdr error");
+ throw new HerdrException("herdr error [" + code + "]: " + message, code, null);
+ }
+ JsonNode result = root.get("result");
+ if (result == null || result.isNull()) {
+ throw new HerdrException("herdr response has neither result nor error: " + trim(line));
+ }
+ return result;
+ }
+
+ private static String trim(String s) {
+ String t = s.strip();
+ return t.length() > 200 ? t.substring(0, 200) + "…" : t;
+ }
+}
diff --git a/bridged/src/main/java/dev/ltms/bridged/herdr/HerdrException.java b/bridged/src/main/java/dev/ltms/bridged/herdr/HerdrException.java
new file mode 100644
index 0000000..25540a1
--- /dev/null
+++ b/bridged/src/main/java/dev/ltms/bridged/herdr/HerdrException.java
@@ -0,0 +1,30 @@
+package dev.ltms.bridged.herdr;
+
+/**
+ * Raised when a herdr call fails: transport error, or an {@code error} envelope
+ * returned by the daemon. {@link #code()} carries herdr's error code
+ * (e.g. {@code invalid_request}) when the failure came back as a protocol error,
+ * or {@code null} for transport-level failures.
+ */
+public class HerdrException extends RuntimeException {
+
+ private final String code;
+
+ public HerdrException(String message) {
+ this(message, null, null);
+ }
+
+ public HerdrException(String message, Throwable cause) {
+ this(message, null, cause);
+ }
+
+ public HerdrException(String message, String code, Throwable cause) {
+ super(message, cause);
+ this.code = code;
+ }
+
+ /** herdr protocol error code, or {@code null} if this was a transport failure. */
+ public String code() {
+ return code;
+ }
+}
diff --git a/bridged/src/main/java/dev/ltms/bridged/herdr/UnixSocketHerdrClient.java b/bridged/src/main/java/dev/ltms/bridged/herdr/UnixSocketHerdrClient.java
new file mode 100644
index 0000000..b2045d0
--- /dev/null
+++ b/bridged/src/main/java/dev/ltms/bridged/herdr/UnixSocketHerdrClient.java
@@ -0,0 +1,122 @@
+package dev.ltms.bridged.herdr;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.net.StandardProtocolFamily;
+import java.net.UnixDomainSocketAddress;
+import java.nio.ByteBuffer;
+import java.nio.channels.SocketChannel;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * {@link HerdrClient} over herdr's Unix domain socket using the JDK's
+ * {@link UnixDomainSocketAddress} + {@link SocketChannel} (no native/JNI dependency).
+ *
+ *
Connection-per-call. The contract test against herdr 0.7.0 established
+ * that herdr serves one request/response per connection and then closes it —
+ * a second write on the same socket gets a broken pipe. So every {@link #call} opens a
+ * fresh connection, writes one frame, reads one line, and closes. A pleasant
+ * consequence: with no shared socket there is no shared read state, so the client is
+ * safe to call concurrently from many virtual threads with no locking.
+ *
+ *
Streaming methods ({@code events.subscribe}) keep their own long-lived connection
+ * and land in a later ticket; they do not reuse this request/response path.
+ */
+public final class UnixSocketHerdrClient implements HerdrClient {
+
+ private static final Logger log = LoggerFactory.getLogger(UnixSocketHerdrClient.class);
+
+ /** herdr's default socket, overridable by {@code HERDR_SOCKET_PATH}. */
+ public static Path defaultSocketPath() {
+ String override = System.getenv("HERDR_SOCKET_PATH");
+ if (override != null && !override.isBlank()) {
+ return Path.of(override);
+ }
+ return Path.of(System.getProperty("user.home"), ".config", "herdr", "herdr.sock");
+ }
+
+ private final Path socketPath;
+ private final HerdrCodec codec;
+ private final AtomicLong ids = new AtomicLong(1);
+
+ private UnixSocketHerdrClient(Path socketPath, ObjectMapper mapper) {
+ this.socketPath = socketPath;
+ this.codec = new HerdrCodec(mapper);
+ }
+
+ /** Client for the default socket with a fresh {@link ObjectMapper}. */
+ public static UnixSocketHerdrClient connect() {
+ return connect(defaultSocketPath(), new ObjectMapper());
+ }
+
+ /**
+ * Client for {@code socketPath}. Does not hold a connection open (herdr is
+ * one-shot per connection); connectivity surfaces on the first {@link #call}.
+ */
+ public static UnixSocketHerdrClient connect(Path socketPath, ObjectMapper mapper) {
+ log.debug("herdr client bound to socket {}", socketPath);
+ return new UnixSocketHerdrClient(socketPath, mapper);
+ }
+
+ @Override
+ public JsonNode call(String method, Object params) {
+ String id = Long.toString(ids.getAndIncrement());
+ byte[] frame = codec.encode(id, method, params);
+ try (SocketChannel ch = SocketChannel.open(StandardProtocolFamily.UNIX)) {
+ ch.connect(UnixDomainSocketAddress.of(socketPath));
+ writeFully(ch, ByteBuffer.wrap(frame));
+ return codec.decodeResult(readLine(ch));
+ } catch (IOException e) {
+ throw new HerdrException("herdr call '" + method + "' failed at transport (socket "
+ + socketPath + ", is herdr running?)", e);
+ }
+ }
+
+ private static void writeFully(SocketChannel ch, ByteBuffer buf) throws IOException {
+ while (buf.hasRemaining()) {
+ ch.write(buf);
+ }
+ }
+
+ /** Read up to and including the first {@code '\n'}, returning the line without it. */
+ private static String readLine(SocketChannel ch) throws IOException {
+ ByteBuffer readBuf = ByteBuffer.allocate(64 * 1024);
+ StringBuilder sb = new StringBuilder();
+ while (true) {
+ int nl = indexOfNewline(sb);
+ if (nl >= 0) {
+ return sb.substring(0, nl);
+ }
+ readBuf.clear();
+ int n = ch.read(readBuf);
+ if (n == -1) {
+ if (sb.length() > 0) {
+ return sb.toString(); // herdr closed after a complete, unterminated frame
+ }
+ throw new IOException("herdr closed the connection with no response");
+ }
+ readBuf.flip();
+ sb.append(StandardCharsets.UTF_8.decode(readBuf));
+ }
+ }
+
+ private static int indexOfNewline(CharSequence s) {
+ for (int i = 0; i < s.length(); i++) {
+ if (s.charAt(i) == '\n') {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ /** No persistent resources to release; present for the {@link AutoCloseable} contract. */
+ @Override
+ public void close() {
+ }
+}
diff --git a/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java b/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java
new file mode 100644
index 0000000..c5dac7c
--- /dev/null
+++ b/bridged/src/main/java/dev/ltms/bridged/rest/BridgedApp.java
@@ -0,0 +1,73 @@
+package dev.ltms.bridged.rest;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import dev.ltms.bridged.herdr.HerdrClient;
+import dev.ltms.bridged.herdr.HerdrException;
+import io.javalin.Javalin;
+import io.javalin.http.Context;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The REST surface — {@code bridged}'s contract, and its testability seam. Every
+ * feature is reachable here without Claude or MCP in the loop, so each is an
+ * acceptance test against plain HTTP. MCP tools (later) are thin adapters over these
+ * same endpoints and are validated by parity, not by re-implementing behaviour.
+ *
+ *
Built from an injected {@link HerdrClient} so tests can supply a fake and run on
+ * an ephemeral port; {@code main} supplies the real Unix-socket client.
+ */
+public final class BridgedApp {
+
+ private final HerdrClient herdr;
+
+ public BridgedApp(HerdrClient herdr) {
+ this.herdr = herdr;
+ }
+
+ /** Wire routes onto a fresh, unstarted Javalin instance. Caller starts it. */
+ public Javalin build() {
+ Javalin app = Javalin.create(cfg -> cfg.showJavalinBanner = false);
+ app.get("/healthz", this::healthz);
+ app.get("/sessions", this::sessions);
+ return app;
+ }
+
+ /** Liveness + herdr reachability. 200 when herdr answers ping, 503 otherwise. */
+ private void healthz(Context ctx) {
+ try {
+ JsonNode pong = herdr.call("ping");
+ ctx.status(200).json(Map.of(
+ "status", "ok",
+ "herdr", Map.of(
+ "version", pong.path("version").asText(""),
+ "protocol", pong.path("protocol").asInt())));
+ } catch (HerdrException e) {
+ ctx.status(503).json(Map.of(
+ "status", "degraded",
+ "herdr", "unreachable",
+ "detail", e.getMessage()));
+ }
+ }
+
+ /**
+ * Sessions view, derived from herdr {@code workspace.list}. Stage-1 maps one
+ * workspace → one session summary; later tickets enrich this with the primary/
+ * worker role and the subscription-guard verdict per pane.
+ */
+ private void sessions(Context ctx) {
+ JsonNode result = herdr.call("workspace.list");
+ List