Markdown for LLMs
Tool profiles and input policy
The source Markdown for this article. Copy it into your assistant or download it as a text file.
# Tool profiles and input policy
## Goal
Show how a corpus profile narrows the tool surface (facets), what a
caller sees when invoking a forbidden or unknown tool, which inputs
carry rights and which do not, what side effects tools have, and
where fact authorship is and is not verified.
## Scope
Component `law-mcp-server` (`law-mcp/`): tool
registry `data/tools.json` (36 tools) plus science tools
(`src/science.rs`), facet gating (`src/server.rs`, `src/profile.rs`),
fact schema (`law_ask.facts`, `law_case_*`). Scenario: operating one
profiled slice (e.g. `islam` on `/mcp/islam`). All calls below are
synthetic created-examples; tool names, facet names, error codes, and
schema fields are real.
## Prerequisites
- A profile file `<name>.json` in the checkout's profiles directory, or `all`.
- A running instance started with `--profile <name>` (facets bind at
startup; changing the file needs a restart).
- A token or loopback access (see [Authentication and access control](/operate/authentication-access-control/)).
## Facets: the nine tool groups
Every tool carries one `facet`. A profile's `facets.allow` lists the
served facets; `facets.deny` maps each withheld facet to a non-empty
human reason. Validation (`validate_facets`) is strict: **each of the
9 known facets must be named exactly once** — in `allow` or `deny`,
never both, never neither; duplicates and empty reasons refuse
startup. Profile `all` allows all 9 with no reasons.
| Facet | Tools (from `tools.json`, contract order) | Typical effect |
|---|---|---|
| `reference` | `law_search`, `law_passport`, `law_answer_capabilities`, `law_packages`, `law_rules`, `law_sources`, `law_query` | read-only: find and quote formalized law |
| `execution` | `law_capture_answer`, `law_prepare_answer`, `law_publish_answer`, `law_revoke_answer`, `law_ask`, `law_screen`, `law_explain`, `law_inspect`, `law_case_ask` | evaluate; prepare/publish/revoke frozen answer documents |
| `drafting` | `law_check`, `law_pin`, `law_draft_check`, `law_draft_eval`, `law_measure`, `law_draft_verbalize`, `law_draft_differential`, `law_draft_impact` | author workbench: check drafts, pin sources, measure completeness |
| `editing` | `law_case_propose`, `law_case_facts`, `law_case_decide`, `law_case_document`, `law_case_check` | case-package journal: propose/accept facts, write documents |
| `argumentation` | `law_argue` | read-only: dispute map over a derivation |
| `adversarial` | `law_loophole`, `law_amend` | circumvention analysis; amendment drafting |
| `process` | `law_process`, `law_process_run` | read-only: procedure graph; replay a case journal |
| `editions` | `law_editions` | read-only: what changed between two law-dates |
| `feedback` | `law_report` | append a formalization-divergence report to the server journal |
Real example — the shipped `islam` profile allows 6 facets and denies
3 with reasons (drafting: "author workbench … is not executed on the
public slice"; adversarial: doctrine "is not amended"; editing:
"executed only on the author's local slice"). Copy that shape for a
new slice; the reason strings are operator-chosen text.
## Steps: serve a narrowed slice
1. Write the profile: `allow` the facets the slice needs, `deny` the
rest with a reason each (all 9 named exactly once).
2. Start with `--profile <name>` and read the stderr launch line for
the profile name.
3. Call `tools/list` (real method, synthetic transport): the reply
contains only allowed-facet tools, with the `facet` field
stripped. Count them against the table above.
4. Call one denied tool directly (next section) and confirm `-32002`.
## Direct call of a forbidden tool
`tools/list` filtering is presentation; enforcement happens per call
in `call_tool`. A direct `tools/call` naming a denied-facet tool
returns a JSON-RPC error (real codes, synthetic ids):
```json
{"jsonrpc": "2.0", "id": 7, "error": {"code": -32002, "message": "tool 'law_pin' ... facet 'drafting' ... <deny reason>"}}
```
Error-code table (all verified in `server.rs`):
| Code | Meaning | When |
|---|---|---|
| `-32002` | facet denied | tool exists but its facet is not in `facets_allow`; message quotes the profile's deny reason |
| `-32602` | unknown tool | `name` matches no registry entry |
| `-32601` | unsupported method | method is not `initialize`/`tools/list`/`tools/call`/`prompts/...`/`ping`/notifications |
| `-32001` + `CALL_TIMEOUT` | call overran `LAW_MCP_CALL_TIMEOUT` | computation abandoned; result discarded; `overrunCalls` in `/healthz` counts it |
| `-32603` + `INTERNAL` | handler panicked | never a legal outcome; journal records `MCP_TRACEBACK` |
## Input-predicate rights: none per predicate
`law_ask` (and `law_argue`, `law_process_run`, `law_case_ask`) accept
facts shaped `{predicate, args, negated?, package?, provenance?,
judgment?}`. There is **no per-predicate allow-list, no per-fact
role, and no caller-rights check on inputs**: any caller passing the
instance check (see [Authentication](/operate/authentication-access-control/))
may assert any predicate with any arguments. What the server does instead:
- `package` on a fact disambiguates a short predicate name; it
grants nothing.
- Unknown predicates and unsatisfied premises surface as
"not established" with a `whyNot` report — a logical outcome, not
a refusal.
- `law_screen` builds only a superset pre-filter: every passing row
must still be fully evaluated with a proof graph; the screen
grants no shortcut around evaluation.
Fact actions are call-scoped: facts live for one evaluation and are
never stored, except inside the call journal record (capped by
`LAW_MCP_LOG_MAX_BODY`) and inside caller-built case packages via
the `editing` facet.
## Effects: the authoritative matrix
This table is the single source of truth for what each tool class
reads, writes, sends out, and requires for affinity. Other articles
defer to it; if they seem to disagree, this table wins and the other
article is a bug — report it.
| Effect class | Tools | Trigger / setting | Reads / writes / sends | Same instance? | Re-creatable after loss? |
|---|---|---|---|---|---|
| Pure evaluation | `law_ask`, `law_argue`, `law_explain`, `law_inspect`, `law_editions`, `law_process`, `law_process_run`, `law_query`, `law_screen`, `reference` readers | facet allowed | reads corpus + case input; writes nothing (journal/OTLP observe, never alter answers) | no — any identical instance | n/a (no state) |
| Answer publication | `law_capture_answer`, `law_prepare_answer` (24 h private freeze), `law_publish_answer` (public URL + separate revoke secret), `law_revoke_answer` | `LAW_ANSWERS_URL` **and** `LAW_ANSWERS_TOKEN` both set (read per call); else the tools answer "unavailable" | **no local writes** — the call is proxied to the external answers service, which owns the freeze, the public URL, and the revoke | no for the call itself; the frozen document lives in the answers service, not on any instance | only from the answers service |
| Neural appendix | `law_search` | `LAW_NEURAL_URL` set (token required by the client; read per call); else lexical-only answer | sends the query text to the external neural service; writes nothing locally | no | n/a (no state) |
| Case-package journal | `law_case_propose`, `law_case_facts`, `law_case_decide`, `law_case_document`, `law_case_check` | `editing` facet allowed | writes the case package on disk (one transaction) — the reason public slices deny `editing` | only if instances share the tree; otherwise the write lands on one instance's files | only from backup |
| Draft checks and pins | `law_check`, `law_pin`, `law_draft_*`, `law_measure` | `drafting` facet allowed | writes working files/pins of the author tree — the reason public slices deny `drafting` | same-tree requirement as above | only from backup / VCS |
| Saved-answer explain | `law_explain` with a `law://answer/` URI | artifact file present | **reads** the artifact from `LAW_MCP_ARTIFACT_DIR` (default `$HOME/.cache/law-dsl/mcp-answers/<profile>`); no tool in this server writes there — artifacts must arrive out-of-band (shared mount, copy step) | yes, unless the directory is shared: the reading instance must see the file | only from whoever saved it |
| Divergence reports | `law_report` | `feedback` facet allowed | server journal only; changes no law and no answer | no | n/a (journaled) |
| Always-on side channels | every call | journal enabled (default) / `LAW_MCP_OTLP_URL` set | call journal to the journald socket or stderr (never to files); OTLP spans out | n/a | n/a |
| Temp files | workbench runs | — | temp dirs + `request.json` under the OS temp dir; `--export-neural-input` writes its target file | n/a (per-call temp) | n/a |
Consequences used by the other articles:
- "The server writes no files" is true only with `editing` and
`drafting` denied, no export flags, and temp files excepted.
Anything else, and the matrix row above names the writer.
- "Nothing leaves the machine" additionally requires every `*_URL`
leg unset — no listening socket does not stop outbound calls.
- Scale-out is affinity-free only for pure evaluation plus the two
remote legs. Case writes need a shared tree (or sticky
routing); `law://answer/` reads need a shared artifact dir (or
the owning instance).
## Initiator identity and author fields: caller-asserted
Fact `provenance` carries `origin` (default `case_input`), `evidence`
(a StableId), `span` (`page`/`quote`/`offset`), `extractor`
(`name`, `version?`, `confidence?`), and timestamps. All of it is
**caller-asserted metadata**: the server records it into
`was_generated_by` / `was_derived_from` edges and may warn
(`FACT_WITHOUT_EVIDENCE` when an `extractor` has no `evidence`), but
it verifies no signature, no author identity, and no extractor
claim. Journal field `MCP_CLIENT` (from `X-Forwarded-For`/socket) is
likewise observability, not authentication (see [Authentication and access control](/operate/authentication-access-control/)). Treat
`extractor.name` as a label the caller chose, never as proof of who
produced the fact.
## Operational tool profile vs model composition
- The **operational tool profile** is the server's facet set, bound
at startup by `--profile`. It is the enforcement point: hidden
tools stay callable-shaped but answer `-32002`, and `tools/list`
is its projection.
- **Model composition** is whatever tool subset the agent harness
exposes to the model (system prompt, tool filter, per-task
allow-list). It is convenience and cost control, not a boundary:
a model that smuggles a `tools/call` for a composed-away tool
succeeds whenever the server profile allows the facet.
- Rule: put every real boundary in the server profile
(facets + instance token + separate processes per tenant); use
model composition only for focus. Audits check `tools/list` and
one direct `-32002` probe, never the harness config alone.
## Expected result
On a slice denying `drafting`/`adversarial`/`editing` (the `islam`
shape): `tools/list` shows 6 facets' tools; `law_ask` evaluates;
`law_pin` answers `-32002` quoting the deny reason; `law_nope`
answers `-32602`.
## Result check
```bash
curl -s http://127.0.0.1:8725/mcp -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \
| python3 -c "import json,sys; print(sorted({t['name'] for t in json.load(sys.stdin)['result']['tools']}))"
curl -s http://127.0.0.1:8725/mcp -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"law_pin","arguments":{}}}'
```
Success: the name list contains no `drafting`/`adversarial`/`editing`
tool, and the second call returns `code -32002`. (Created-example
port; methods and codes are real.)
## Failures and diagnostics
| Symptom | Cause | Fix |
|---|---|---|
| Startup: `each known facet must be named exactly once` | facet missing or in both lists | name all 9 exactly once |
| Startup: `facets.deny reason must be non-empty` | blank reason | write a reason per denied facet |
| Startup: `corpus profile "x" not found` | `--profile` typo | match the profile file name (`<name>.json`) |
| `-32002` for a tool the slice should serve | facet denied in the profile | move it to `allow`, restart |
| `-32602` for a real tool name | typo in `name` (e.g. `law-ask`) | use the exact `law_*` name |
| Denied tool still listed after profile edit | process not restarted | restart; facets bind at startup |
| `FACT_WITHOUT_EVIDENCE` warning | fact has `extractor` but no `evidence` | attach `provenance.evidence` or drop the extractor claim |
## Support boundaries
- Facets are the finest tool boundary the server supports: there is
no per-tool flag, per-predicate right, or per-user tool set.
Separate processes give instance isolation (own token, own
profile, own files), not finer authorization — one `execution`
facet still bundles evaluation with answer preparation and
publication in every process. "Evaluate but never publish" has no
server-side switch: the nearest real mechanism is leaving the
answers leg unwired (`LAW_ANSWERS_URL` unset), which turns all
four publication tools into "unavailable" on that instance.
- Deny reasons are human text in the profile; they are quoted in
`-32002` messages and must not contain secrets.
- `extractor`/`origin` fields are unverified labels, not authorship
proof. Provenance that must be trusted needs an operator-side
signing/inventory process this server does not provide.
- Science tools additionally require the profile to read
`calc-multiple-testing`; on other profiles they are hidden from
`tools/list` even when their facet is allowed.
## Next step
- To guard the whole instance (token/loopback/public):
[Authentication and access control](/operate/authentication-access-control/).
- For the full setting table behind `--profile` and friends:
[Configuration reference](/operate/configuration-reference/).