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
9 changes: 9 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ packages/adapters/*/*
!packages/mcp/
!packages/mcp/src/
!packages/mcp/src/**
!packages/sdk/
!packages/sdk/src/
!packages/sdk/src/**
!packages/portal/
!packages/portal/src/
!packages/portal/src/**
!packages/server/
!packages/server/src/
!packages/server/src/**
!packages/ci/
!packages/ci/dist/
!packages/ci/dist/**
Expand Down
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,8 @@ jobs:
run: docker build -f Containerfile --target cli -t adrkit-cli:ci .
- name: Build the dedicated MCP target
run: docker build -f Containerfile --target mcp -t adrkit-mcp:ci .
- name: Build the dedicated read-only server target
run: docker build -f Containerfile --target server -t adrkit-server:ci .
- name: Build the dedicated governing-decisions Action target
run: docker build -f Containerfile --target ci -t adrkit-action:ci .
- name: Build the dedicated queue Action target
Expand All @@ -255,6 +257,8 @@ jobs:
'test -f /opt/adrkit/adr.js && test ! -e /opt/adrkit/adrkit-mcp.js && test ! -e /opt/adrkit/ci.js'
docker run --rm --entrypoint sh adrkit-mcp:ci -c \
'test -f /opt/adrkit/adrkit-mcp.js && test ! -e /opt/adrkit/adr.js && test ! -e /opt/adrkit/ci.js'
docker run --rm --entrypoint sh adrkit-server:ci -c \
'test -f /opt/adrkit/server.js && test ! -e /opt/adrkit/adr.js && test ! -e /opt/adrkit/adrkit-mcp.js'
docker run --rm --entrypoint sh adrkit-action:ci -c \
'test -f /opt/adrkit/ci.js && test ! -e /opt/adrkit/adr.js && test ! -e /opt/adrkit/queue-action.js'
docker run --rm --entrypoint sh adrkit-queue-action:ci -c \
Expand All @@ -274,6 +278,35 @@ jobs:
adrkit:ci lint --dir docs/adr
node scripts/smoke-container.mjs adrkit-mcp:ci
node scripts/smoke-container.mjs adrkit:ci mcp
# ADR-0055 clauses 12 to 14: the server keeps every part of the MCP posture but
# `--network none` (it listens): a read-only root file system and a `:ro` corpus
# mount. It binds 127.0.0.1 unless told otherwise, so the smoke sets the host and
# publishes the port on the runner's loopback only.
- name: Smoke test the read-only server through the all-in-one image
run: |
set -euo pipefail
corpora="$RUNNER_TEMP/corpora"
mkdir -p "$corpora/adrkit/docs"
cp -R docs/adr "$corpora/adrkit/docs/adr"
commit="$(git rev-parse HEAD)"
printf '{"commit":"%s"}\n' "$commit" > "$corpora/adrkit/.adrkit-corpus.json"
docker run -d --name adrkit-server --read-only -p 127.0.0.1:18080:8080 \
-v "$corpora:/corpora:ro" \
-e ADRKIT_SERVER_ROOT=/corpora -e ADRKIT_SERVER_CORPORA=adrkit \
-e ADRKIT_SERVER_HOST=0.0.0.0 \
adrkit:ci server
trap 'docker logs adrkit-server; docker rm -f adrkit-server >/dev/null' EXIT
for _ in $(seq 1 30); do
curl -fsS http://127.0.0.1:18080/healthz && break
sleep 1
done
base=http://127.0.0.1:18080
curl -fsS "$base/api/v1/corpora" \
| jq -e --arg commit "$commit" '.data == [{corpus: "adrkit", commit: $commit}]'
curl -fsS "$base/api/v1/corpora/adrkit/records" | jq -e --arg commit "$commit" '.commit == $commit and (.data | length) > 0'
test "$(curl -s -o /dev/null -w '%{http_code}' -X POST "$base/api/v1/corpora/adrkit/records")" = 405
curl -fsS -D - -o /dev/null "$base/portal/adrkit/" | grep -i "^content-security-policy:.*frame-ancestors 'none'"
docker logs adrkit-server 2>&1 | grep -F 'This server authenticates no one'
- name: Exercise the governing-decisions Action event boundary
run: |
set -euo pipefail
Expand Down
37 changes: 35 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1168,8 +1168,9 @@ GitHub release.

Things that are load-bearing:

- **Only the all-in-one `adrkit` target is published.** The `cli`, `mcp`, `ci`,
and `queue-action` targets exist for local isolation, SBOM, and policy checks;
- **Only the all-in-one `adrkit` target is published.** The `cli`, `mcp`,
`server`, `ci`, and `queue-action` targets exist for local isolation, SBOM,
and policy checks;
each final stage contains only its own executable. Publishing five packages
would multiply visibility, retention, and rollback operations without adding
behavior.
Expand All @@ -1192,6 +1193,38 @@ This surface is at **rung 1** of ADR-0014: contract tests, local Docker/Podman
build and runtime smoke, and CI construction. No reference-repository or
external/community validation has been recorded.

## The read-only REST server (`packages/server`)

`@adrkit/server` (private) is the image's `server` selector, under
[ADR-0055](./docs/adr/0055-serve-a-read-only-rest-api-over-the-sdk-in-the-oci-image-and-reach-mcp-clients-th.md)
(**accepted**). **It authenticates no one.** `node:http` over `@adrkit/sdk`
and `@adrkit/portal`'s pure builder, and nothing else (`check-deps` admits
exactly those two). **Rung 1**: unit, contract, and side-effect-denial tests
plus a local Podman smoke; the APIM path is unmeasured. Load-bearing:

- **The route table is the server.** `src/routes.ts` is data; the dispatcher
matches it and `/openapi.json` is generated from it. Every row is `GET`;
any other method is 405 before routing and no body is read. `operationId`s
are APIM tool names, so renaming one breaks remote agents.
- **The commit comes from the sidecar, never from git.** `.adrkit-corpus.json`
is the ADR-0055/0056 contract. Missing or invalid with the directory present
is 503; a directory briefly absent (the two-move swap) or a failed or moved
load keeps the last handle. A load is kept only if the sidecar names the
same commit after it.
- **`governing` passes `readMarkers: false`, always.** Removing it turns a
query parameter into a file read; a test plants a marker file to catch it.
- **Errors come from `messages.ts` only.** The catch-all answers `internal`
with fixed text; a sentinel test covers thrown errors, sidecars, and
directory names. No access log is written, because a request line carries
caller-supplied paths.
- **The portal is per corpus at `/portal/{corpus}/`.** `/` redirects
relatively when one corpus is configured and lists them otherwise; links stay
relative so a gateway base path survives. Portal files carry the page's CSP
plus `frame-ancestors 'none'`.
- **Configuration is `ADRKIT_SERVER_CORPORA`** (`key[=dir],...` or JSON),
confined under `ADRKIT_SERVER_ROOT` by `realpath`, at start and before every
load. As PID 1 the binary handles SIGTERM itself.

## Derived surfaces stay in lockstep three different ways

[ADR-0040](./docs/adr/0040-keep-derived-surfaces-in-lockstep-with-three-mechanisms-matched-to-three-classes.md)
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,23 @@ Until `1.0.0`, minor releases may include breaking changes
listed the id), and a log-qualified `supersededBy` names clause 4 in its
existing failure.

- **A read-only REST server in the OCI image, `adrkit-container server`.**
This server authenticates no one: run it on localhost or behind a gateway
that authenticates every request. It serves `GET` and `HEAD` only, under
`/api/v1/corpora/{corpus}`: `records`, `records/{id}`, `graph?kinds=`,
`queue?asOf=`, `issues`, and `governing?path=`, plus `/api/v1/corpora`,
`/openapi.json`, `/healthz`, and the decision portal at `/`
([ADR-0055](docs/adr/0055-serve-a-read-only-rest-api-over-the-sdk-in-the-oci-image-and-reach-mcp-clients-th.md)).
Every answer is `{ corpus, commit, data }`, with the commit read from the
`.adrkit-corpus.json` file the populator writes next to the records; a corpus
without one is not served. Configure it with `ADRKIT_SERVER_CORPORA`
(`key[=dir],...` or a JSON object), `ADRKIT_SERVER_ROOT`,
`ADRKIT_SERVER_HOST` (default `127.0.0.1`), and `ADRKIT_SERVER_PORT`
(default `8080`); see [Container usage](README.md#the-read-only-rest-server).
It runs no `git`, spawns no process, and opens no outbound connection.
`governing` matches `affects` only: it never reads the file at the path, so
it has no `@adr` marker evidence.

## [0.19.0] - 2026-10-10

This release ships the decision portal: a third, build-only nested Action,
Expand Down
18 changes: 16 additions & 2 deletions Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,17 @@ COPY packages/ci/package.json ./packages/ci/package.json
COPY packages/catalog-envelope/package.json ./packages/catalog-envelope/package.json
COPY packages/sdk/package.json ./packages/sdk/package.json
COPY packages/portal/package.json ./packages/portal/package.json
COPY packages/server/package.json ./packages/server/package.json
COPY packages/adapters/agent-plugin/package.json ./packages/adapters/agent-plugin/package.json
COPY packages/adapters/catalog-backstage/package.json ./packages/adapters/catalog-backstage/package.json
COPY packages/adapters/spec-kit/package.json ./packages/adapters/spec-kit/package.json
COPY packages/core/src ./packages/core/src
COPY packages/evaluator/src ./packages/evaluator/src
COPY packages/cli/src ./packages/cli/src
COPY packages/mcp/src ./packages/mcp/src
COPY packages/sdk/src ./packages/sdk/src
COPY packages/portal/src ./packages/portal/src
COPY packages/server/src ./packages/server/src
COPY packages/ci/dist ./packages/ci/dist

RUN bun install --frozen-lockfile --production --ignore-scripts
Expand All @@ -35,11 +39,15 @@ RUN mkdir -p /out \
&& bun build packages/mcp/src/bin.ts \
--target=node \
--conditions bun \
--outfile=/out/adrkit-mcp.js
--outfile=/out/adrkit-mcp.js \
&& bun build packages/server/src/bin.ts \
--target=node \
--conditions bun \
--outfile=/out/server.js

FROM node:${NODE_VERSION}@${NODE_DIGEST} AS runtime
LABEL org.opencontainers.image.title="adrkit" \
org.opencontainers.image.description="Containerized adrkit CLI, MCP server, and CI entry points" \
org.opencontainers.image.description="Containerized adrkit CLI, MCP server, read-only REST server, and CI entry points" \
org.opencontainers.image.source="https://github.com/mbeacom/adrkit" \
org.opencontainers.image.licenses="Apache-2.0"

Expand All @@ -58,6 +66,11 @@ FROM runtime AS mcp
COPY --from=build --chown=node:node /out/adrkit-mcp.js /opt/adrkit/adrkit-mcp.js
ENTRYPOINT ["node", "/opt/adrkit/adrkit-mcp.js"]

FROM runtime AS server
COPY --from=build --chown=node:node /out/server.js /opt/adrkit/server.js
EXPOSE 8080
ENTRYPOINT ["node", "/opt/adrkit/server.js"]

FROM runtime AS ci
COPY --from=build --chown=node:node /opt/adrkit/packages/ci/dist/index.js /opt/adrkit/ci.js
ENTRYPOINT ["node", "/opt/adrkit/ci.js"]
Expand All @@ -69,6 +82,7 @@ ENTRYPOINT ["node", "/opt/adrkit/queue-action.js"]
FROM runtime AS adrkit
COPY --from=build --chown=node:node /out/adr.js /opt/adrkit/adr.js
COPY --from=build --chown=node:node /out/adrkit-mcp.js /opt/adrkit/adrkit-mcp.js
COPY --from=build --chown=node:node /out/server.js /opt/adrkit/server.js
COPY --from=build --chown=node:node /opt/adrkit/packages/ci/dist/index.js /opt/adrkit/ci.js
COPY --from=build --chown=node:node /opt/adrkit/packages/ci/dist/queue-action.js /opt/adrkit/queue-action.js
COPY --chmod=0755 scripts/container-entrypoint.sh /usr/local/bin/adrkit-container
Expand Down
57 changes: 54 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,15 +125,16 @@ bind-mount label (for example, `:Z`).

The default image treats an unrecognized selector as an `adr` subcommand.
Explicit selectors are `cli`/`adr`/`adrkit`, `mcp`/`adrkit-mcp`,
`ci`/`adrkit-ci`, and `queue-action`/`adrkit-queue-action`. The default
`server`/`adrkit-server`, `ci`/`adrkit-ci`, and
`queue-action`/`adrkit-queue-action`. The default
`--help` describes these selectors; `cli --help` opens the CLI command
reference. The container also reserves `-h`, `container-help`, and
`--container-help`; CLI help subcommands such as `help lint` otherwise pass
through unchanged.

Build the same source locally with Docker or Podman. Purpose-specific `cli`,
`mcp`, `ci`, and `queue-action` targets are isolated for local policy and SBOM
inspection; the registry publishes only the all-in-one `adrkit` target:
`mcp`, `server`, `ci`, and `queue-action` targets are isolated for local policy
and SBOM inspection; the registry publishes only the all-in-one `adrkit` target:

```sh
docker build -f Containerfile -t adrkit:local .
Expand All @@ -157,6 +158,56 @@ GitHub Marketplace listing. That root form is available beginning with
GitHub lists only root Action metadata, so the queue Action stays at its nested
path.

### The read-only REST server

**This server authenticates no one.** Run it on localhost, or behind a gateway
that authenticates every request. Anyone who can reach its port can read every
corpus it serves, private repositories included.

The `server` selector serves a read-only REST API and the decision portal over
one or more corpora
([ADR-0055](docs/adr/0055-serve-a-read-only-rest-api-over-the-sdk-in-the-oci-image-and-reach-mcp-clients-th.md)).
It answers `GET` and `HEAD` only, and every other method gets `405`. Every data
response is `{ corpus, commit, data }`. It runs no `git`, spawns no process, and
opens no outbound connection. It binds `127.0.0.1` unless `ADRKIT_SERVER_HOST`
says otherwise; inside a container that address cannot be reached from outside,
so publishing a port also takes `ADRKIT_SERVER_HOST=0.0.0.0`, and the server
then logs a warning. Routes, under `/api/v1/corpora/{corpus}`: `records`,
`records/{id}`, `graph?kinds=`, `queue?asOf=`, `issues`, and `governing?path=`.
`governing` matches a path against `affects` only and never reads the file, so
it has no `@adr` marker evidence. Also served: `/api/v1/corpora`,
`/openapi.json`, `/healthz`, and the portal at `/` (one corpus) or
`/portal/{corpus}/`.

Each corpus is a directory something else writes: the repository's records under
`docs/adr` (or another `adrDir`) and, at its top, `.adrkit-corpus.json`, a JSON
object holding at least `{ "commit": "<40 hex>" }`. A corpus without a valid
sidecar is not served (`503`). When the sidecar's commit changes, the server
loads the new tree and swaps to it. Configure the corpora with environment
variables:

| Variable | Default | Meaning |
| --- | --- | --- |
| `ADRKIT_SERVER_CORPORA` | (required) | `key[=dir],...`, or a JSON object such as `{"adrkit":"adrkit","platform":{"dir":"repos/platform","adrDir":"decisions"}}`. A bare `key` means `<root>/<key>`. Keys match `^[a-z0-9][a-z0-9-]{0,63}$` and are the `{corpus}` path segment. |
| `ADRKIT_SERVER_ROOT` | working directory | Every corpus directory must resolve inside it, symbolic links included. |
| `ADRKIT_SERVER_HOST` | `127.0.0.1` | Address to bind. |
| `ADRKIT_SERVER_PORT` | `8080` | Port to bind. |

To serve a checkout of your own repository on your machine only, write the
sidecar, then mount the checkout read-only:

```sh
printf '{"commit":"%s"}\n' "$(git rev-parse HEAD)" > .adrkit-corpus.json
docker run --rm --read-only -p 127.0.0.1:8080:8080 \
-v "$PWD:/workspace:ro" \
-e ADRKIT_SERVER_HOST=0.0.0.0 -e ADRKIT_SERVER_CORPORA=adrkit=. \
ghcr.io/mbeacom/adrkit:vX.Y.Z server
```

The `-p 127.0.0.1:` prefix keeps the published port on the host's loopback.
This server has no `--network none` mode, because it listens for requests, but
it never needs a writable mount.

## What it looks like

`adr queue` emits the review backlog as a deterministic, read-only projection of
Expand Down
13 changes: 13 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions packages/cli/test/node-compatibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ const SHIPPED_ROOTS = [
// ADR-0016 clause 2: observed failing with `export const planted = () => Bun.version;`
// appended to packages/portal/src/data/build.ts; the retained input is that line, removed.
resolve(process.cwd(), 'packages/portal/src'),
// `@adrkit/server` is private, but the image runs it on Node 24 as /opt/adrkit/server.js
// (ADR-0055 clauses 10 and 12).
// ADR-0016 clause 2: observed failing with `export const planted = () => Bun.version;`
// appended to packages/server/src/validate.ts; the retained input is that line, removed.
resolve(process.cwd(), 'packages/server/src'),
];

/** Strip comments so the rule is about code, not about prose describing the rule. */
Expand Down
34 changes: 34 additions & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"name": "@adrkit/server",
"version": "0.0.0",
"private": true,
"description": "A read-only REST API over @adrkit/sdk, with the decision portal, shipped as the `server` selector of the adrkit OCI image (ADR-0055). Private; bundled into the image, never published.",
"type": "module",
"license": "Apache-2.0",
"homepage": "https://adrkit.dev",
"repository": {
"type": "git",
"url": "git+https://github.com/mbeacom/adrkit.git",
"directory": "packages/server"
},
"bugs": {
"url": "https://github.com/mbeacom/adrkit/issues"
},
"//boundary": "ADR-0055 clause 10: node:http and no framework. The only runtime dependencies are @adrkit/sdk (the consumer contract, ADR-0031) and the private @adrkit/portal (its pure builder and page assets only). It never imports @adrkit/core or @adrkit/cli, spawns no process, runs no git, and opens no outbound connection; scripts/check-deps.ts admits exactly those two. Neither declares an exports map, so the import specifiers are the subpaths `@adrkit/sdk/src/index.ts`, `@adrkit/portal/src/data/build.ts`, and `@adrkit/portal/src/page/assets.ts`.",
"//release": "Private and unpublished. It reaches users only as /opt/adrkit/server.js in the all-in-one OCI image (ADR-0055 clause 12), which rides the lockstep release (ADR-0032).",
"engines": {
"node": ">=22"
},
"scripts": {
"build": "bun run typecheck",
"lint": "bun run typecheck",
"typecheck": "tsc --noEmit --customConditions bun --project ../../tsconfig.json"
},
"dependencies": {
"@adrkit/portal": "workspace:*",
"@adrkit/sdk": "workspace:*"
},
"devDependencies": {
"@types/bun": "latest"
}
}
Loading
Loading