Markdown for LLMs
Private HTTP service with law serve
The source Markdown for this article. Copy it into your assistant or download it as a text file.
# Private HTTP service with law serve
## Goal
Run a private, journaled `law serve` instance end to end: install, prepare
the world, start on loopback with a token, put an operator TLS ingress in
front, ask a question, survive a pin mismatch and a restart, and verify
that every served decision replays.
## Scope
Component `law serve` (usage in `serve.rs`, service in the
`law-serve-core` crate; CLI tool `0.1.1` per the
[CLI reference](/cli/reference/)). Scenario: one private host, journaled mode,
single world. All paths, tokens, and hashes are synthetic examples.
## Prerequisites
- The `law` binary ([Install](/cli/install/)); `law version` prints.
- A case package with `law.toml` and `law.lock` (created-example path
`/srv/cases/kompaniya`). `World::load` refuses a world directory without
both files.
- A secret token the operator chose (created-example value below is not a
real secret). Prefer `--token-file` over `--token` so the secret never
appears in the process list.
- An empty journal directory the service user can write
(created-example `/srv/cases/kompaniya-journal`).
- The service runs as a dedicated OS user (operator-policy-example;
created-example user `lawsvc`). The implementation checks no user; the
OS does.
## Steps
### Step 1: Prepare the world
The world is a case-package directory with a lock, optionally narrowed to
one pinned profile file. This recipe uses the known world from the
reference kit — the in-repo package `packs/examples/cases/kompaniya`
with its vendored dependencies (no registry access needed):
```bash
WORLD=packs/examples/cases/kompaniya
ls $WORLD/law.toml $WORLD/law.lock
law ask $WORLD --query-json $WORLD/queries/zaregistrirovana.json \
--out /tmp/world-smoke --format brief
```
Expected local answer (recorded at tool `0.1.1`): `TRUE_ONLY` with
`resultHash sha256:b6094c8e…64487`. The same query, case
(`Registratsiya`), and pin below form the fixture for every HTTP
check in this article: the full request body lives in the kit at
`requests/serve-ask.json`, the recorded outcome at
`expected/serve-ask-expected.json`.
Real command shapes: `law ask [<case>] (--query … | --query-json <file> |
--card <id>) [--out <dir>] [--format json|text|brief]`
([CLI reference](/cli/reference/)); `--format brief` is accepted only together
with `--out <dir>` (otherwise the refusal names the missing `--out`):
```text
lawc: ask: --format brief only together with --out <dir>
```
The local `law ask` must answer before the
world is served; serve-side refusals (`SERVE_LIMITS_REQUIRED` when the
case declares no evaluation limits) reproduce locally first.
Alternative world pointer (real form): a path ending in
`deps/profiles/<id>/<version>/profile.json` inside the same package; any
other `profile.json` path is refused as
`profile.json outside deps/profiles/<id>/<version>/`.
### Step 2: Start the service
Reference-setting invocation (every flag below exists in the `USAGE`
string; values are operator choices):
```bash
law serve --world /srv/cases/kompaniya --journal /srv/cases/kompaniya-journal \
--host 127.0.0.1 --port 8480 --token-file /srv/secrets/serve-token \
--workers 2 --call-timeout-ms 30000
```
`/srv/cases/kompaniya` is the deployed copy of the known world from
Step 1 (same `law.toml` + `law.lock` bytes, so the same pin). The
fixture pin in the kit matches this world exactly; if you serve a
different world, re-read its pin from `GET /v1/world` and rebuild
the fixture instead of reusing these hashes.
Defaults you are explicitly keeping: `--host 127.0.0.1`, `--port 8480`,
`--workers 2`, 30 s call timeout, queue depth 64, 1 MiB max body, 10 000
max assertions, 16 MiB max response
(`law-serve-core/src/service.rs`, `Config::default`). Startup prints a
real line of this shape (world id and resolution-hash affixes are
the kit fixture values; `programHash` below is synthetic):
```text
law serve: listening on http://127.0.0.1:8480/v1/ask; world examples.cases.kompaniya@0.1.0 sha256:3ad7…78b5; programHash 9f8e…; warming 2 workers…
law serve: ready (/readyz 200)
```
`/readyz` answers 200 only after ALL workers warm; before that it is 503.
`--journal` is the chosen journal mode: a decision is sent to the client
only after its record is fsynced (`journal.rs`). `--no-journal` exists
but is development-only: unrecorded decisions never replay.
### Step 3: Secured ingress (operator layer, created-example)
The server speaks plain HTTP and has no TLS flag. "Secured" ingress here
means only: TLS plus access control at an operator reverse proxy. Publish
exactly the surface below — a whole-`/` proxy would also expose the
open observability routes, contradicting the access table in Expected
result:
| Surface | Boundary |
|---|---|
| Working API (`/v1/ask`, `/v1/world`) | the TLS ingress below (backend still checks the token) |
| Readiness/liveness (`/readyz`, `/healthz`) | balancer + internal network only |
| Metrics (`/metrics`) | internal scraper, or its own auth |
Sketch (not a tested artifact; fuller version with the internal
listener: `kit/secure-deploy/serve-edge.conf.example`):
```nginx
# created-example: operator edge, not part of law serve
server {
listen 443 ssl;
ssl_certificate /etc/ssl/private/serve.example.crt;
ssl_certificate_key /etc/ssl/private/serve.example.key;
location = /v1/ask {
proxy_pass http://127.0.0.1:8480;
proxy_read_timeout 120s;
}
location = /v1/world {
proxy_pass http://127.0.0.1:8480;
}
location / {
return 404; # observability routes are not published here
}
}
```
Keep the service on loopback; let only the proxy reach it. The Bearer
token is still checked by `law serve` itself (`Authorization: Bearer
<token>`, else `SERVE_UNAUTHORIZED`, HTTP 401).
### Step 4: Ask: request and response
Real envelope (closed by the envelope rule: an extra top-level field is
a refusal): `{pin, case, query | evaluate, queryId?, proof?}`,
or the facts form `{pin, facts[], context{}, options{}, query, proof?}`.
The fixture query is schema-complete (`queryId`, `kind`, and
`literal` with predicate and args — the same question Step 1 answered
locally). The live pin is compared with the approved fixture pin,
and only a matching fixture is sent as is — the client never
auto-adopts whatever version answers (the same three-pin rule as
in Step 5). `GET /v1/world` returns an object whose `pin` is
`{world, resolutionHash}` with `programHash` as a sibling field
(`service.rs`), so compare `.pin`, never the whole body:
```bash
SERVE_TOKEN=${SERVE_TOKEN:?set SERVE_TOKEN to the live token} # same convention as check-postflight.sh
KIT=docs/operate/secure-deployment/kit/secure-deploy
# Two request classes (see the endpoint access table in Expected
# result): open service probes vs tokened API calls. One helper
# per class, so a protected call can never lose its header by
# accident the way a bare curl to /v1/world would (401).
probe() { curl -fsS "http://127.0.0.1:8480$1"; } # /healthz /readyz /metrics only
api() { curl -H "Authorization: Bearer $SERVE_TOKEN" "$@"; } # /v1/world /v1/ask
probe /readyz > /dev/null
api -fsS http://127.0.0.1:8480/v1/world > /tmp/serve-world.json
jq -e --slurpfile live /tmp/serve-world.json \
'.pin == $live[0].pin' \
$KIT/requests/serve-ask.json > /dev/null
api -s -o /tmp/serve-ask-resp.json -w '%{http_code}\n' \
http://127.0.0.1:8480/v1/ask \
-H 'content-type: application/json' \
-H 'Idempotency-Key: kompaniya-smoke-0001' \
-d @$KIT/requests/serve-ask.json
```
If the `jq -e` comparison fails, stop: the served world is not
the approved one. Resend only after the three-pin check in
Step 5 consciously accepts the new value — never by stamping
the live pin into the request automatically.
Expected result: HTTP 200 with a document carrying `pin`,
`programHash sha256:8725…935e`, and the recorded outcome
(`TRUE_ONLY`, `resultHash sha256:b6094c8e…64487`; full values in
`expected/serve-ask-expected.json`). `ProofMode::Full` is the default
when `proof` is absent; `"omit"` drops the proof graph. Repeating the
call with the same `Idempotency-Key` replays the earlier decision
instead of recomputing it; a key is 1–255 printable ASCII characters
without spaces. That makes the fixed `kompaniya-smoke-0001` key a
replay check on every re-run — good for proving idempotency, wrong
as a post-upgrade or post-restart canary. A canary that must prove
fresh computation uses a new key per run (e.g.
`kompaniya-canary-$(date +%Y%m%d%H%M%S)`) and confirms the new
record's `engine` identity; see the fresh-key rule in
[Upgrades and compatibility](/operate/upgrades-compatibility/).
Service routes (all real, `service.rs` `route`): `GET /healthz`,
`GET /readyz`, `GET /v1/world` (object `{pin: {world, resolutionHash},
programHash}`), `GET /metrics` (Prometheus text, incl.
`law_serve_refusals_total{code=…}`).
### Step 5: Pin mismatch (expected failure, exercised on purpose)
Send the negative fixture, which is byte-identical to the
positive one except for `pin.resolutionHash` — so a refusal proves
the pin check fired, not some other body error:
```bash
# same shell as Step 4 (needs the api helper and $KIT from there)
api -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8480/v1/ask \
-H 'content-type: application/json' \
-d @$KIT/requests/serve-ask-stale-pin.json
```
Expected: `409` with code `SERVE_PIN_MISMATCH` ("request pin differs from
the loaded world"). Resolve the mismatch by comparing three values
before resending anything:
1. the pin your client was configured to expect (release record);
2. the active pin from `GET /v1/world`;
3. the pin of the release you actually intended to deploy.
Only when all three agree on the new value — i.e. you consciously
accept the new version — resend with the active pin, never by
guessing hashes. A mismatch can also mean a wrong endpoint, bad
routing, or the wrong release behind the ingress; "read the
server's pin and retry" as an automatic loop would bless
whichever version happens to answer.
### Step 6: Restart
Stop the process (SIGTERM/SIGINT), then start with the same flags.
There is no graceful drain in production: a signal kills the process
immediately and in-flight calls die with it (the stop-accept +
drain-workers path exists only in tests; there are no
`--drain`/`--grace` flags). This is safe for the journal because a
decision is served only after its record is fsynced — a killed call
leaves either a complete record or none. The journal index rebuilds
from `segment-*.jsonl` at open; a torn line is tolerated only at the
tail of the last segment, anything else is a refusal (real,
`SERVE_JOURNAL_FAILED`). Tolerance is not preservation: open
truncates the torn bytes and logs `cut torn tail of N bytes from
segment M (decision was not confirmed)` on stderr (real). Snapshot
the segments before restarting if the tail bytes are evidence; to
inventory a crashed journal without consuming it, use the read-only
`kit/bin/arxo-list-decisions` (see [Backup and restore](/operate/backup-restore/)). After restart,
`/readyz` must return 200 again and `/v1/world` the same pin and
`programHash` before the ingress is re-enabled.
For rotation without a restart, point `--world` at a symlink and pass
`--watch-world`: the server polls the target once a second and warms a new
generation when it changes (`serve.rs`).
## Expected result
- `GET /readyz` → 200; `GET /v1/world` → the pinned `world`,
`resolutionHash`, `programHash`.
- Endpoint access (real, `service.rs` `route` + `authorize` — no
guessing which routes the token covers):
| Endpoint | Token | Readiness gate | Notes |
|---|---|---|---|
| `GET /healthz` | no | no | liveness only; 200 `status:"ok"` even while warming or failed |
| `GET /readyz` | no | n/a (it IS the gate) | 200 `ready` only when all workers warm and no failure; else 503 `warming` or `failed` + reason |
| `GET /metrics` | no | no | Prometheus 0.0.4 text; worker-call durations only |
| `GET /v1/world` | yes | no | pin + `programHash`; wrong/missing token → 401 `SERVE_UNAUTHORIZED` |
| `POST /v1/ask` | yes | yes | 503 `SERVE_NOT_READY` while warming or after a failure; 405/404 for wrong method/path |
Without a configured token (`--token`/`--token-file` unset)
the `yes` rows are open — the authorize gate passes
everything (real). There is no middle ground: one token
guards both `/v1/world` and `/v1/ask`, and the three
observability routes stay open for the balancer and the
supervisor. Put the tokened routes behind the TLS edge and
keep the open ones off the public internet anyway — they
expose the pin, counters, and (via `/healthz`) the pid.
- `POST /v1/ask` with the active pin → 200 + decision document; with a
stale pin → 409 `SERVE_PIN_MISMATCH`; without/wrong token → 401
`SERVE_UNAUTHORIZED`.
- The journal directory holds `segment-*.jsonl` records, one fsynced line
per served decision.
## Result check
```bash
SERVE_TOKEN=${SERVE_TOKEN:?set SERVE_TOKEN to the live token}
probe() { curl -s "http://127.0.0.1:8480$1"; } # open routes only
api() { curl -s -H "Authorization: Bearer $SERVE_TOKEN" "$@"; } # tokened routes
probe /readyz; echo
api http://127.0.0.1:8480/v1/world; echo
DECISION=$(jq -r '.decisionId' /tmp/serve-ask-resp.json)
law replay-record --world /srv/cases/kompaniya \
--journal /srv/cases/kompaniya-journal --decision "$DECISION" --json
```
Real command shape: `law replay-record --world <dir> --journal <dir>
[--decision <id>]… [--json]`. Success is: `/readyz` 200, the world pin
matches the request pin, and replay of the `decisionId` returned by
the Step 4 check request reports no mismatch
(`SERVE_REPLAY_MISMATCH` would mean record and recomputation disagree).
Binding the replay to that `decisionId` closes exactly the path this
article exercised — same world, same journal, same decision.
## Failures and diagnostics
| Symptom | Code / exit | Meaning and fix |
|---|---|---|
| startup: `a decision journal is required` | exit 2 | pass `--journal <dir>` or explicit `--no-journal` |
| startup: `address … without a token is refused` | refusal | non-loopback `--host` needs `--token`/`--token-file`, or deliberate `--public` |
| startup: `SERVE_WORLD_INVALID` | exit 1 | world lacks `law.toml`/`law.lock`, or bytes fail content checks |
| ask: 409 | `SERVE_PIN_MISMATCH` | stale pin; re-read `/v1/world` |
| ask: 401 | `SERVE_UNAUTHORIZED` | missing/wrong `Authorization: Bearer` |
| ask: 422 | `SERVE_LIMITS_REQUIRED` | case declares no evaluation limits (`maxFacts`, `maxGroundedApplications`, `maxProofNodes`); the service substitutes none |
| ask: 413 / 507 | `SERVE_BODY_TOO_LARGE` / `SERVE_RESPONSE_TOO_LARGE` | over admission caps (defaults 1 MiB / 16 MiB); `proof:"omit"` shrinks answers |
| ask: 503 | `SERVE_QUEUE_FULL` / `SERVE_NOT_READY` | queue (default 64) full, or workers still warming |
| ask: 504 | `SERVE_TIMEOUT` | call exceeded `--call-timeout-ms`; the process worker was stopped |
| ask: 400/422/409 | `SERVE_IDEMPOTENCY_*` | bad, conflicting, or still-running `Idempotency-Key` |
Full code table: `law-serve-core/src/codes.rs` (every `SERVE_*` entry
carries its HTTP status).
## Support boundaries
- "Private" here means: loopback bind + Bearer token + operator TLS edge.
The server itself does no TLS, no rate limiting, no per-client quotas.
- `--public` without a token is explicit consent to an open evaluator,
not a deployment mode; `--no-journal` is development-only.
- Implementation limits are admission caps (body, assertions, response,
queue, timeouts), not semantic limits: the case's own declared
evaluation limits still govern what the question may compute.
- Journal segments are append-only evidence; hand-editing them breaks
replay loudly (`SERVE_RECORD_CORRUPT`), not silently.
## Next step
- [01 Deployment overview](/operate/deployment-overview/) to compare surfaces.
- [03 Private MCP server](/operate/private-mcp-server/) for agent/editor access
to the same corpus over MCP.