Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions .github/workflows/definition-of-done.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
name: Definition of Done

# Enforces CLAUDE.md HARD RULE #0: a user-facing feature is not done until
# demonstrated through its actual runtime. See axonflow-claude-plugin#59
# for the doctrine + lint-no-mocks rationale.

on:
pull_request:
types: [opened, synchronize, reopened, edited]

permissions:
contents: read
pull-requests: read

jobs:
lint-no-mocks-in-runtime-e2e:
name: Lint — no mocks in runtime-e2e/
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run lint
run: |
if [ -x scripts/lint-no-mocks-in-runtime-e2e.sh ]; then
./scripts/lint-no-mocks-in-runtime-e2e.sh
else
echo "lint-no-mocks-in-runtime-e2e.sh not present — skipping (older branch)."
fi

runtime-e2e-required:
name: Runtime E2E required for user-facing changes
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Detect user-facing surface changes
id: detect
env:
BASE: ${{ github.event.pull_request.base.sha }}
HEAD: ${{ github.event.pull_request.head.sha }}
run: |
set -uo pipefail
# Java SDK user-facing surface: the published library code under
# src/main/java/, the Maven manifest pom.xml that downstream
# consumers depend on, and runnable example sources under examples/.
USER_FACING_GLOBS=(
'src/main/java/'
'pom.xml'
'examples/'
)
CHANGED=$(git diff --name-only "$BASE" "$HEAD" || true)
echo "Changed files in PR:" >&2
printf ' %s\n' $CHANGED >&2

MATCHED=""
for f in $CHANGED; do
for pat in "${USER_FACING_GLOBS[@]}"; do
case "$f" in
"$pat"*|*"/$pat"*)
MATCHED="$MATCHED $f"
break
;;
esac
done
done

RUNTIME_E2E_TOUCHED=$(echo "$CHANGED" | grep -c '^runtime-e2e/' || true)

{
echo "user_facing_changed=$([ -n "$MATCHED" ] && echo true || echo false)"
echo "runtime_e2e_touched=$RUNTIME_E2E_TOUCHED"
} >> "$GITHUB_OUTPUT"

if [ -n "$MATCHED" ]; then
echo "User-facing files changed:" >&2
for f in $MATCHED; do echo " - $f" >&2; done
fi

- name: Check escape-hatch justification
id: hatch
if: steps.detect.outputs.user_facing_changed == 'true' && steps.detect.outputs.runtime_e2e_touched == '0'
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
run: |
set -uo pipefail
if [[ "$PR_TITLE" == *"[skip-runtime-e2e]"* ]]; then
if echo "$PR_BODY" | grep -q '## Skip-runtime-e2e justification'; then
echo "Escape hatch active." >&2
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "::error::PR title carries [skip-runtime-e2e] but body has no '## Skip-runtime-e2e justification' section."
exit 1
fi
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi

- name: Enforce runtime-e2e/ presence
if: steps.detect.outputs.user_facing_changed == 'true' && steps.detect.outputs.runtime_e2e_touched == '0' && steps.hatch.outputs.skip != 'true'
run: |
cat <<'EOF' >&2
::error::This PR touches real SDK code (src/main/java/, pom.xml, examples/) without runtime-e2e/ test
but does not add or update any runtime-e2e/ test in the same PR.

Per CLAUDE.md HARD RULE #0:
A user-facing feature is not done until you have demonstrated it
working through its actual runtime — real SDK class loaded by a
real JVM, real HTTP request against a real running AxonFlow agent.

Mocks, stubs, WireMock, MockWebServer, capture-stub harnesses do
NOT count as runtime proof.

To resolve, do ONE of:
1. Add a test under runtime-e2e/<feature>/ that invokes the
feature through the actual user-facing surface (real
AxonFlow client class wired to a real running agent).
2. If genuinely internal (build / deps / lint baseline / docs),
add `[skip-runtime-e2e]` to PR title AND a
`## Skip-runtime-e2e justification` section to PR body.

See: axonflow-internal-docs/engineering/E2E_EXAMPLES_TESTING_WORKFLOW.md
EOF
exit 1

- name: All clear
if: steps.detect.outputs.user_facing_changed == 'false' || steps.detect.outputs.runtime_e2e_touched != '0' || steps.hatch.outputs.skip == 'true'
run: |
if [ "${{ steps.detect.outputs.user_facing_changed }}" = 'false' ]; then
echo "No user-facing surface changed. Gate not applicable." >&2
elif [ "${{ steps.detect.outputs.runtime_e2e_touched }}" != '0' ]; then
echo "User-facing change detected and runtime-e2e/ updated in same PR. ✓" >&2
else
echo "Escape hatch active with valid justification. ✓" >&2
fi
99 changes: 99 additions & 0 deletions runtime-e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Runtime End-to-End Tests — Java SDK

Tests in this directory MUST exercise the published `axonflow-sdk` Java
library through its real user-facing surface — a real JVM loading the
built `axonflow-sdk-<version>.jar`, a real `AxonFlow` client constructed
through the public builder, and a real HTTP request to a real running
AxonFlow agent. Calling internal classes, package-private helpers, or
WireMock/MockWebServer fixtures is not a runtime test — those belong
under `tests/` (unit + integration).

If the Java SDK can't reach a feature through its public API, the feature
isn't ready to ship.

## Why this directory exists

A May 3, 2026 audit found multiple AxonFlow capabilities (audit search,
decision explain, override CRUD) where the platform endpoint and SDK
method existed for months but no host integration ever wired them up.
Users running with the AxonFlow Java SDK could not reach the capability.
The fix: every user-facing AxonFlow feature exposed via this SDK must
have a test in this directory that invokes through the SDK's real public
API hitting a real running agent.

The single rule:

> **If a user cannot reach the feature from their runtime, we did not
> ship a feature, we shipped a library.**

## What "runtime" means here

The runtime is a real JVM with the built SDK JAR on the classpath, where
the test:

- Constructs `AxonFlow.create(AxonFlowConfig.builder()...)` exactly as a
consumer would.
- Issues real HTTP requests through the SDK to a real AxonFlow agent
reachable over the network.
- Asserts on the wire-level behaviour observable to that consumer
(response body, exception message, agent-side audit) — not on internal
fields of mock objects.

If a test imports `com.getaxonflow.sdk.internal.*` or pulls in any
HTTP-stubbing fixture library or `mockito-*`, it is a unit/integration
test. That belongs under `tests/`, not here.

## Layout

```
runtime-e2e/
README.md # this file
<feature-name>/ # one folder per feature
<Feature>Test.java # `java` script, run via classpath
README.md # 5 lines: prereqs, what it asserts, how to run
```

## Running

Each test folder has its own README. Most tests assume:

- An AxonFlow community-saas-style stack is reachable (default
`http://localhost:8080`, override with `AXONFLOW_AGENT_URL`).
- The SDK is built locally: `mvn -DskipTests package` produces
`target/axonflow-sdk-<version>.jar`.
- A working JDK 17+ on `$PATH` (use `java <File>.java` single-file mode
with `-cp` pointing at the SDK JAR + Maven runtime classpath).

Typical invocation:

```bash
mvn -DskipTests dependency:build-classpath \
-Dmdep.outputFile=/tmp/cp.txt -q
SDK_JAR=$(ls target/axonflow-sdk-*.jar | head -1)
CP="$SDK_JAR:$(cat /tmp/cp.txt)"

AXONFLOW_AGENT_URL=http://localhost:8080 \
AXONFLOW_TENANT_ID=cs_... \
AXONFLOW_TENANT_SECRET=... \
AXONFLOW_E2E_PLUGIN_TOKEN=AXON-... \
java -cp "$CP" runtime-e2e/x-axonflow-client/SdkClientHeaderTest.java
```

Note: like the Go SDK, the Java SDK does not currently expose a public
hook for injecting `X-License-Token` per-request. Tests that need to
prove a particular header reaches the agent should chain through a small
local logging proxy that adds the token before forwarding to the real
agent. See `x-axonflow-client/README.md` for the proxy snippet.

## Adding a test

1. Confirm you can invoke the feature through the real published
`AxonFlow` client. If you can't, the answer is to fix the SDK's
public surface, not to write a `tests/`-style integration test that
imports internals.
2. Create the folder, write `<Feature>Test.java` and `README.md`.
3. Update
`axonflow-internal-docs/engineering/FEATURE_RUNTIME_COVERAGE.md`
(private; engineering team only) to mark the new green cell under
the Java SDK column.
4. Reference the test in the PR that wires the feature.
76 changes: 76 additions & 0 deletions runtime-e2e/x-axonflow-client/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# runtime-e2e — `X-Axonflow-Client` wire assertion (Java SDK)

## What this asserts

The published `axonflow-sdk` Java client, when constructed via the
public `AxonFlow.create(AxonFlowConfig.builder()...)` surface and used
to call `mcpCheckInput`, emits the header

```
X-Axonflow-Client: sdk-java/<SDK_VERSION>
```

on every governed request, where `<SDK_VERSION>` is the value of
`com.getaxonflow.sdk.AxonFlowConfig.SDK_VERSION` baked into the JAR.

The agent is configured to reject requests whose
`X-License-Token` scope does not match that header. The test asserts
the agent's rejection message echoes the header value — proving the
header travelled across the wire and was read by the agent, not just
set on the local request object.

## Prereqs

- Java 17+ on `$PATH` (`java --version`).
- `mvn -DskipTests package` has been run; `target/axonflow-sdk-*.jar`
exists.
- A running AxonFlow agent reachable at `$AXONFLOW_AGENT_URL`
(default `http://localhost:8080`) — typically a local
community-saas stack brought up by `./scripts/setup-e2e-testing.sh
community`.
- Tenant credentials: `AXONFLOW_TENANT_ID` + `AXONFLOW_TENANT_SECRET`.
- A scoped license token: `AXONFLOW_E2E_PLUGIN_TOKEN` (issue via the
community-saas `/license/issue` flow with a scope that is _not_
`sdk-java/*`, so the agent rejects with the expected error).

## How to run

```bash
cd <axonflow-sdk-java repo>

# Build SDK + materialise runtime classpath
mvn -DskipTests package
mvn -DskipTests dependency:build-classpath \
-Dmdep.outputFile=/tmp/cp.txt -q
SDK_JAR=$(ls target/axonflow-sdk-*.jar | head -1)
CP="$SDK_JAR:$(cat /tmp/cp.txt)"

# (1) Bring up local logging proxy on :18080 that injects
# `X-License-Token: $AXONFLOW_E2E_PLUGIN_TOKEN` and forwards to
# $REAL_AGENT (e.g. http://localhost:8080). The simplest form is a
# ~25-line Python http.server with do_POST forwarding via urllib;
# a worked example is checked in at
# /tmp/axonflow-e2e/proxy.py from the May 4 2026 wire-shape session.
#
# (2) Point the SDK at the proxy:
AXONFLOW_AGENT_URL=http://localhost:18080 \
AXONFLOW_TENANT_ID=cs_... \
AXONFLOW_TENANT_SECRET=... \
AXONFLOW_E2E_PLUGIN_TOKEN=AXON-... \
java -cp "$CP" runtime-e2e/x-axonflow-client/SdkClientHeaderTest.java
```

PASS exits 0 with `PASS: agent reflected sdk-java/<v> ...`. Any other
result fails with a clear message.

## Why through a proxy?

`sdk-java` does not (today) expose a public `requestInterceptor` /
header-builder hook for callers, so the
`X-License-Token` header — which is the agent's input for the
scope-match check — must be added by an out-of-process agent. A small
local proxy is the correct shape: it changes nothing about how the SDK
constructs the request, only adds one header on the way out, mirroring
how a sidecar would behave in production. Adding a public
`requestInterceptor` to the SDK would let this test skip the proxy; that
work is tracked separately.
66 changes: 66 additions & 0 deletions runtime-e2e/x-axonflow-client/SdkClientHeaderTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* runtime-e2e/x-axonflow-client/SdkClientHeaderTest.java
*
* Per CLAUDE.md HARD RULE #0: real wire test of the SDK's
* getClientHeader() + addAuthHeaders() emitting
* X-Axonflow-Client: sdk-java/<SDK_VERSION>
* to a real running AxonFlow agent.
*
* Run:
* mvn -DskipTests dependency:build-classpath \
* -Dmdep.outputFile=/tmp/cp.txt -q
* SDK_JAR=$(ls target/axonflow-sdk-*.jar | head -1)
* CP="$SDK_JAR:$(cat /tmp/cp.txt)"
* AXONFLOW_AGENT_URL=http://localhost:8080 \
* AXONFLOW_TENANT_ID=cs_... AXONFLOW_TENANT_SECRET=... \
* AXONFLOW_E2E_PLUGIN_TOKEN=AXON-... \
* java -cp "$CP" \
* runtime-e2e/x-axonflow-client/SdkClientHeaderTest.java
*/
import com.getaxonflow.sdk.AxonFlow;
import com.getaxonflow.sdk.AxonFlowConfig;
import com.getaxonflow.sdk.types.MCPCheckInputResponse;

public class SdkClientHeaderTest {
public static void main(String[] args) {
String endpoint = System.getenv().getOrDefault("AXONFLOW_AGENT_URL", "http://localhost:8080");
String tenant = System.getenv("AXONFLOW_TENANT_ID");
String secret = System.getenv("AXONFLOW_TENANT_SECRET");
String token = System.getenv("AXONFLOW_E2E_PLUGIN_TOKEN");
if (tenant == null || secret == null || token == null) {
System.err.println("AXONFLOW_TENANT_ID + AXONFLOW_TENANT_SECRET + AXONFLOW_E2E_PLUGIN_TOKEN must be set; see ../README.md");
System.exit(2);
}

String expected = "sdk-java/" + AxonFlowConfig.SDK_VERSION;
System.out.println("Asserting wire X-Axonflow-Client = " + expected);

AxonFlow client = AxonFlow.create(
AxonFlowConfig.builder()
.agentUrl(endpoint)
.clientId(tenant)
.clientSecret(secret)
.build()
);
// NOTE: like sdk-go, sdk-java does not currently expose a public way
// to inject X-License-Token into requests. The driver script that
// runs this test should chain through a small local logging proxy
// that injects the token before forwarding to the agent. See
// ../README.md "How to run" for the proxy snippet. The assertion
// below relies on the proxy chain producing a scope_mismatch
// response that echoes the client header.
try {
MCPCheckInputResponse r = client.mcpCheckInput("postgres", "SELECT 1");
System.err.println("UNEXPECTED 200: allowed=" + r.isAllowed());
System.exit(1);
} catch (Exception e) {
String msg = e.getMessage() == null ? e.toString() : e.getMessage();
if (msg.contains("client \"" + expected + "\"")) {
System.out.println("PASS: agent reflected " + expected + " in scope_mismatch response");
System.exit(0);
}
System.err.println("FAIL: error did not echo expected client header; got: " + msg);
System.exit(1);
}
}
}
Loading
Loading