Skip to content
docs
Arxo ↗

Private HTTP service with law serve

For LLMs9 sections

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.

  • The law binary (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.

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):

Terminal
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); --format brief is accepted only together with --out <dir> (otherwise the refusal names the missing --out):

Output
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>/.

Reference-setting invocation (every flag below exists in the USAGE string; values are operator choices):

Terminal
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):

Output
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:

SurfaceBoundary
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).

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:

Terminal
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.

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:

Terminal
# 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.

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).

  • 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):

    EndpointTokenReadiness gateNotes
    GET /healthznonoliveness only; 200 status:"ok" even while warming or failed
    GET /readyznon/a (it IS the gate)200 ready only when all workers warm and no failure; else 503 warming or failed + reason
    GET /metricsnonoPrometheus 0.0.4 text; worker-call durations only
    GET /v1/worldyesnopin + programHash; wrong/missing token → 401 SERVE_UNAUTHORIZED
    POST /v1/askyesyes503 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.

Terminal
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.

SymptomCode / exitMeaning and fix
startup: a decision journal is requiredexit 2pass --journal <dir> or explicit --no-journal
startup: address … without a token is refusedrefusalnon-loopback --host needs --token/--token-file, or deliberate --public
startup: SERVE_WORLD_INVALIDexit 1world lacks law.toml/law.lock, or bytes fail content checks
ask: 409SERVE_PIN_MISMATCHstale pin; re-read /v1/world
ask: 401SERVE_UNAUTHORIZEDmissing/wrong Authorization: Bearer
ask: 422SERVE_LIMITS_REQUIREDcase declares no evaluation limits (maxFacts, maxGroundedApplications, maxProofNodes); the service substitutes none
ask: 413 / 507SERVE_BODY_TOO_LARGE / SERVE_RESPONSE_TOO_LARGEover admission caps (defaults 1 MiB / 16 MiB); proof:"omit" shrinks answers
ask: 503SERVE_QUEUE_FULL / SERVE_NOT_READYqueue (default 64) full, or workers still warming
ask: 504SERVE_TIMEOUTcall exceeded --call-timeout-ms; the process worker was stopped
ask: 400/422/409SERVE_IDEMPOTENCY_*bad, conflicting, or still-running Idempotency-Key

Full code table: law-serve-core/src/codes.rs (every SERVE_* entry carries its HTTP status).

  • “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.

Documentation for Arxo. Writings — blog.arxo.io.

Anonymous visit counts on stats.arxo.io, no cookies.