# 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 [] (--query … | --query-json | --card ) [--out ] [--format json|text|brief]` ([CLI reference](/cli/reference/)); `--format brief` is accepted only together with `--out ` (otherwise the refusal names the missing `--out`): ```text lawc: ask: --format brief only together with --out ``` 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///profile.json` inside the same package; any other `profile.json` path is refused as `profile.json outside deps/profiles///`. ### 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 `, 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 --journal [--decision ]… [--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 ` 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.