Skip to content

feat(server): serve a read-only REST API over the SDK in the OCI image (ADR-0055, #296) - #314

Merged
mbeacom merged 2 commits into
mainfrom
feat/rest-server
Oct 10, 2026
Merged

mbeacom merged 2 commits into
mainfrom
feat/rest-server

Conversation

@mbeacom

@mbeacom mbeacom commented Oct 10, 2026

Copy link
Copy Markdown
Owner

Closes #296.

What and why

This builds the read-only REST server the accepted ADR-0055 decides. mbeacom/adrkit-azure deploys it behind APIM (ADR-0056). @adrkit/server is private and built only on @adrkit/sdk and @adrkit/portal's pure builder (clause 10). It runs from a new server selector in the all-in-one image.

  • Routes. GET and HEAD only; anything else gets 405 with Allow, and request bodies are never read.
    • Under /api/v1/corpora/{corpus}: records, records/{id} (with body), graph?kinds=, queue?asOf=, issues, governing?path=.
    • governing doesn't read markers. On a corpus with errors it returns resolution: withheld with the issues.
    • Also /api/v1/corpora, /openapi.json (OpenAPI 3.0.3 generated from the route table, with stable operationIds), and /healthz.
  • Commit pinning. Every response is { corpus, commit, data }, and the commit comes from .adrkit-corpus.json, the sidecar ADR-0055 and ADR-0056 define.
    • A missing or invalid sidecar returns 503.
    • A new commit opens a fresh handle.
    • A tree that's briefly absent during ADR-0056's two-move swap keeps serving the last handle.
  • Portal. Each corpus gets one at /portal/{corpus}/, with a CSP header that includes frame-ancestors 'none'. With one corpus, / is a relative redirect to it.
  • Posture.
    • It binds 127.0.0.1 by default. ADRKIT_SERVER_HOST overrides that with a fixed warning that the server authenticates no one.
    • It runs no git, spawns nothing, and makes no outbound connections. A side-effect-denial test allows exactly one listener, under Bun on the source and under Node on the bundle.
    • Error text is fixed.
    • There's no access log, because request lines carry caller-supplied paths.

Config (answers adrkit-azure's TODO(server-config))

ADRKIT_SERVER_ROOT (default: the working directory) and ADRKIT_SERVER_CORPORA, which takes either a key[=dir],... list or JSON ({"key":"dir"} or {"key":{"dir":…,"adrDir":…}}). Rules:

  • A bare key means <root>/<key>.
  • Records live in <dir>/docs/adr unless adrDir says otherwise.
  • The sidecar is <dir>/.adrkit-corpus.json.
  • Every directory is confined inside the root through realpath.

In Terraform that's ADRKIT_SERVER_ROOT=<mount> and ADRKIT_SERVER_CORPORA=join(",", keys(var.repos)).

Checklist

  • DCO signed off.
  • Recorded decision: ADR-0055 (accepted). I believe items 1–5 and 7 are done but haven't ticked them; I'll tick them once this merges and the image smoke test runs in CI.
  • Schema changed: n/a.
  • packages/ci / core: n/a. The CI bundles are unchanged.
  • Observed failing first (ADR-0016), each against a planted break:
    • readMarkers removed;
    • a POST route added;
    • a writing verb in a handler;
    • OpenAPI drift;
    • frame-ancestors dropped;
    • String(error) returned in a 500;
    • the loopback warning check inverted;
    • the swap logic made to never reopen a handle;
    • execSync in a handler (caught under both runtimes);
    • Bun.version in the source;
    • the check-deps entry deleted;
    • the Containerfile COPY line removed and the selector renamed.
  • Full bun test: 4406 pass. typecheck, lint, check:deps, check:stale-refs, check:changelog, check:doc-pins, check:freeze-hashes, and adr lint pass.

Notes for reviewers

  • Gate label needed: this touches .github/workflows/ci.yml and scripts/.
  • Local podman smoke:
    • podman build --target adrkit succeeded, and I ran server read-only with a :ro fixture corpus and sidecar.
    • /healthz, /api/v1/corpora (with the commit), and records (59) all answered.
    • POST got 405.
    • / redirected to the portal, which rendered with no CSP errors and pathTable: absent.
    • Without ADRKIT_SERVER_HOST, the server was unreachable from the host.
  • Follow-ups:
    • Clause 9 wants the server-hosted portal's path view to point at the governing route. That text lives in the shared portal page, and changing it means rebuilding packages/ci/dist.
    • Action item 8: measure APIM. That's in adrkit-azure's harness.
    • adrkit-azure's TODO(openapi) must filter out /healthz and the /portal/... operations.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Root confinement is bypassable through a symlinked ADR directory, and several API and ADR-0055 contract issues remain.

6 open findings
What changed in this PR

Adds the ADR-0055 read-only REST server and portal to the OCI image.

Changes:

  • Implements REST routes, corpus loading, commit sidecars, and portal serving.
  • Adds container wiring, documentation, and dependency boundaries.
  • Adds comprehensive server and security tests.

Decision-context MCP was unavailable; ADR-0055 was checked against repository documentation.

File Description
.dockerignore Includes server build sources.
.github/​workflows/​ci.yml Builds and smokes the server image.
AGENTS.md Documents the server contract.
CHANGELOG.md Announces the REST server.
Containerfile Builds and packages the server.
README.md Documents configuration and deployment.
bun.lock Registers the server workspace.
packages/​cli/​test/​node-compatibility.test.ts Extends Node compatibility checks.
packages/​server/​package.json Defines the private server package.
packages/​server/​src/​bin.ts Implements startup and shutdown.
packages/​server/​src/​config.ts Parses and confines configuration.
packages/​server/​src/​corpus.ts Loads and swaps committed corpora.
packages/​server/​src/​messages.ts Defines fixed public messages.
packages/​server/​src/​openapi.ts Generates OpenAPI metadata.
packages/​server/​src/​routes.ts Defines the route table.
packages/​server/​src/​server.ts Implements HTTP request handling.
packages/​server/​src/​shapes.ts Projects stable response shapes.
packages/​server/​src/​validate.ts Validates request parameters.
packages/​server/​test/​bin.test.ts Tests process behavior.
packages/​server/​test/​config.test.ts Tests configuration parsing.
packages/​server/​test/​error-text.test.ts Tests error-data containment.
packages/​server/​test/​helpers.ts Provides server test fixtures.
packages/​server/​test/​methods.test.ts Enforces read-only methods.
packages/​server/​test/​openapi.test.ts Checks route/OpenAPI agreement.
packages/​server/​test/​portal.test.ts Tests portal delivery.
packages/​server/​test/​routes.test.ts Tests REST behavior.
packages/​server/​test/​side-effect-denial-preload.mjs Traps prohibited side effects.
packages/​server/​test/​side-effect-denial.test.ts Exercises side-effect restrictions.
packages/​server/​test/​sidecar.test.ts Tests sidecar-driven reloads.
packages/​server/​test/​writing-verbs.test.ts Blocks writing capabilities.
scripts/​check-deps.test.ts Tests the dependency boundary.
scripts/​check-deps.ts Adds the server allowlist.
scripts/​container-contract.test.ts Tests container integration.
scripts/​container-entrypoint.sh Adds the server selector.

🧠 Review effort: Balanced

Comment thread packages/server/src/corpus.ts
Comment thread packages/server/src/bin.ts Outdated
Comment thread packages/server/src/server.ts
Comment thread packages/server/src/server.ts
Comment thread packages/server/src/server.ts Outdated
Comment thread packages/server/src/validate.ts Outdated
@github-actions github-actions Bot removed the gate-change-acknowledged A maintainer has seen and accepted this PR's change to the CI gate surface (ADR-0035) label Oct 10, 2026
@github-actions

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Decisions governing this change

14 accepted decisions govern this change · 1 active proposal

Show all 14 governing decisions and why each applies
  • 0003 — Ship as a Spec Kit extension plus a standalone CLI, not a competing harness
    • via path: packages/cli/**
  • 0007 — Isolate integrations as optional adapters and build only against public surfaces
    • via path: .github/workflows/**
    • via path: packages/*/package.json
  • 0010 — Use Bun as the package manager and test runner while publishing Node-targeted artifacts
    • via path: .github/workflows/**
  • 0016 — Require every check to be observed failing before it counts as coverage
    • via path: packages/*/test/**
  • 0017 — Keep dependency audit scope explicit and release-scoped
    • via path: .github/workflows/ci.yml
    • via path: packages/*/package.json
  • 0018 — Adopt MCP SDK v2 and serve protocol revision 2026-07-28 dual-era
    • via path: scripts/check-deps.ts
  • 0025 — Ship badges as recipes over existing output, not a new CLI surface
    • via path: .github/workflows/ci.yml
  • 0030 — Keep extension surfaces that carry a dependency tree outside this repository
    • via path: scripts/check-deps.ts
  • 0032 — Publish one lockstep OCI image after the coordinated release succeeds
    • via path: .dockerignore
    • via path: .github/workflows/ci.yml
    • via path: Containerfile
    • via path: README.md
    • via path: scripts/container-*
  • 0035 — Execute the gates that certify a pull request from the default branch
    • via path: .github/workflows/**
    • via path: scripts/**
  • 0037 — Treat generated knowledge systems as downstream read models, not decision authorities
    • via path: README.md
  • 0040 — Keep derived surfaces in lockstep with three mechanisms matched to three classes of drift
    • via path: .github/workflows/ci.yml
    • via path: AGENTS.md
  • 0055 — Serve a read-only REST API over the SDK in the OCI image, and reach MCP clients through it
    • via path: Containerfile
    • via path: packages/server/**
    • via path: scripts/container-entrypoint.sh
  • 0057 — Preview the decision portal locally through a preview selector in the published image, published to loopback only
    • via path: Containerfile
    • via path: scripts/container-entrypoint.sh
Active proposals touching this change (1)

These are not yet ratified and do not bind this change:

  • 0056 — Ship the Azure deployment surface as Terraform in a separate repository behind APIM Standard v2 with public_access (proposed)
    • via path: Containerfile
    • via path: packages/server/**
    • via path: scripts/container-entrypoint.sh

Links point at 6e37b02, this run's GITHUB_SHA. Run adr explain <path> locally to see why a file is governed.

mbeacom added a commit that referenced this pull request Oct 10, 2026
…ags, RFC 9110 If-None-Match, and core's asOf rule

Review on #314 found five gaps:
- Confinement covered only the corpus directory, so an in-root corpus
  with `docs/adr -> /outside` served outside records. The actual records
  directory is now realpath-checked inside ADRKIT_SERVER_ROOT before
  every open and reopen (fixed corpus-escapes-root 503).
- `[::1]` passed config but Node's listen rejects the brackets; they are
  stripped for the bind and kept only in the printed URL.
- The ETag now hashes the serialized body too, so a server or SDK
  upgrade at the same commit can never answer 304 with stale data.
- If-None-Match follows RFC 9110 for GET/HEAD: `*`, comma lists, and
  weak comparison; it applies only to 200 responses.
- asOf validation round-trips the ISO date as core does, so years
  0000-0099 are accepted exactly as DecisionSet.queue accepts them.

Each new test was observed failing first (ADR-0016).

Signed-off-by: Mark Beacom <m@beacom.dev>
@mbeacom mbeacom added the gate-change-acknowledged A maintainer has seen and accepted this PR's change to the CI gate surface (ADR-0035) label Oct 10, 2026
…e (ADR-0055, #296)

@adrkit/server (private) is a node:http server built on @adrkit/sdk and
@adrkit/portal's pure builder alone, run by a new `server` selector in
the all-in-one image.

- GET/HEAD only (405 with Allow otherwise; request bodies are never
  read), under /api/v1/corpora/{corpus}: records, records/{id},
  graph?kinds=, queue?asOf=, issues, governing?path= (markers not read;
  resolution withheld with the issues on a corpus with errors), plus
  /api/v1/corpora, /openapi.json generated from the route table,
  /healthz, and a portal per corpus at /portal/{corpus}/.
- Every response is { corpus, commit, data }, with the commit from the
  `.adrkit-corpus.json` sidecar (503 without a valid one); a new commit
  opens a fresh handle, and a briefly absent tree (ADR-0056's two-move
  swap) keeps serving the last one. Fixed error text, fixed-text 400s.
- Binds 127.0.0.1 by default; ADRKIT_SERVER_HOST overrides it with a
  fixed "authenticates no one" warning. Corpora come from
  ADRKIT_SERVER_ROOT + ADRKIT_SERVER_CORPORA (a key list or JSON), each
  confined through realpath. No git, no spawn, no outbound network: a
  side-effect-denial test allows exactly one listener, under Bun on the
  source and under Node on the bundle.
- check-deps admits only @adrkit/sdk and @adrkit/portal; a CI step
  smokes the image's `server` selector.

Every check was observed failing against a planted break (ADR-0016).

Signed-off-by: Mark Beacom <m@beacom.dev>
…ags, RFC 9110 If-None-Match, and core's asOf rule

Review on #314 found five gaps:
- Confinement covered only the corpus directory, so an in-root corpus
  with `docs/adr -> /outside` served outside records. The actual records
  directory is now realpath-checked inside ADRKIT_SERVER_ROOT before
  every open and reopen (fixed corpus-escapes-root 503).
- `[::1]` passed config but Node's listen rejects the brackets; they are
  stripped for the bind and kept only in the printed URL.
- The ETag now hashes the serialized body too, so a server or SDK
  upgrade at the same commit can never answer 304 with stale data.
- If-None-Match follows RFC 9110 for GET/HEAD: `*`, comma lists, and
  weak comparison; it applies only to 200 responses.
- asOf validation round-trips the ISO date as core does, so years
  0000-0099 are accepted exactly as DecisionSet.queue accepts them.

Each new test was observed failing first (ADR-0016).

Signed-off-by: Mark Beacom <m@beacom.dev>
@github-actions github-actions Bot removed the gate-change-acknowledged A maintainer has seen and accepted this PR's change to the CI gate surface (ADR-0035) label Oct 10, 2026
@mbeacom mbeacom added the gate-change-acknowledged A maintainer has seen and accepted this PR's change to the CI gate surface (ADR-0035) label Oct 10, 2026
@mbeacom
mbeacom merged commit a5b0646 into main Oct 10, 2026
20 of 21 checks passed
@mbeacom
mbeacom deleted the feat/rest-server branch October 10, 2026 23:29
mbeacom added a commit that referenced this pull request Oct 10, 2026
…est the portal bundle's datetime as-of

packages/ci/dist rebuilt under linux/amd64 Bun 1.4.2 on top of main (after #307, #308, #314). The portal bundle now takes the SDK's --as-of grammar: a new bundle test resolves 2026-01-08T23:30:00-05:00 to 2026-01-09, and the usage-error test uses a timezone-less datetime with core's message. Both failed against main's bundle (ADR-0016).

Signed-off-by: Mark Beacom <m@beacom.dev>
mbeacom added a commit that referenced this pull request Oct 10, 2026
…ut publishing it (#295) (#312)

* feat(core,cli,sdk): close the SDK's remaining publication gates without publishing it (#295)

- ADR-0031 item 8 / ADR-0053 item 3: core exports todayUtcDate, the
  absent-input `--as-of` default, and replaces every inline copy: adr
  queue, the queue Action (bundle regenerated), adr new's scaffold, and
  the SDK's queue(). The portal now takes the SDK's echoed default
  (queue().asOf) instead of computing its own. Tests ban the inline
  expression in all of them.
- ADR-0031 item 4 / ADR-0053 item 8: `adr explain`, `adr lint`, and
  `adr new --json` now come from core formatters (packages/core/src/
  report). Old and new CLIs produced byte-identical stdout and exit codes
  on 53 command lines over this corpus and the SDK fixtures; committed
  goldens hold it.
- ADR-0031 item 6 / ADR-0053 item 5: a conformance fixture under
  packages/core/test/conformance/golden: the CLI must reproduce its
  queue and check goldens byte for byte, and the SDK must agree with them.
- ADR-0031 item 5: docs/RELEASING.md documents the SDK and CLI JSON
  consumer contracts.

Nothing is published: RELEASE_PACKAGES, exports, files, and the
no-release assertions are untouched. packages/ci/dist rebuilt under
linux/amd64 Bun 1.4.2. Every new test was observed failing first
(ADR-0016). Ticked: ADR-0031 items 4, 5, 6, 8 and ADR-0053 items 3, 5, 8.

Signed-off-by: Mark Beacom <m@beacom.dev>

* fix(sdk,portal): accept the CLI's --as-of grammar in queue(), so the portal takes ISO datetimes

Review on #312 found three gaps:
- #295 promised the portal would accept ISO datetimes once the default
  moved to core, but it still refused them. The SDK's queue({ asOf }) now
  resolves a supplied value through core's resolveAsOf, the grammar
  `adr queue --as-of` uses (a bare date, or an ISO datetime with an
  explicit zone), and echoes the resolved UTC date in QueueView.asOf;
  anything else throws RangeError. The portal passes the caller's asOf
  to queue() and builds from view.asOf, so it accepts datetimes while
  importing only the SDK. No new SDK symbol; the doc no longer tells
  callers to reach into core.
- The portal's default-date test could fail across UTC midnight; it now
  brackets both readings and compares only when no midnight passed.
- docs/sdk-surface.md and the ADR-0031 item 8 note still described the
  portal's removed copy and bare-date rule.

New tests were observed failing first (ADR-0016), including SDK/CLI
equivalence for a datetime asOf on all four corpora.

Signed-off-by: Mark Beacom <m@beacom.dev>

* build(ci): rebuild the Action bundles after rebasing onto main, and test the portal bundle's datetime as-of

packages/ci/dist rebuilt under linux/amd64 Bun 1.4.2 on top of main (after #307, #308, #314). The portal bundle now takes the SDK's --as-of grammar: a new bundle test resolves 2026-01-08T23:30:00-05:00 to 2026-01-09, and the usage-error test uses a timezone-less datetime with core's message. Both failed against main's bundle (ADR-0016).

Signed-off-by: Mark Beacom <m@beacom.dev>

---------

Signed-off-by: Mark Beacom <m@beacom.dev>
mbeacom added a commit that referenced this pull request Oct 11, 2026
…tion evidence (#316)

* docs(adr): record ADR-0054's rung-2 dogfood and ADR-0055's implementation evidence

- ADR-0054 item 8 ticked: https://adrkit.dev/decisions/ serves this
  repository's portal from site.yml via `uses: ./packages/ci/portal`
  (#310), verified by curl (version "1", 59 records, a real commit).
  Evidence rung is now 2 (maintainer-owned dogfood); rung 3 not claimed.
- ADR-0055 items 1-7 and 9 ticked against #314 and the CI container
  smoke on main; item 8 (APIM measurement) stays open. Evidence rung 1.
- ADR-0053 item 9 stays open: the publication record decides it.
  docs/sdk-surface.md now names the REST server as the long-lived
  consumer that exercises every handle member, beside the build-time
  portal.
- AGENTS.md rung notes updated, and the broken ADR-0055 link (`-th.md`)
  fixed in AGENTS.md, README.md, CHANGELOG.md, and nine
  packages/server/src header comments.

Signed-off-by: Mark Beacom <m@beacom.dev>

* docs(adr): keep ADR-0054 at rung 1; the adrkit.dev deploy is dogfood, not rung 2

Copilot pointed out that ADR-0014 defines rung 2 as a run in a separate, isolated reference repository with reproducible, self-verifying evidence, and ADR-0053 records that an in-repository consumer stays rung 1. Item 8 stays ticked because it was done as written, but the evidence rung is 1, item 8's own 'rung-2' label is noted as an overstatement, and rung 2 is named as a portal Action run in an isolated reference repository such as adrkit-t018-dogfood. AGENTS.md matches.

Signed-off-by: Mark Beacom <m@beacom.dev>

---------

Signed-off-by: Mark Beacom <m@beacom.dev>
mbeacom added a commit to mbeacom/adrkit-azure that referenced this pull request Oct 11, 2026
…OpenAPI document (#5)

* feat: wire the server's corpora config, portal path, and OpenAPI document

Refs #3. Uses the adrkit `server` selector as merged in mbeacom/adrkit#314
(not yet released; v0.20.0 expected).

- container_apps: set ADRKIT_SERVER_ROOT=/corpora (the read-only share
  mount), ADRKIT_SERVER_CORPORA to the repos[].key list (the server's
  `key[=dir],...` form, bare keys), and ADRKIT_SERVER_PORT from one
  server_port local shared with ingress and probes. The sync job's
  contract now names the layout the server reads:
  <root>/<key>/docs/adr/... and <root>/<key>/.adrkit-corpus.json.
- apim: import the server's real OpenAPI 3.0.3 document, vendored from
  adrkit e491a785 (source and regeneration in modules/apim/openapi/README.md).
  /healthz is never imported; the /portal/... operations are replaced by one
  private-mode wildcard GET /portal/*; / stays private-mode only. MCP tools
  are the seven /api/v1 data operations, now read from mcp-tools.json.
- ci: scripts/check-openapi-tools.sh fails if a tool's operationId is
  missing from the vendored document, maps to anything other than
  GET /api/v1/..., or if the document has a non-GET operation. Observed
  failing on planted cases before passing.
- variables: image_tag refuses tags below v0.20.0; repos requires at least
  one entry, because the server refuses to start with no corpus.
- docs: README TODO table drops server-config, openapi, and portal-path;
  measure/ refers to the operation-naming assumption instead of the
  retired TODO(openapi) tag.

Signed-off-by: Mark Beacom <m@beacom.dev>

* feat: pin the v0.20.0 server and regenerate its OpenAPI document

Closes #3

Signed-off-by: Mark Beacom <m@beacom.dev>

---------

Signed-off-by: Mark Beacom <m@beacom.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request gate-change-acknowledged A maintainer has seen and accepted this PR's change to the CI gate surface (ADR-0035)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

server: implement the read-only REST API in the OCI image (ADR-0055)

2 participants