Private HTTP service with law serve
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.
Component law serve (usage in serve.rs, service in the
law-serve-core crate; CLI tool 0.1.1 per the
CLI reference). Scenario: one private host, journaled mode,
single world. All paths, tokens, and hashes are synthetic examples.
Prerequisites
Section titled “Prerequisites”- The
lawbinary (Install);law versionprints. - A case package with
law.tomlandlaw.lock(created-example path/srv/cases/kompaniya).World::loadrefuses a world directory without both files. - A secret token the operator chose (created-example value below is not a
real secret). Prefer
--token-fileover--tokenso 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.
Step 1: Prepare the world
Section titled “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):
WORLD=packs/examples/cases/kompaniyals $WORLD/law.toml $WORLD/law.locklaw ask $WORLD --query-json $WORLD/queries/zaregistrirovana.json \ --out /tmp/world-smoke --format briefExpected 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); --format brief is accepted only together
with --out <dir> (otherwise the refusal names the missing --out):
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
Section titled “Step 2: Start the service”Reference-setting invocation (every flag below exists in the USAGE
string; values are operator choices):
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):
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)
Section titled “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):
# created-example: operator edge, not part of law serveserver { 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
Section titled “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:
SERVE_TOKEN=${SERVE_TOKEN:?set SERVE_TOKEN to the live token} # same convention as check-postflight.shKIT=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 onlyapi() { curl -H "Authorization: Bearer $SERVE_TOKEN" "$@"; } # /v1/world /v1/askprobe /readyz > /dev/nullapi -fsS http://127.0.0.1:8480/v1/world > /tmp/serve-world.jsonjq -e --slurpfile live /tmp/serve-world.json \ '.pin == $live[0].pin' \ $KIT/requests/serve-ask.json > /dev/nullapi -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.jsonIf 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.
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)
Section titled “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:
# 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.jsonExpected: 409 with code SERVE_PIN_MISMATCH (“request pin differs from
the loaded world”). Resolve the mismatch by comparing three values
before resending anything:
- the pin your client was configured to expect (release record);
- the active pin from
GET /v1/world; - 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
Section titled “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). 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
Section titled “Expected result”-
GET /readyz→ 200;GET /v1/world→ the pinnedworld,resolutionHash,programHash. -
Endpoint access (real,
service.rsroute+authorize— no guessing which routes the token covers):Endpoint Token Readiness gate Notes GET /healthzno no liveness only; 200 status:"ok"even while warming or failedGET /readyzno n/a (it IS the gate) 200 readyonly when all workers warm and no failure; else 503warmingorfailed+ reasonGET /metricsno no Prometheus 0.0.4 text; worker-call durations only GET /v1/worldyes no pin + programHash; wrong/missing token → 401SERVE_UNAUTHORIZEDPOST /v1/askyes yes 503 SERVE_NOT_READYwhile warming or after a failure; 405/404 for wrong method/pathWithout a configured token (
--token/--token-fileunset) theyesrows are open — the authorize gate passes everything (real). There is no middle ground: one token guards both/v1/worldand/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/askwith the active pin → 200 + decision document; with a stale pin → 409SERVE_PIN_MISMATCH; without/wrong token → 401SERVE_UNAUTHORIZED. -
The journal directory holds
segment-*.jsonlrecords, one fsynced line per served decision.
Result check
Section titled “Result check”SERVE_TOKEN=${SERVE_TOKEN:?set SERVE_TOKEN to the live token}probe() { curl -s "http://127.0.0.1:8480$1"; } # open routes onlyapi() { curl -s -H "Authorization: Bearer $SERVE_TOKEN" "$@"; } # tokened routesprobe /readyz; echoapi http://127.0.0.1:8480/v1/world; echoDECISION=$(jq -r '.decisionId' /tmp/serve-ask-resp.json)law replay-record --world /srv/cases/kompaniya \ --journal /srv/cases/kompaniya-journal --decision "$DECISION" --jsonReal 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
Section titled “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
Section titled “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.
--publicwithout a token is explicit consent to an open evaluator, not a deployment mode;--no-journalis 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
Section titled “Next step”- 01 Deployment overview to compare surfaces.
- 03 Private MCP server for agent/editor access to the same corpus over MCP.
Documentation for Arxo. Writings — blog.arxo.io.
Anonymous visit counts on stats.arxo.io, no cookies.