diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29b4274e..d11a178b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -293,12 +293,14 @@ jobs: # < 50ms") was already false for the SSVC pack this repo ships. The # benchmark now holds the numbers and this job enforces them. # - # Gating is on medians, and `--slack 1.5` multiplies every published target - # before comparing: this runner measured ~1.7x slower than a developer - # machine on the compilation case, so enforcing the published numbers here - # verbatim would fail on the hardware rather than on the code. A red bench is - # meant to mean "something got materially slower", never "the runner was - # busy". + # Gating is on medians, and `--slack 2.0` multiplies every published target + # before comparing: enforcing the published numbers on this hardware would + # fail on the runner rather than on the code. The factor is measured, not + # guessed. Five consecutive runs of this job put the single-rule median at + # 105, 118, 130, 166 and 166 microseconds against 89 on the developer + # machine the targets come from -- 1.2x to 1.9x. It was 1.5x here, which the + # two slowest of those runs walked straight through. A red bench is meant to + # mean "something got materially slower", never "the runner was busy". bench: needs: changes if: github.event_name == 'push' || needs.changes.outputs.code == 'true' @@ -316,4 +318,4 @@ jobs: run: uv sync - name: Benchmark - run: uv run python scripts/benchmark.py --slack 1.5 + run: uv run python scripts/benchmark.py --slack 2.0 diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 98ac703c..682d4f53 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -61,7 +61,7 @@ jobs: version: 9 - name: Install Python deps - run: uv sync --extra docs --extra server --extra mcp + run: uv sync --extra docs --extra server --extra mcp --extra attestation - name: Generate everything run: | diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 576f1f8f..79fab71a 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -4,11 +4,16 @@ name: docs # # docs REQUIRED (branch protection). Deterministic and fast: # version-sync + strict mkdocs build + docstring/SDK -# coverage + tutorial snippets. It does NOT regenerate -# artifacts, walk git history, or hit the network — all of -# which are nondeterministic across the squash-merge -# boundary or depend on an exact toolchain, and so must -# never gate a merge. +# coverage + tutorial snippets, plus source drift for the +# sources THIS pull request touched. It does NOT regenerate +# artifacts or hit the network — nondeterministic across the +# squash-merge boundary or dependent on an exact toolchain, +# and so must never gate a merge. The drift check is scoped +# to the branch's own edits for the same reason: a source +# edit ages every page citing it, and a whole-repo gate would +# fail the next unrelated pull request instead of the one +# that caused the drift. That whole-repo sweep is advisory, +# in docs-quality. # # docs-quality ADVISORY (not a required check; every step is # continue-on-error). Regenerates artifacts and runs the @@ -35,7 +40,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: + # full history: the scoped drift check diffs against the PR base and + # reads each cited source's last meaningful commit. - uses: actions/checkout@v7 + with: + fetch-depth: 0 - name: Install uv uses: astral-sh/setup-uv@v7 @@ -43,7 +52,7 @@ jobs: python-version: "3.13" - name: Install Python deps - run: uv sync --extra docs --extra server --extra mcp + run: uv sync --extra docs --extra server --extra mcp --extra attestation - name: Version sync run: uv run python scripts/check_version_sync.py @@ -55,6 +64,15 @@ jobs: - name: Build (strict) run: uv run mkdocs build --strict + # Every page citing a source this branch edited must have been + # re-verified in this branch. Pages citing sources the branch did not + # touch are somebody else's drift and are left to the advisory sweep. + - name: Source drift (sources this branch touched) + if: github.event_name == 'pull_request' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: uv run python scripts/check_doc_sources.py --changed-vs "$BASE_SHA" + - name: Docstring coverage run: uv run python scripts/check_docstrings.py @@ -81,7 +99,7 @@ jobs: python-version: "3.13" - name: Install Python deps - run: uv sync --extra docs --extra server --extra mcp + run: uv sync --extra docs --extra server --extra mcp --extra attestation # arduino/setup-protoc@v3 is unreliable (see ci.yml); install from the # upstream release archive. continue-on-error: a missing foreign @@ -132,7 +150,10 @@ jobs: echo "::warning::Generated docs artifacts are stale. Run 'make docs-gen && make docs-gen-foreign' and commit." fi - - name: Source drift (advisory) + # Whole-repo sweep: what has gone stale anywhere, including pages no + # open branch is touching. The blocking, branch-scoped check is in the + # required `docs` job. + - name: Source drift sweep (advisory) continue-on-error: true run: uv run python scripts/check_doc_sources.py diff --git a/.github/workflows/release-please-docs.yml b/.github/workflows/release-please-docs.yml index d5be20de..7a1fd811 100644 --- a/.github/workflows/release-please-docs.yml +++ b/.github/workflows/release-please-docs.yml @@ -40,7 +40,7 @@ jobs: python-version: "3.13" - name: Install Python deps - run: uv sync --extra docs --extra server --extra mcp + run: uv sync --extra docs --extra server --extra mcp --extra attestation # Same protoc install pattern as docs.yml (arduino/setup-protoc@v3 # is unreliable, see comment there). @@ -87,5 +87,9 @@ jobs: git config user.name "release-bot" git config user.email "release-bot@users.noreply.github.com" git add -A - git commit -m "chore(release): regenerate docs artifacts for version bump" + # -s: the DCO app checks every commit on the release PR, and + # this one is authored by release-bot. Signing off as that + # same identity satisfies it; without this the release PR + # needs a hand-written third-party remediation commit. + git commit -s -m "chore(release): regenerate docs artifacts for version bump" git push diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index f8d18d5e..bb56aebd 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -9,6 +9,12 @@ name: release-please # GITHUB_TOKEN cannot be used — PRs and tags it creates do not trigger # other workflows (CI would never run on the release PR, and the tag # push would never trigger pypi-publish). +# +# release-please authors its commits as the token's owner, so the +# `signoff` entry in release-please-config.json names that account. The +# DCO app matches a Signed-off-by line against the commit author; if the +# token is ever reissued to a different account, update that string too +# or every release PR comes up red. on: push: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2997dfb4..a78437d3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,6 +64,34 @@ Use the [feature request template](https://github.com/KrakenNet/fathom/issues/ne 4. Update documentation if you're changing public API 5. Open a pull request +### Changing a public surface + +`VERSIONING.md` lists what this project promises to keep working, and +`tests/test_public_surface.py` fails when that list and `fathom.__all__` drift +apart. Adding a name to `__all__` is a commitment, not a convenience — update +`VERSIONING.md` in the same change, and say in the changelog what a caller has +to do if you changed or removed one. + +### Documentation that cites your code + +Every page under `docs/` lists the files it describes in its frontmatter +`sources:`, with a `last_verified:` date. If your branch edits a file some page +cites, the required `docs` job fails until that page is re-verified **in the +same branch**: read the claims it makes about your file, correct the ones your +change made false, and set `last_verified:` to today. + +```bash +uv run python scripts/check_doc_sources.py --changed-vs origin/main # what CI gates +uv run python scripts/check_doc_sources.py # everything stale, anywhere +``` + +Only the sources your branch touched are gated. The whole-repo sweep is a +warning in the advisory `docs-quality` job — it is a maintenance list, not +your problem to clear. + +Line numbers in a citation (`src/fathom/engine.py` line 1179) move when the +file does; re-check them rather than only bumping the date. + ### Sign-off (DCO) Every commit must be signed off, certifying you wrote the change (or have the right to submit it) under the project license. Add the sign-off line by committing with `-s`: diff --git a/README.md b/README.md index 905c400e..597c0278 100644 --- a/README.md +++ b/README.md @@ -93,11 +93,17 @@ See the [Getting Started guide](docs/getting-started.md) for a full walkthrough. - **FastAPI REST server** with bearer-token auth and rule-path jailing - **gRPC server** with bearer-token auth (see `protos/fathom.proto`) - **MCP tool server** (`FathomMCPServer`) for agent discovery -- **LangChain adapter** callback handler +- **Framework adapters** — LangChain callback handler, CrewAI before-tool-call + hook, OpenAI Agents SDK tool guardrail, Google ADK before-tool callback. + Each is allowlist-only: the call proceeds when the decision is exactly + `allow`, and every other outcome raises `PolicyViolation` (ADK returns an + error dict instead) - **CLI** — `fathom validate`, `fathom compile`, `fathom test`, `fathom bench`, `fathom info`, `fathom status`, `fathom verify-artifact`, `fathom verify-chain`, `fathom repl` - **Docker sidecar** (Debian slim + uv) - **Prometheus metrics** export (`/metrics` endpoint) -- **Policy Studio** — FastAPI + HTMX UI that mounts the REST engine in-process under `/api` (`python -m fathom.studio.app`) +- **Policy Studio** — browser UI over a real engine, shipped as its own + package (`packages/fathom-studio/`, run with `uv run fathom-studio`). See + [Running Policy Studio](docs/how-to/policy-studio.md) **Rule packs** @@ -105,12 +111,19 @@ See the [Getting Started guide](docs/getting-started.md) for a full walkthrough. - `fathom-nist-800-53` — Access control, audit, information flow - `fathom-hipaa` — PHI handling, minimum necessary, breach triggers - `fathom-cmmc` — CMMC Level 2+ controls +- `fathom-ssvc` — SSVC supplier, deployer, and CISA vulnerability-triage trees (144 rules) **SDKs (in progress)** -- `fathom-go` — REST + gRPC client (`packages/fathom-go/`) -- `fathom-ts` — `@fathom-rules/sdk` (`packages/fathom-ts/`); hand-written client, 4 of 10 REST endpoints -- `fathom-editor` — React visual rule editor (`packages/fathom-editor/`); stub +- `fathom-go` — REST + gRPC client (`packages/fathom-go/`); unit and + integration suites run in CI, not yet published to a Go proxy +- `fathom-ts` — `@fathom-rules/sdk` (`packages/fathom-ts/`); hand-written + client covering 4 of the 10 REST endpoints, vitest suite required in CI, + not yet published to npm + +Scaffolds that are **not** usable yet — including the `fathom-editor` visual +rule editor — are catalogued in +[Planned Integrations](docs/reference/planned-integrations.md). ## Core Primitives @@ -175,6 +188,7 @@ Entry points: - [How-to Guides](docs/how-to/index.md) - [Concepts](docs/concepts/index.md) - [Reference](docs/reference/index.md) +- [Configuration](docs/reference/configuration.md) — every `FATHOM_*` variable and the gRPC TLS setup ## Performance Targets @@ -189,8 +203,10 @@ Measured by `scripts/benchmark.py` and enforced on every pull request by CI's `bench` job, which fails the build if a median regresses past its target. Compilation is stated per rule because it scales with pack size: the packaged SSVC pack is 144 rules. The numbers above are what the benchmark reports on a -developer machine; CI enforces them with a 1.5x allowance (`--slack 1.5`) -because a shared runner is roughly that much slower. +developer machine; CI enforces them with a 2x allowance (`--slack 2.0`) +because GitHub's shared runners measured 1.2x to 1.9x slower than that machine +across five consecutive runs of the same job. Run `python scripts/benchmark.py` +with no slack to hold your own hardware to the published numbers directly. ## Related Projects @@ -205,7 +221,7 @@ git clone https://github.com/KrakenNet/fathom.git cd fathom uv sync --all-extras # --all-extras is required for the full test suite -uv run pytest # 1695 tests +uv run pytest # engine, integrations, and Studio suites uv run ruff check src/ tests/ # lint uv run mypy src/ # type check uv run pytest --cov=fathom # coverage report @@ -220,6 +236,14 @@ uv run uvicorn fathom.integrations.rest:app --reload See [CONTRIBUTING.md](CONTRIBUTING.md) for full development guidelines and [CHANGELOG.md](CHANGELOG.md) for release notes. +## Stability + +Fathom is pre-1.0. [VERSIONING.md](VERSIONING.md) names the surfaces that are +covered — `fathom.__all__`, the YAML authoring keys, the REST/gRPC/MCP +contracts, and the CLI — states what a `0.x` minor and patch bump each mean for +them, and defines the deprecation period. The symbol list there is checked +against the package on every test run. + ## Star History diff --git a/SECURITY.md b/SECURITY.md index 47cd55bc..f6fd7661 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,15 +1,108 @@ # Security Policy -## Supported Versions +## Supported versions -| Version | Supported | -| ------- | ------------------ | -| 0.7.x | :white_check_mark: | +The latest released minor is supported. Security fixes ship as a new patch of +that minor and are not backported: pre-1.0, upgrading to the current minor is +the supported response to a fix. See +[VERSIONING.md](VERSIONING.md#supported-versions) for the full policy and what +a version bump means for compatibility. -## Reporting a Vulnerability +Check what is current on [PyPI](https://pypi.org/project/fathom-rules/) or in +[CHANGELOG.md](CHANGELOG.md). Every release from 0.5.0 onward is signed; see +[Verifying a signed release](docs/how-to/release-signing.md). -Use GitHub's private vulnerability reporting: Security tab → Advisories → Report a vulnerability +## Reporting a vulnerability -### Response Timeline +Report privately through GitHub: **Security → Advisories → Report a +vulnerability**, or directly at +. -Acknowledge 48h, assess 7 days, fix critical within 30 days +Do not open a public issue, a pull request, or a discussion for a suspected +vulnerability. A public report starts the disclosure clock before there is a +fix to disclose. + +Useful in a report: the version, the surface (library, REST, gRPC, MCP, +Studio), what an attacker gains, and the smallest input that reproduces it. A +ruleset or fact set that triggers the behaviour is worth more than a +description of it. + +### What happens next + +| Step | Target | +|---|---| +| Acknowledgement that the report was received | 2 business days | +| Assessment: in scope or not, and a severity | 7 days | +| Fix released for a critical or high finding | 30 days from assessment | +| Fix released for a moderate or low finding | Next scheduled minor | + +If a fix will take longer than 30 days, the advisory thread says so and why, +rather than going quiet. + +### Disclosure + +Fixes are coordinated: the advisory stays private until a fixed version is +published, then it is published as a GitHub Security Advisory with a CVE +requested through GitHub's CNA. The advisory names the affected versions, the +fixed version, and the reporter — tell us if you would rather not be credited, +or how you want to be named. + +The embargo runs until the fixed release is out, or **90 days** from +acknowledgement, whichever comes first. If a report is still unfixed at 90 +days, the reporter is free to disclose; we would rather publish an advisory +with a workaround than let a real issue sit unannounced. A vulnerability +already public, or under active exploitation, is not embargoed — it gets an +advisory as soon as it is confirmed, with whatever mitigation exists. + +## Scope + +### In scope + +- **The library** (`fathom` package) — including compiler injection: any + input to the YAML authoring surface that produces CLIPS constructs the + author did not write. +- **The REST server** (`fathom.integrations.rest`) — authentication, the + ruleset path jail, request size limits, and the hot-reload signature check. +- **The gRPC server** (`fathom.integrations.grpc_server`) — the same, plus TLS + configuration. +- **The MCP tool server** (`fathom.integrations.mcp_server`). +- **Hot reload** (`POST /v1/rules/reload` and the `Reload` RPC) — anything that + loads an unsigned or attacker-supplied ruleset where the configuration says + it should not. +- **Attestation and the audit log** — signature forgery, token replay across + sessions or inputs, and any way to alter a hash-chained log without + detection. +- **The release path** — the published wheels and their signatures, the + packaged public keys, and the workflows that produce them. +- **Policy Studio** (`packages/fathom-studio`) — authentication bypass, and + any path that lets a Studio request reach a file or a ruleset outside its + configured root. + +### Not in scope + +- **The `test:` conditional element and `type: raw` functions.** Both emit + author-written CLIPS verbatim and are documented escape hatches. A ruleset + author is trusted with the CLIPS environment; if untrusted parties author + rulesets in your deployment, the boundary you need is the signature check on + reload, not these. +- **`FATHOM_GRPC_ALLOW_INSECURE=1` and the unsigned-ruleset dev escape.** Both + require an explicit opt-in and both log what they disabled. Reports that + they are insecure describe their purpose. +- **Denial of service from a ruleset you deployed yourself.** Evaluation is + bounded by an activation budget and request bodies are capped; a ruleset + that is merely slow is a performance issue. +- **Missing hardening with no attack behind it** — a header that could be set, + a dependency that could be newer. Send a pull request or open an issue. +- **Findings in a dependency** with no Fathom-specific path to reach them. + Report those upstream; if Fathom's use makes an upstream issue exploitable + when it otherwise would not be, that is in scope and worth saying. +- **The visual editor** (`packages/fathom-editor`), which is a stub and is not + shipped. + +## Hardening the deployment + +Configuration that materially changes the attack surface — token scopes, the +ruleset path jail, request caps, gRPC TLS — is documented in +[Configuration](docs/reference/configuration.md). The reload security model, +including signature verification and key rotation, is in +[Hot-reloading rulesets](docs/how-to/hot-reload.md). diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 00000000..bef142ce --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,153 @@ +# Versioning and stability + +This document says which parts of Fathom you can build on, what a version +number change means for them, and how long a released version is supported. It +is enforced where it can be: `tests/test_public_surface.py` fails if the symbol +list below stops matching `fathom.__all__`. + +Fathom is pre-1.0. Read [Pre-1.0 semantics](#pre-10-semantics) before you pin. + +## Covered surfaces + +### Python: `fathom.__all__` + +These names are importable from the `fathom` package and are the covered +Python surface: + + +- `AssertSpec` +- `AssertedFact` +- `AttestationError` +- `AttestationService` +- `AuditLog` +- `AuditRecord` +- `AuditSink` +- `ChainedAttestationLog` +- `CompilationError` +- `Engine` +- `EvaluationError` +- `EvaluationLimitError` +- `EvaluationResult` +- `FactStore` +- `FathomError` +- `FileSink` +- `FleetConnectionError` +- `FleetEngine` +- `FleetError` +- `InMemoryFactStore` +- `NullSink` +- `ScopeError` +- `ValidationError` +- `__version__` +- `verify_chain` +- `verify_token` + + +`AttestationService`, `verify_token`, `ChainedAttestationLog`, and +`verify_chain` are resolved on first attribute access rather than at import, +because they need the optional `attestation` extra. Accessing one without that +extra installed raises `ImportError` naming the extra — it is not an +`AttributeError`, and the name is covered either way. + +Each of these submodules also declares its own `__all__`, which is covered on +the same terms when imported directly (`from fathom.models import +RuleDefinition`): + +| Module | What it holds | +|---|---| +| `fathom.engine` | `Engine`, and the `fathom-` prefix reserved for built-in CLIPS functions | +| `fathom.models` | The Pydantic models for the YAML authoring surface, the evaluation result, the audit record, and the REST request/response bodies | +| `fathom.errors` | The exception hierarchy, all rooted at `FathomError` | +| `fathom.audit` | The audit sink protocol and the sinks that ship with it | +| `fathom.fleet` | `FleetEngine`, the fact-store protocol, and the in-memory store | +| `fathom.attestation` | Ed25519 signing and token verification | +| `fathom.chained_log` | The hash-chained attestation log and its verifier | + +### Other covered surfaces + +- **The YAML authoring surface** — every key documented under + [`docs/reference/yaml/`](docs/reference/yaml/index.md): the fields each + construct accepts, the condition-expression grammar, and the operator set. +- **The REST API** — the paths, request bodies, and response bodies published + in [`docs/reference/rest/openapi.json`](docs/reference/rest/openapi.json). +- **The gRPC service** — the messages and RPCs in + [`protos/fathom.proto`](protos/fathom.proto). +- **The MCP tool surface** — the tool names and argument schemas in + [`docs/reference/mcp/manifest.json`](docs/reference/mcp/manifest.json). +- **The CLI** — the command names, their documented options, and their exit + codes. Human-readable output text is not covered; parse the JSON output + where a command offers it. +- **Environment variables** — every `FATHOM_*` variable documented in the + configuration reference. + +## Not covered + +Anything not named above is internal, and may change in any release without +notice or a changelog entry: + +- Modules with no `__all__`, and any name a module's `__all__` omits. +- Any name beginning with `_`, at any level. +- **The generated CLIPS text.** Construct layout, and in particular the + generated variable names (`?s__`, `?-`), are + compiler implementation. Use `bind:` to name a variable you need to + reference. +- Rule firing order beyond what salience and `focus_order` specify. +- Wall-clock timings, including the published performance targets. They are + measurements of an implementation on one machine, not a contract. +- Policy Studio (`packages/fathom-studio/`) and the visual editor + (`packages/fathom-editor/`). The Studio ships as its own package on its own + version line, and its routes — including the REST app it mounts under `/api` + — are not covered here. +- The Go and TypeScript SDKs, which are pre-release and version separately. +- Test helpers, fixtures, and everything under `tests/` and `scripts/`. + +## Pre-1.0 semantics + +Fathom is in the `0.x` series, and `0.x` is not semver's stable contract. What +this project does within it: + +- **A minor bump (`0.9.0` → `0.10.0`) may break a covered surface.** Breaking + changes are held to minor releases and never appear in a patch. This is + enforced in `release-please-config.json` by `bump-minor-pre-major`, which + routes a `BREAKING CHANGE:` commit to a minor rather than to `1.0.0`. +- **A patch bump (`0.9.0` → `0.9.1`) never breaks a covered surface.** Bug + fixes and additions only. +- **Every break is in the changelog**, under `### Changed` or `### Removed`, + with what to do instead. `CHANGELOG.md` is hand-written for exactly this + reason. + +Pin accordingly: `fathom-rules>=0.10,<0.11` is the pre-1.0 equivalent of a +caret range. `fathom-rules~=0.10.0` has the same effect. + +After 1.0 this becomes ordinary semver: breaking changes only in a major. + +## Deprecation + +A covered name is never removed without a deprecation period first: + +1. The name keeps working and starts emitting `DeprecationWarning`, naming its + replacement. +2. The changelog entry for that release records the deprecation. +3. Removal comes no earlier than the **second** minor release after the + warning first shipped — so a name deprecated in `0.10.0` may not be removed + before `0.12.0`. + +A name that never worked (it raised on every call, or was documented but not +implemented) can be removed without this period; the changelog says so. + +## Supported versions + +The latest released minor is supported. Fixes — including security fixes — are +released as a new patch of that minor, not backported to earlier ones. Pre-1.0 +that is the whole support window; upgrading to the current minor is the +supported response to a fix. + +See [SECURITY.md](SECURITY.md) for how to report a vulnerability and what +happens after you do. + +## Verifying what you installed + +Releases are signed. `fathom verify-artifact` checks a downloaded artifact +against the project's minisign key; see +[Release signing](docs/how-to/release-signing.md) for the key and the +verification steps. diff --git a/docs/concepts/audit-attestation.md b/docs/concepts/audit-attestation.md index 9cd3c5c8..f4fd75e2 100644 --- a/docs/concepts/audit-attestation.md +++ b/docs/concepts/audit-attestation.md @@ -4,9 +4,10 @@ summary: Why every Fathom evaluation is recorded, and how the optional Ed25519 J audience: [app-developers, rule-authors] diataxis: explanation status: stable -last_verified: 2026-06-05 +last_verified: 2026-08-20 sources: - src/fathom/audit.py + - src/fathom/chained_log.py - src/fathom/attestation.py - src/fathom/engine.py - src/fathom/models.py @@ -74,6 +75,7 @@ class AuditRecord(BaseModel): duration_us: int metadata: dict[str, str] = Field(default_factory=dict) asserted_facts: list[AssertedFact] | None = None + attestation_token: str | None = None ``` Field by field: @@ -99,6 +101,10 @@ Field by field: decision's rule. - **`asserted_facts`** — populated only when at least one loaded rule declares an RHS `asserts` block (see below). +- **`attestation_token`** — the JWT signed for this same evaluation, copied + onto the record so an exported line can be checked without the caller's + copy of the token. `None` on an engine constructed without an + `attestation_service`. Records are written one-per-line as JSON. JSON Lines is trivially grep-able, `jq`-able, and concatenatable; it's what most log aggregators expect. @@ -118,13 +124,29 @@ class AuditSink(Protocol): def write(self, record: AuditRecord) -> None: ... ``` -Two implementations ship with Fathom: +Three implementations ship with Fathom: - **`FileSink(path)`** — writes `record.model_dump_json() + "\n"` to the given file in append mode. The constructor creates parent directories and `touch`es the file, so pointing it at a fresh path Just Works. - **`NullSink`** — `write()` is a no-op. This is the default when you construct an `Engine` without passing `audit_sink`. +- **`ChainedAttestationLog(path, service)`** (`fathom.chained_log`) — the + same JSON Lines shape, but each line is signed and commits to the hash of + the line before it, so deleting or reordering entries is detectable and + not just discouraged. It needs the `attestation` extra and a signing + service: + + ```python + from fathom import Engine + from fathom.attestation import AttestationService + from fathom.chained_log import ChainedAttestationLog + + log = ChainedAttestationLog("/var/log/fathom/audit.jsonl", AttestationService.generate_keypair()) + engine = Engine(audit_sink=log) + ``` + + Verify it later with `fathom verify-chain --pubkey .pub.pem`. Anything satisfying the protocol is a valid sink. A production deployment might write to S3, publish to Kafka, call out to syslog, or fan out to @@ -153,26 +175,38 @@ real sink; everything else keeps working with zero ceremony. ## What gets recorded when -The recording happens inside `Engine.evaluate()`. The sequence: +The recording happens inside `Engine.evaluate()`, under the engine's +re-entrant lock, so a concurrent caller cannot interleave its own facts into +the snapshots below. The sequence: 1. **Pre-snapshot user facts** — but only if `self._has_asserting_rules` is true. That flag is set at load time when any compiled rule declares a non-empty `asserts` block. If no loaded rule can assert new facts, the snapshot is skipped entirely — there's nothing to diff against. -2. **Run inference** — `self._evaluator.evaluate()` returns an +2. **Snapshot input facts** — `self._snapshot_input_facts()` captures the + caller-supplied working memory the decision is about to be computed over. + It is taken only when an `attestation_service` is configured or the audit + log is recording, because it costs a query per template and nothing else + consumes it. This snapshot is what `input_hash` binds (see + [Attestation as signed proof](#attestation-as-signed-proof)) and what + `log: full` records. +3. **Run inference** — `self._evaluator.evaluate()` returns an `EvaluationResult` with `decision`, `reason`, `rule_trace`, - `module_trace`, and `duration_us`. -3. **Sign, if configured** — if the engine was constructed with an - `attestation_service`, call `sign(result, self._session_id)` and store the - returned JWT on `result.attestation_token`. -4. **Diff pre/post snapshots** — a second `_snapshot_user_facts()` call, + `module_trace`, and `duration_us`, plus the effective log level. +4. **Sign, if configured** — if the engine was constructed with an + `attestation_service`, call + `sign(result, self._session_id, input_facts=...)` and store the returned + JWT on `result.attestation_token`. The input facts are not optional here: + `sign` refuses `None`, because a token signed without inputs binds + nothing. +5. **Diff pre/post snapshots** — a second `_snapshot_user_facts()` call, differenced against the pre-snapshot, yields the facts the rules asserted during this evaluation. Order is preserved from the post snapshot; equality is keyed on `(template, sorted(slots.items()))`. -5. **Record** — `self._audit_log.record(result, session_id, - asserted_facts=...)` constructs the `AuditRecord` and hands it to the - sink. -6. **Metrics** — `self._metrics.record_evaluation(...)` runs in a `finally` +6. **Record** — `self._audit_log.record(result, session_id, + input_facts=..., asserted_facts=..., log_level=...)` constructs the + `AuditRecord` and hands it to the sink. +7. **Metrics** — `self._metrics.record_evaluation(...)` runs in a `finally` so metrics are updated even if recording raised. Two things worth flagging: @@ -181,10 +215,11 @@ Two things worth flagging: and also when asserting rules exist but none fired. An empty list is collapsed to `None`, so the record distinguishes "didn't try to capture this" from "captured nothing." -- Signing happens *before* the audit record is written. The JWT ends up - on the `EvaluationResult` the caller receives but is **not** one of the - `AuditRecord` fields — the log records the decision; the token is - returned to the caller to store or forward separately. +- Signing happens *before* the audit record is written, which is what lets + the record carry the token: the JWT is set on the `EvaluationResult` in + step 4 and copied onto `AuditRecord.attestation_token` in step 6. The + caller gets the same token on the result to forward separately if it + wants to. ## Attestation as signed proof @@ -271,9 +306,20 @@ What audit + attestation *do* protect against: - **Disputes about what was decided.** A signed `decision` and `rule_trace` pin down the answer and the rules that produced it. -- **Tampering with exported logs.** An attacker who modifies an audit line - after export can't re-sign it without the private key; `verify_token` - fails. +- **Tampering with the decision in an exported log.** Each line carries the + JWT signed for that evaluation, and the payload commits to `decision`, + `rule_trace`, `session_id`, `iat`, and `input_hash`. Change any of those + in the line and it no longer matches the claims `verify_token` returns; + re-signing needs the private key. + + What this does **not** cover: `reason`, `metadata`, `duration_us`, + `input_facts`, and `asserted_facts` sit outside the signed payload, so a + line whose token verifies is not thereby proof that those fields are + untouched. Nor does a per-line signature detect a line being *deleted* — + for that you need order and continuity, which is what + `fathom.chained_log.ChainedAttestationLog` and `fathom verify-chain` + provide: each entry commits to the hash of the one before it, so a + removed or reordered entry breaks the chain. - **Input substitution.** The `input_hash` commits the token to a specific set of facts — template name and slot values both. Swap a fact, change a slot, or move a fact to a different template, and the hash stops matching. diff --git a/docs/concepts/five-primitives.md b/docs/concepts/five-primitives.md index 4799e32b..05b2dbd2 100644 --- a/docs/concepts/five-primitives.md +++ b/docs/concepts/five-primitives.md @@ -4,7 +4,7 @@ summary: Templates, Facts, Rules, Modules, Functions — the author-level vocabu audience: [rule-authors, app-developers] diataxis: explanation status: stable -last_verified: 2026-06-05 +last_verified: 2026-08-20 sources: - src/fathom/models.py - src/fathom/compiler.py @@ -23,7 +23,7 @@ shortest path to a working mental model of the engine. The rest of this page walks the five in the order the engine loads them (`templates → modules → functions → rules` — see -`src/fathom/engine.py`, `Engine.from_rules`, lines 452–489) and closes with how +`src/fathom/engine.py`, `Engine.from_rules`, lines 496–570) and closes with how they fit together at evaluate time. ## Templates diff --git a/docs/concepts/not-in-v1.md b/docs/concepts/not-in-v1.md index 499a91ed..6ef508e3 100644 --- a/docs/concepts/not-in-v1.md +++ b/docs/concepts/not-in-v1.md @@ -4,7 +4,7 @@ summary: The CLIPS features Fathom deliberately omits from its authored surface audience: [rule-authors, app-developers] diataxis: explanation status: stable -last_verified: 2026-06-05 +last_verified: 2026-08-20 sources: - src/fathom/compiler.py - src/fathom/models.py diff --git a/docs/concepts/runtime-and-working-memory.md b/docs/concepts/runtime-and-working-memory.md index b1bf890c..e714a801 100644 --- a/docs/concepts/runtime-and-working-memory.md +++ b/docs/concepts/runtime-and-working-memory.md @@ -4,7 +4,7 @@ summary: How the Fathom engine runs — sessions, the evaluation loop, module fo audience: [rule-authors, app-developers] diataxis: explanation status: stable -last_verified: 2026-08-19 +last_verified: 2026-08-20 sources: - src/fathom/engine.py - src/fathom/evaluator.py @@ -138,6 +138,14 @@ when no rules in it can fire, the next module in the list gets its turn. The first module in `focus_order` is the first to run, and `_setup_focus_stack()` emits the list unchanged to say so. +`focus_order` comes from the `focus_order:` key in a module YAML file, or +from `Engine.set_focus(...)`. If a pack declares modules and sets neither, +`Engine.load_modules` focuses every declared module in declaration order — +without that fallback the focus list is empty, no module's agenda is ever +drained, and the caller gets the default decision back from a pack whose +rules all matched. Declaration order is a floor, not a design: state +`focus_order:` whenever the sequence matters. + The practical upshot is that modules are a coarse-grained ordering tool. If `policy` comes before `logging` in the focus order, every activatable `policy` rule fires before any `logging` rule gets a chance, diff --git a/docs/concepts/yaml-compilation.md b/docs/concepts/yaml-compilation.md index 25f2b4ba..2c3ac10e 100644 --- a/docs/concepts/yaml-compilation.md +++ b/docs/concepts/yaml-compilation.md @@ -4,7 +4,7 @@ summary: How authored YAML becomes CLIPS source — parse, validate, compile — audience: [rule-authors, app-developers] diataxis: explanation status: stable -last_verified: 2026-06-05 +last_verified: 2026-08-20 sources: - src/fathom/compiler.py - src/fathom/models.py @@ -102,9 +102,10 @@ allowed-value directives come from `_CLIPS_TYPE_MAP` and (defmodule governance (import MAIN ?ALL)) ``` -Emitted by `compile_module`. `ModuleDefinition` carries a `priority` -integer consumed by focus-stack ordering at load time, not serialized -into the CLIPS source. +Emitted by `compile_module`. `ModuleDefinition` also carries a `priority` +integer, which is neither serialized into the CLIPS source nor read by the +runtime: focus order comes from the `focus_order:` key, or from declaration +order when that key is absent (see [Module](../reference/yaml/module.md)). `RuleDefinition` → a `defrule` qualified by its owning module: diff --git a/docs/getting-started.md b/docs/getting-started.md index b4a47779..15004bc1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -5,7 +5,7 @@ sources: - src/fathom/engine.py - src/fathom/evaluator.py - src/fathom/models.py -last_verified: 2026-07-07 +last_verified: 2026-08-20 --- # Fathom -- Getting Started diff --git a/docs/how-to/cli.md b/docs/how-to/cli.md index 8834088d..80e01487 100644 --- a/docs/how-to/cli.md +++ b/docs/how-to/cli.md @@ -4,7 +4,7 @@ summary: One-line purpose + worked example for every fathom CLI command. audience: [app-developers, rule-authors] diataxis: how-to status: stable -last_verified: 2026-06-05 +last_verified: 2026-08-20 sources: - src/fathom/cli.py --- @@ -56,6 +56,13 @@ its own line. fathom compile examples/02-rbac-modules --format pretty ``` +Literals are emitted according to the declared slot type, so `compile` needs +the templates in view: pointed at a directory it reads them from the pack, and +pointed at a single file under `rules/` it picks up the sibling `templates/` +directory. Without that context a `string` slot's literal is emitted bare — +CLIPS rejects that form with `[CSTRNCHK1]`, so the printed constructs would be +ones the engine never builds and that do not load. + ## info Load a rule pack and print a summary of everything the engine sees: diff --git a/docs/how-to/embed-sdk.md b/docs/how-to/embed-sdk.md index b5bd6350..a563f006 100644 --- a/docs/how-to/embed-sdk.md +++ b/docs/how-to/embed-sdk.md @@ -4,7 +4,7 @@ summary: Embed Fathom in Python (in-process Engine), Go, or TypeScript (both HTT audience: [app-developers] diataxis: how-to status: stable -last_verified: 2026-06-06 +last_verified: 2026-08-20 sources: - src/fathom/engine.py - src/fathom/integrations/rest.py diff --git a/docs/how-to/fastapi.md b/docs/how-to/fastapi.md index c7ca0e8b..fa12c709 100644 --- a/docs/how-to/fastapi.md +++ b/docs/how-to/fastapi.md @@ -4,7 +4,7 @@ summary: Mount the bundled Fathom REST app or wrap Engine directly to add rule e audience: [app-developers] diataxis: how-to status: stable -last_verified: 2026-06-06 +last_verified: 2026-08-20 sources: - src/fathom/integrations/rest.py - src/fathom/engine.py diff --git a/docs/how-to/hot-reload.md b/docs/how-to/hot-reload.md index 0f2dec6c..fe2912eb 100644 --- a/docs/how-to/hot-reload.md +++ b/docs/how-to/hot-reload.md @@ -4,7 +4,7 @@ summary: Sign a ruleset, POST it to /v1/rules/reload, understand the fail-closed audience: [operators] diataxis: how-to status: stable -last_verified: 2026-06-06 +last_verified: 2026-08-20 sources: - src/fathom/integrations/rest.py - src/fathom/integrations/grpc_server.py @@ -41,9 +41,21 @@ cleared with them. A failed reload changes nothing: the old environment keeps serving, so working memory survives. Only a *successful* swap wipes it. -Plan for this. Re-assert startup facts from a reload listener, re-run -`FleetEngine.sync_fleet_facts` for fleet-scoped state, and expect session -callers to re-seed anything they asserted before the reload. +Plan for this. `Engine.subscribe_reload` is the seam: it registers a +zero-argument callback fired after each successful swap, outside the reload +lock, and returns an unsubscribe callable. A listener that raises is logged and +swallowed, so a wedged subscriber cannot break the reload. + +```python +def reseed() -> None: + engine.assert_fact("tenant", {"id": "acme", "tier": "gold"}) + +unsubscribe = engine.subscribe_reload(reseed) +``` + +Re-run `FleetEngine.sync_fleet_facts` from the same callback for fleet-scoped +state, and expect session callers to re-seed anything they asserted before the +reload. This how-to covers how to sign a ruleset, wire up the deployment config, use the dev escape during development, monitor the live diff --git a/docs/how-to/index.md b/docs/how-to/index.md index 9c196a65..70840093 100644 --- a/docs/how-to/index.md +++ b/docs/how-to/index.md @@ -15,3 +15,6 @@ last_verified: 2026-04-15 - [Registering a Python function](register-function.md) - [Loading a rule pack](load-rule-pack.md) - [Embedding via SDK](embed-sdk.md) +- [Hot-reloading rulesets in a running server](hot-reload.md) +- [Verifying a signed release](release-signing.md) +- [Running Policy Studio](policy-studio.md) diff --git a/docs/how-to/load-rule-pack.md b/docs/how-to/load-rule-pack.md index 73834420..44c58b8b 100644 --- a/docs/how-to/load-rule-pack.md +++ b/docs/how-to/load-rule-pack.md @@ -4,7 +4,7 @@ summary: Load rules from a directory or a distributed Python package via Engine. audience: [app-developers, rule-authors] diataxis: how-to status: stable -last_verified: 2026-07-07 +last_verified: 2026-08-20 sources: - src/fathom/engine.py - src/fathom/packs.py diff --git a/docs/how-to/policy-studio.md b/docs/how-to/policy-studio.md new file mode 100644 index 00000000..29586650 --- /dev/null +++ b/docs/how-to/policy-studio.md @@ -0,0 +1,95 @@ +--- +title: Running Policy Studio +summary: Start the Studio from a checkout, get past its token gate, and know what its five views drive — the real engine, mounted in the same process. +audience: [app-developers, rule-authors] +diataxis: how-to +status: stable +sources: + - packages/fathom-studio/src/fathom_studio/app.py + - packages/fathom-studio/src/fathom_studio/auth.py + - packages/fathom-studio/src/fathom_studio/studio_api.py + - packages/fathom-studio/src/fathom_studio/scenarios.py + - packages/fathom-studio/pyproject.toml +last_verified: 2026-08-20 +--- + +# Running Policy Studio + +Policy Studio is a local browser UI over a real Fathom engine: load a ruleset, +push facts at it, and read the decision, the rules that fired, and the audit +line that came out. It is a **separate package** — `fathom-studio`, its own +version line — because the engine wheel ships no UI code. + +## Install and run + +`fathom-studio` is not published to PyPI yet. Run it from a checkout, where +it is a workspace member and `uv sync` already installs it: + +```bash +git clone https://github.com/KrakenNet/fathom.git +cd fathom +uv sync + +export FATHOM_API_TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')" +uv run fathom-studio +``` + +The process prints the URL to open and binds `127.0.0.1:8020`. `--host` and +`--port` override the bind; `FATHOM_STUDIO_PORT` sets the port from the +environment. + +Open it as `http://127.0.0.1:8020/?token=$FATHOM_API_TOKEN` **once**. The +token is validated and stored in the `fathom_token` cookie, which the SPA's +`fetch` calls and the plain HTML forms then carry for you. + +## The token gate + +The Studio mounts the production REST app in the same process, so it holds +itself to the same door: every route that loads a ruleset, drives the engine, +or mints an attestation token requires the same `FATHOM_API_TOKEN` the REST +app requires. There is no second secret. Present it as either +`Authorization: Bearer ` (for `curl` and tests) or the cookie above. + +Only three things are ungated, and none of them carry engine data: `/health`, +the SPA shell at `/`, and its static assets under `/creem`. With +`FATHOM_API_TOKEN` unset, every gated route answers 401 — an unconfigured +Studio exposes nothing. + +## What the views do + +| View | What it drives | +|---|---| +| Reasoning Bench | Loads a bundled scenario, evaluates it, and shows decision, reason, and the firing rules | +| Live Wire | A rolling decision stream and deny-rate strip. The traffic is synthetic — it samples the bundled scenarios — but every packet is a real evaluation | +| Rules | The compiled rules of the selected ruleset, with their conditions | +| Templates | The fact schemas the ruleset declares | +| Audit | The signed, hash-linked record of what the Studio has evaluated so far | + +Nine demo scenarios ship with the package, covering five example rulesets: +hello allow/deny, RBAC with modules, Bell-LaPadula classification, temporal +anomaly detection, and LangChain guardrails. Each is a copy of the matching +`examples/0N-*` directory, held byte-identical to it by a test. Point +`FATHOM_RULESET_ROOT` at your own directory to drive the Studio with your +rulesets instead. + +Decisions are never faked. `POST /studio/api/evaluate` builds a fresh +`Engine` for the chosen ruleset, asserts the facts, evaluates, and renders +what came back — the same stateless shape `/v1/evaluate` uses, so working +memory never leaks from one bench run into the next. + +## What it is not + +- **Not a sandbox.** The REST app is *mounted*, not proxied: it runs in the + Studio's process, under `/api`, sharing that module's in-memory session + store. Anything driven through `/api` — including the server-rendered + panels, which call it with a per-browser `X-Session-Id` — lands in the same + working memory a REST client of that process sees. +- **Not a stable API.** The `/studio/api/*` routes are unversioned and + excluded from [the compatibility policy](https://github.com/KrakenNet/fathom/blob/main/VERSIONING.md); + they change with the UI. +- **Not your audit log.** The Audit view's chain lives in process memory, + holds the most recent 200 records, and is signed with a keypair generated + at startup — it dies with the process. For a durable, verifiable log, use + `ChainedAttestationLog` from the library + ([Audit & Attestation](../concepts/audit-attestation.md)). +- **Not hardened for exposure.** It binds loopback by default. Keep it there. diff --git a/docs/how-to/register-function.md b/docs/how-to/register-function.md index aa685494..c22e3fc6 100644 --- a/docs/how-to/register-function.md +++ b/docs/how-to/register-function.md @@ -4,7 +4,7 @@ summary: Expose a Python callable to CLIPS rules via Engine.register_function an audience: [app-developers, rule-authors] diataxis: how-to status: stable -last_verified: 2026-06-05 +last_verified: 2026-08-20 sources: - src/fathom/engine.py - src/fathom/models.py diff --git a/docs/how-to/writing-rules.md b/docs/how-to/writing-rules.md index 101f79e9..489eb162 100644 --- a/docs/how-to/writing-rules.md +++ b/docs/how-to/writing-rules.md @@ -4,7 +4,7 @@ summary: How to author a Fathom ruleset in YAML, covering patterns, conditions, audience: [rule-authors] diataxis: how-to status: stable -last_verified: 2026-06-05 +last_verified: 2026-08-20 sources: - src/fathom/models.py - src/fathom/compiler.py @@ -34,7 +34,7 @@ rules: - template: agent conditions: - slot: clearance - expression: unclassified + expression: equals(unclassified) then: action: deny reason: "Clearance insufficient" @@ -60,16 +60,16 @@ fact type in working memory. ```yaml when: - template: data-request # name of the deftemplate to match - alias: req # optional — bind the whole fact to a CLIPS variable + alias: $req # optional — names this pattern for $req.slot references conditions: - slot: action - expression: read + expression: equals(read) ``` | Field | Required | Notes | |-------|----------|-------| | `template` | yes | Must match a loaded `TemplateDefinition` name | -| `alias` | no | When set, the compiler emits `?alias <- (template ...)` | +| `alias` | no | `$`-prefixed name for this pattern, referenced from another pattern's expression as `$alias.slot`. Must not be `$p`, and no two patterns in a rule may share one | | `conditions` | yes | List of `ConditionEntry` objects | A rule with two `when` entries fires only when **both** facts exist simultaneously in @@ -77,26 +77,31 @@ working memory — CLIPS evaluates the conjunction. ## `ConditionEntry` fields -`ConditionEntry` (`src/fathom/models.py`, lines 105–171) represents one slot constraint +`ConditionEntry` (`src/fathom/models.py`, line 203) represents one slot constraint inside a `FactPattern`. At least one of `expression`, `bind`, or `test` must be present. ### `slot` + `expression`: value match -Use `expression` to require an exact slot value (compiled to a CLIPS equality -constraint). +An `expression` is always one `operator(argument)` form — a bare value is +rejected by the model, not silently treated as `equals`. ```yaml conditions: - slot: status - expression: active + expression: equals(active) ``` -`slot` is required when `expression` is set. +`slot` is required when `expression` is set. For the full operator list, the +argument forms (`$alias.slot` cross-references, `[a, b]` lists, literals), and +how a literal is emitted for each slot type, see the +[Rule reference](../reference/yaml/rule.md). ### `slot` + `bind`: capture a value Use `bind` to capture a slot value into a CLIPS variable for use in other conditions or -the `then` block. The value **must start with `?`** — the validator rejects anything else. +the `then` block. The value must be `?` followed by a CLIPS identifier +(`^\?[A-Za-z_][A-Za-z0-9_\-]*$`) — the validator rejects anything else, because the +name is interpolated straight into the generated `defrule`. ```yaml conditions: @@ -131,13 +136,19 @@ conditions: **Validator rules enforced by `ConditionEntry`:** -- `bind` must start with `?` — e.g. `?sid`, not `sid`. +- `bind` must be `?` followed by a CLIPS identifier — e.g. `?sid`, not `sid` and + not `?a b`. +- `expression` must be a single `operator(argument)` form — e.g. `equals(active)`, + not `active`. - `test` must be a parenthesized CLIPS expression — e.g. `(my-fn ?sid)`. - `slot` must be present when `expression` or `bind` is set. - Setting `slot` alongside `test` alone (no `expression` or `bind`) is **rejected** — the compiler has no slot position to emit; either add `expression`/`bind` or drop `slot`. - At least one of `expression`, `bind`, or `test` must be set. +- Any key other than `slot`, `expression`, `bind`, and `test` is rejected. This + holds for every model on this page: a misspelled field is an error, never a + silently ignored line. ## `then` block: `ThenBlock` diff --git a/docs/index.md b/docs/index.md index 3f705261..439a4f68 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ sources: - pyproject.toml - src/fathom/__init__.py - README.md -last_verified: 2026-08-19 +last_verified: 2026-08-20 --- # Fathom @@ -41,6 +41,9 @@ quadrant that matches what you're doing. - [Registering a Python function](how-to/register-function.md) - [Loading a rule pack](how-to/load-rule-pack.md) - [Embedding via SDK](how-to/embed-sdk.md) +- [Hot-reloading rulesets](how-to/hot-reload.md) +- [Verifying a signed release](how-to/release-signing.md) +- [Running Policy Studio](how-to/policy-studio.md) ### Understand — Concepts @@ -52,6 +55,7 @@ quadrant that matches what you're doing. ### Look up — Reference +- [Configuration](reference/configuration.md) — every `FATHOM_*` variable - YAML: [Template](reference/yaml/template.md) · [Rule](reference/yaml/rule.md) · [Module](reference/yaml/module.md) · @@ -67,6 +71,13 @@ quadrant that matches what you're doing. - [Rule Packs](reference/rule-packs/owasp-agentic.md) - [Planned Integrations](reference/planned-integrations.md) +## Stability + +Fathom is pre-1.0. `fathom.__all__` is the covered public surface, and +[VERSIONING.md](https://github.com/KrakenNet/fathom/blob/main/VERSIONING.md) +states what a `0.x` minor and patch each mean for it, the YAML keys, the +REST/gRPC/MCP contracts, and the CLI. + ## What is not in v1 Fathom v1 is forward-chaining only and deliberately narrow. Backward chaining, diff --git a/docs/llms-full.txt b/docs/llms-full.txt index a7f975d1..10f08687 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -34,6 +34,9 @@ quadrant that matches what you're doing. - [Registering a Python function](how-to/register-function.md) - [Loading a rule pack](how-to/load-rule-pack.md) - [Embedding via SDK](how-to/embed-sdk.md) +- [Hot-reloading rulesets](how-to/hot-reload.md) +- [Verifying a signed release](how-to/release-signing.md) +- [Running Policy Studio](how-to/policy-studio.md) ### Understand — Concepts @@ -45,6 +48,7 @@ quadrant that matches what you're doing. ### Look up — Reference +- [Configuration](reference/configuration.md) — every `FATHOM_*` variable - YAML: [Template](reference/yaml/template.md) · [Rule](reference/yaml/rule.md) · [Module](reference/yaml/module.md) · @@ -60,6 +64,13 @@ quadrant that matches what you're doing. - [Rule Packs](reference/rule-packs/owasp-agentic.md) - [Planned Integrations](reference/planned-integrations.md) +## Stability + +Fathom is pre-1.0. `fathom.__all__` is the covered public surface, and +[VERSIONING.md](https://github.com/KrakenNet/fathom/blob/main/VERSIONING.md) +states what a `0.x` minor and patch each mean for it, the YAML keys, the +REST/gRPC/MCP contracts, and the CLI. + ## What is not in v1 Fathom v1 is forward-chaining only and deliberately narrow. Backward chaining, @@ -446,7 +457,10 @@ focus_order: The optional `focus_order` list tells the engine which modules to activate and in what order: the first name runs first. It maps straight onto the CLIPS `(focus ...)` command, which gives the focus to its first argument and queues -the rest behind it. +the rest behind it. Omit it and the engine focuses every declared module in +declaration order, which keeps a pack working but leaves the order to depend +on how the files happen to be written — write it out whenever the sequence +carries meaning. Every listed module runs — an earlier one does not short-circuit the ones after it. Because decisions are last-write-wins, the module listed last is @@ -801,6 +815,9 @@ constructs. `clear_facts()` and `reset()` only affect data, not compiled constru - [Registering a Python function](register-function.md) - [Loading a rule pack](load-rule-pack.md) - [Embedding via SDK](embed-sdk.md) +- [Hot-reloading rulesets in a running server](hot-reload.md) +- [Verifying a signed release](release-signing.md) +- [Running Policy Studio](policy-studio.md) ## how-to/writing-rules.md @@ -829,7 +846,7 @@ rules: - template: agent conditions: - slot: clearance - expression: unclassified + expression: equals(unclassified) then: action: deny reason: "Clearance insufficient" @@ -855,16 +872,16 @@ fact type in working memory. ```yaml when: - template: data-request # name of the deftemplate to match - alias: req # optional — bind the whole fact to a CLIPS variable + alias: $req # optional — names this pattern for $req.slot references conditions: - slot: action - expression: read + expression: equals(read) ``` | Field | Required | Notes | |-------|----------|-------| | `template` | yes | Must match a loaded `TemplateDefinition` name | -| `alias` | no | When set, the compiler emits `?alias <- (template ...)` | +| `alias` | no | `$`-prefixed name for this pattern, referenced from another pattern's expression as `$alias.slot`. Must not be `$p`, and no two patterns in a rule may share one | | `conditions` | yes | List of `ConditionEntry` objects | A rule with two `when` entries fires only when **both** facts exist simultaneously in @@ -872,26 +889,31 @@ working memory — CLIPS evaluates the conjunction. ## `ConditionEntry` fields -`ConditionEntry` (`src/fathom/models.py`, lines 105–171) represents one slot constraint +`ConditionEntry` (`src/fathom/models.py`, line 203) represents one slot constraint inside a `FactPattern`. At least one of `expression`, `bind`, or `test` must be present. ### `slot` + `expression`: value match -Use `expression` to require an exact slot value (compiled to a CLIPS equality -constraint). +An `expression` is always one `operator(argument)` form — a bare value is +rejected by the model, not silently treated as `equals`. ```yaml conditions: - slot: status - expression: active + expression: equals(active) ``` -`slot` is required when `expression` is set. +`slot` is required when `expression` is set. For the full operator list, the +argument forms (`$alias.slot` cross-references, `[a, b]` lists, literals), and +how a literal is emitted for each slot type, see the +[Rule reference](../reference/yaml/rule.md). ### `slot` + `bind`: capture a value Use `bind` to capture a slot value into a CLIPS variable for use in other conditions or -the `then` block. The value **must start with `?`** — the validator rejects anything else. +the `then` block. The value must be `?` followed by a CLIPS identifier +(`^\?[A-Za-z_][A-Za-z0-9_\-]*$`) — the validator rejects anything else, because the +name is interpolated straight into the generated `defrule`. ```yaml conditions: @@ -926,13 +948,19 @@ conditions: **Validator rules enforced by `ConditionEntry`:** -- `bind` must start with `?` — e.g. `?sid`, not `sid`. +- `bind` must be `?` followed by a CLIPS identifier — e.g. `?sid`, not `sid` and + not `?a b`. +- `expression` must be a single `operator(argument)` form — e.g. `equals(active)`, + not `active`. - `test` must be a parenthesized CLIPS expression — e.g. `(my-fn ?sid)`. - `slot` must be present when `expression` or `bind` is set. - Setting `slot` alongside `test` alone (no `expression` or `bind`) is **rejected** — the compiler has no slot position to emit; either add `expression`/`bind` or drop `slot`. - At least one of `expression`, `bind`, or `test` must be set. +- Any key other than `slot`, `expression`, `bind`, and `test` is rejected. This + holds for every model on this page: a misspelled field is an error, never a + silently ignored line. ## `then` block: `ThenBlock` @@ -1214,6 +1242,13 @@ its own line. fathom compile examples/02-rbac-modules --format pretty ``` +Literals are emitted according to the declared slot type, so `compile` needs +the templates in view: pointed at a directory it reads them from the pack, and +pointed at a single file under `rules/` it picks up the sibling `templates/` +directory. Without that context a `string` slot's literal is emitted bare — +CLIPS rejects that form with `[CSTRNCHK1]`, so the printed constructs would be +ones the engine never builds and that do not load. + ## info Load a rule pack and print a summary of everything the engine sees: @@ -1952,6 +1987,619 @@ removes the server from the deployment diagram entirely. — typedoc-generated client, interface, and error reference. +## how-to/hot-reload.md + +# Hot-reloading rulesets in a running server + +Fathom can swap a running server's ruleset atomically, without a +restart, via `POST /v1/rules/reload`. Each reload: + +- Compiles the new YAML in a fresh CLIPS environment and only swaps it + in on success — a bad ruleset leaves the previous one serving + traffic (NFR-8). +- Verifies an Ed25519 signature over the raw YAML bytes before + accepting the payload — unsigned rulesets are **rejected by default** + (fail-closed, AC-5.5). +- Emits a JWT-attested audit event (`ruleset_reloaded` on success, + `ruleset_reload_rejected` on failure) carrying the + `ruleset_hash_before`/`ruleset_hash_after` pair. + +## Warning: a reload discards all working memory + +The new ruleset is compiled into a **fresh** CLIPS environment, and that +environment starts empty. Every fact in working memory at the moment of the +swap is gone — session facts, fleet facts synced from the shared store, and any +long-lived facts the caller asserted at startup. Fact TTL timestamps are +cleared with them. + +A failed reload changes nothing: the old environment keeps serving, so working +memory survives. Only a *successful* swap wipes it. + +Plan for this. `Engine.subscribe_reload` is the seam: it registers a +zero-argument callback fired after each successful swap, outside the reload +lock, and returns an unsubscribe callable. A listener that raises is logged and +swallowed, so a wedged subscriber cannot break the reload. + +```python +def reseed() -> None: + engine.assert_fact("tenant", {"id": "acme", "tier": "gold"}) + +unsubscribe = engine.subscribe_reload(reseed) +``` + +Re-run `FleetEngine.sync_fleet_facts` from the same callback for fleet-scoped +state, and expect session callers to re-seed anything they asserted before the +reload. + +This how-to covers how to sign a ruleset, wire up the deployment +config, use the dev escape during development, monitor the live +ruleset with `fathom status`, and rotate the runtime pubkey. + +## Endpoint shape + +``` +POST /v1/rules/reload +Authorization: Bearer +Content-Type: application/json +``` + +Request body — supply **exactly one** of `ruleset_path` or +`ruleset_yaml`; `signature` is required in production (fail-closed +default): + +```json +{ + "ruleset_path": "/etc/fathom/rulesets/prod-2026-04-22.yaml", + "signature": "" +} +``` + +Inline form — useful when the operator lacks filesystem write access +on the Fathom host: + +```json +{ + "ruleset_yaml": "rules:\n - id: ...\n", + "signature": "" +} +``` + +Response (200): + +```json +{ + "ruleset_hash_before": "sha256:aaaa…", + "ruleset_hash_after": "sha256:bbbb…", + "attestation_token": "" +} +``` + +Error codes: + +- `400 invalid_request` — both or neither of `ruleset_path` / + `ruleset_yaml` supplied; `ruleset_path` unreadable; `signature` is + not valid base64; ruleset failed to compile. +- `400 unsigned_ruleset` — `require_signature=true` (the default) and + either `signature` is missing or Ed25519 verification failed. Also + emits `ruleset_reload_rejected` to the audit sink. +- `413 payload_too_large` — the request body exceeds the reload size + cap (see below). Enforced before any YAML parsing. +- `500 server_misconfigured` — `require_signature=true` but the + pubkey never loaded (should have failed at boot; treated as + defence-in-depth). +- `503 not_ready` — engine or attestation service not yet configured + on `app.state`. + +## Admin token, body size cap, and rate limiting + +`POST /v1/rules/reload` (REST) and the gRPC `Reload` RPC are +admin-scoped, defence-in-depth controls on top of signature +verification: + +- **Scoped admin token.** Set `FATHOM_ADMIN_TOKEN` to require a + dedicated token for reload. When it is set, the reload surface + accepts **only** that token — the data-plane `FATHOM_API_TOKEN` + (used by `/v1/evaluate`, `/v1/facts`, …) no longer grants reload, + so a leaked data-plane token cannot replace the ruleset. When + `FATHOM_ADMIN_TOKEN` is **unset**, reload falls back to + `FATHOM_API_TOKEN` exactly as before (backward compatible). A + rejected token returns `401 unauthorized`. +- **Body size cap.** Reload bodies are capped at 5 MB by default, + overridable via `FATHOM_MAX_RELOAD_BYTES`. The cap is enforced on + the **actual bytes received** (the body is streamed and aborted once + the running total exceeds the limit), not the `Content-Length` + header — a chunked request that under-reports its length is still + rejected with `413 payload_too_large` before the YAML parser sees + anything. The gRPC server inherits gRPC's default 4 MB + `max_receive_message_length`, which already bounds `Reload` payloads + below the REST cap. +- **Rate limiting is host-level by design.** Fathom does not implement + per-client request throttling in-process. Run reload behind a + reverse proxy (nginx `limit_req`, Envoy, an API gateway, …) and rate + limit there. Keeping rate limiting at the host layer avoids + duplicating shared infrastructure inside the engine and keeps the + admin surface a thin, auditable control. + +## Signing a ruleset + +The runtime pubkey is a **separate concept** from the release-signing +key documented in [release-signing.md](release-signing.md). Per design +decision D2 you can either: + +- **Reuse the release-signing keypair.** Simpler key inventory; + operators who already run `scripts/sign_release.sh` can sign rulesets + with the same material. Trade-off: a compromise of the release key + also lets an attacker forge hot-reload payloads. +- **Generate a separate runtime keypair.** Blast-radius reduction: a + compromised runtime key does not let the attacker forge release + artifacts, and vice-versa. Recommended for production deployments. + +### Generating a runtime keypair + +The server expects a **raw Ed25519 PEM public key**, not minisign's +format. Generate with `openssl`: + +```shell +openssl genpkey -algorithm ed25519 -out ruleset-signing.key +openssl pkey -in ruleset-signing.key -pubout -out ruleset-signing.pub +``` + +Keep `ruleset-signing.key` in the same custody regime as your other +secrets (HSM, sealed secret, offline backup). The public half +(`ruleset-signing.pub`) is what the Fathom server loads at boot. + +### Producing a detached signature + +Sign the **raw YAML bytes** you intend to POST — not a hash, not a +normalised form. The server re-hashes the payload bytes internally and +uses them both for verification and for `ruleset_hash_after`. Any pre-signing +transformation (whitespace fix-ups, YAML re-emit) invalidates the +signature. + +Python example using `cryptography`: + +```python +from base64 import b64encode +from pathlib import Path +from cryptography.hazmat.primitives.serialization import load_pem_private_key + +yaml_bytes = Path("prod-2026-04-22.yaml").read_bytes() +priv = load_pem_private_key(Path("ruleset-signing.key").read_bytes(), password=None) +signature_b64 = b64encode(priv.sign(yaml_bytes)).decode("ascii") +print(signature_b64) +``` + +The resulting base64 string goes into the `signature` field of the +reload request. OpenSSL's `pkeyutl -sign` path works too, but keeping +the YAML bytes byte-identical through the shell pipeline is error- +prone; scripted signing is recommended. + +## Deployment configuration (fail-closed default) + +Two independent controls must both line up for a production deployment: + +1. **Config.** In `config.yaml` (or whichever layer feeds your server + factory): + + ```yaml + rules: + hot_reload: + require_signature: true # default + ``` + +2. **Environment.** Point at the public key PEM file: + + ```shell + export FATHOM_RULESET_PUBKEY_PATH=/etc/fathom/ruleset-signing.pub + ``` + +The server loads the pubkey **at boot** and pins it onto +`app.state.ruleset_pubkey`. If `require_signature=true` and the path +is missing, unset, or unreadable, `build_app()` raises a +`RuntimeError` with the message `ruleset pubkey unreadable or missing; +set FATHOM_RULESET_PUBKEY_PATH or enable dev escape` and the server +never starts. This is deliberate — failing at boot beats failing on +first reload attempt. + +## Dev escape (development only) + +There is a single supported way to run the server with signature +verification disabled, and it requires **both** of the following, by +design (belt-and-suspenders — a single stray flag is not enough to +lower the security floor): + +1. Config: `rules.hot_reload.require_signature: false`. +2. Environment: `FATHOM_ALLOW_UNSIGNED_RULESETS=1`. + +Neither alone is sufficient; any other combination keeps the server +fail-closed. When both are set, `build_app()` logs at WARN on startup: + +``` +ruleset signature verification disabled (require_signature=false + +FATHOM_ALLOW_UNSIGNED_RULESETS=1); hot-reload will accept unsigned +rulesets +``` + +The WARN line is emitted once per process; scrape for it in your log +aggregator to detect dev escape accidentally turned on in prod. Every +unsigned reload that succeeds under the dev escape still writes a +`ruleset_reloaded` audit record, so there is no silent window. + +## Monitoring the live ruleset + +`GET /v1/status` (unauthenticated; liveness-shaped) returns: + +```json +{ + "ruleset_hash": "sha256:bbbb…", + "version": "0.3.1", + "loaded_at": "2026-04-22T22:15:07.482+00:00" +} +``` + +`loaded_at` is the timestamp of the most recent reload, or the +server's boot time if no reload has happened yet. The CLI wraps this +endpoint: + +```shell +fathom status --server https://fathom.prod.example.com +``` + +Output: + +``` +ruleset_hash: sha256:bbbb… +version: 0.3.1 +loaded_at: 2026-04-22T22:15:07.482+00:00 +``` + +Pass `--token` (or set `FATHOM_TOKEN`) if the server fronts `/v1/status` +with auth in your environment. Exit code `0` means the call succeeded +and the ruleset hash was reported; non-zero means the server was +unreachable, returned an HTTP error, or replied with non-JSON. + +The `ruleset_hash` you see here should match the `ruleset_hash_after` value +returned by the most recent `POST /v1/rules/reload`. If it does not, +either a later reload happened between your calls, or the audit log +and live state have diverged — investigate immediately. + +## Audit events + +Every reload — accepted or rejected — writes one record to the +configured audit sink. Successful reloads emit: + +```json +{ + "event_type": "ruleset_reloaded", + "ruleset_hash_before": "sha256:aaaa…", + "ruleset_hash_after": "sha256:bbbb…", + "actor": "bearer-token", + "timestamp": "2026-04-22T22:15:07.482+00:00" +} +``` + +Rejected reloads emit `event_type: ruleset_reload_rejected` with a +`reason` field (`missing_signature`, `verification_failed`, +`unknown_template`, …) and the *unchanged* `ruleset_hash_before`. + +The `attestation_token` returned from a successful reload is a JWT +signed by the server's `AttestationService.sign_event()` (C6); you can +verify it offline with the service's pubkey and replay it in your +audit trail as a non-repudiation proof. + +## Rotating the runtime pubkey + +Rotate on a schedule (annual, matching the release-signing cadence) +or immediately on suspected compromise: + +1. Generate a new Ed25519 keypair with `openssl` (see above); store + the private half in the same custody regime as the old one. +2. Copy the new public key to a fresh path (e.g. `.pub.v2`) on each + Fathom host so you can roll back instantly if needed. +3. Update the deployment's `FATHOM_RULESET_PUBKEY_PATH` to point at + the new `.pub.v2` file. +4. Restart the server. `build_app()` reloads the pubkey at boot; there + is no hot path for pubkey rotation by design (the pubkey is the + trust anchor for reloads, so rotating it via a reload would be + circular). +5. Confirm the new pubkey is live by POSTing a reload signed with the + new private key and watching `fathom status --server …` for a new + `ruleset_hash` and advanced `loaded_at`. +6. Securely erase the previous private key once you have confirmed + the next scheduled ruleset push signs cleanly under the new key. + +The runtime pubkey and the release-signing pubkey rotate on +independent schedules — if you reuse the same key for both (D2 +trade-off), plan one rotation event that covers both surfaces. + +## Migration from earlier Fathom builds + +This release is the **first** to ship `POST /v1/rules/reload`, so no +operator has ever had a "signatures optional" hot-reload in +production. However, operators who have been experimenting with +pre-release dev builds that accepted unsigned rulesets need to know: + +- **The default is fail-closed.** After upgrading, a server started + without `FATHOM_RULESET_PUBKEY_PATH` will refuse to boot with + `ruleset pubkey unreadable or missing…`. This is intentional. +- **Before restart, pick one of:** + 1. **Production path (recommended).** Generate a runtime keypair + (or decide to reuse the release key), publish the pubkey file, + set `FATHOM_RULESET_PUBKEY_PATH` on every Fathom host, and keep + `rules.hot_reload.require_signature: true`. + 2. **Dev/CI path.** Set **both** `rules.hot_reload.require_signature: + false` in config **and** `FATHOM_ALLOW_UNSIGNED_RULESETS=1` in + the environment. Either alone leaves fail-closed behaviour + active — deliberately, to stop a partial config from silently + downgrading security. +- **Audit sink consumers.** Start recognising the two new + `event_type` values (`ruleset_reloaded`, + `ruleset_reload_rejected`) alongside the existing eval-shaped + records. They share the `actor` + `timestamp` envelope but carry + `ruleset_hash_before` / `ruleset_hash_after` instead of eval fields. + + +## how-to/release-signing.md + +# Verifying release signatures + +Every Fathom release artifact from **0.5.0 onward** is signed with +[minisign](https://jedisct1.github.io/minisign/) (detached Ed25519) and +attached to its GitHub Release. This how-to shows you how to install minisign, +verify a downloaded wheel or sdist, and understand the custody, rotation, and +revocation policy for the signing key. + +**Releases before 0.5.0 are unsigned.** Signing was introduced with the 0.4.0 +tree, which reached PyPI as 0.5.0. The five earlier PyPI releases — 0.1.0, +0.2.0, 0.3.0, 0.3.1 and 0.3.2 — have no `.minisig`, and the GitHub releases for +0.3.0, 0.3.1, 0.3.3 and 0.4.0 carry no assets at all. There is nothing to +verify for those versions, and no signature will be published retroactively: +back-signing an old artifact today would assert a custody chain that did not +exist when it was built. Use 0.5.0 or later if you need a verifiable artifact. + +## What is signed, and how + +- **Artifacts**: every `fathom_rules-*.whl` and `fathom_rules-*.tar.gz` + from 0.5.0 onward. `pypi-publish.yml` fails the release if any artifact + in `dist/` reaches the publish step without a sibling `.minisig`. +- **Algorithm**: Ed25519 via minisign's standard `.minisig` format — + detached signature, one file per artifact, binary-safe. +- **Where signatures appear**: + - Alongside each wheel / sdist as `.minisig` on the GitHub + Release page. + - In the `dist/` directory produced by `scripts/sign_release.sh` + during the `pypi-publish` workflow. +- **Pubkey**: published at + [`docs/reference/release-signing-pubkey.minisig`](../reference/release-signing-pubkey.minisig) + and embedded in the Fathom wheel at + `fathom/_data/release_pubkey.minisign` so that `fathom + verify-artifact` works offline. + +A `.minisig` file is a small text blob containing an Ed25519 signature +plus a trusted comment. It is not a bundle — the artifact and its +`.minisig` travel together but remain separate files. + +## Installing minisign + +Pick the instruction that matches your platform. All of them give you a +`minisign` binary on your `PATH`. + +- **macOS** (Homebrew): + + ```shell + brew install minisign + ``` + +- **Linux** (Debian, Ubuntu): + + ```shell + sudo apt install minisign + ``` + + On Fedora / RHEL: `sudo dnf install minisign`. On Arch: `sudo pacman + -S minisign`. + +- **Windows**: download a pre-built binary from the upstream release + page at + [`https://github.com/jedisct1/minisign/releases`](https://github.com/jedisct1/minisign/releases) + and place it somewhere on `PATH`. Alternatively, use WSL and follow + the Linux instructions above. + +Confirm the install: + +```shell +minisign -v +``` + +## Verifying a downloaded artifact + +You have two equivalent paths. Use the first if you already have Fathom +installed; use the second if you only have `minisign` and the public +key file. + +### Option A — `fathom verify-artifact` (embedded pubkey) + +The CLI carries the release pubkey inside the wheel, so once you have +any recent Fathom version installed you can verify later downloads +offline: + +```shell +fathom verify-artifact fathom_rules-1.2.0-py3-none-any.whl +``` + +If `.minisig` lives next to the artifact, the CLI picks it up +automatically. Override either side with `--sig ` or `--pubkey +` when auditing a specific file. Exit code `0` means the +signature verified; non-zero means the verification failed or the +inputs were malformed — re-download the artifact before trusting it. + +### Option B — raw `minisign` + +No Fathom install needed. Download the artifact, its `.minisig`, and +the pubkey file from +[`docs/reference/release-signing-pubkey.minisig`](../reference/release-signing-pubkey.minisig), +then: + +```shell +minisign -Vm fathom_rules-1.2.0-py3-none-any.whl \ + -p release-signing-pubkey.minisig +``` + +`minisign` looks for `.minisig` by default. Pass `-x ` +to point at a signature that lives elsewhere. A `Signature and comment +signature verified` line means the artifact is authentic. + +The pubkey's first line is also pasted into every GitHub Release body, +so you can cross-check the key you fetched from the repository against +the one on the release page. + +## M-of-N custody policy + +The release private key is never held in a single place. Two named +custodians each hold one half of the operational state, and **both +must agree out-of-band before a release tag is pushed**. + +- `MINISIGN_KEY_PRIMARY` — GitHub Actions secret, held by custodian A. + Loaded into `scripts/sign_release.sh` during the `pypi-publish` + workflow. +- `MINISIGN_KEY_SECONDARY` — offline backup of the same keypair, held + by custodian B. Used only for rotation, recovery, and the custody + handshake described below. +- Both custodians confirm the target tag, commit SHA, and intended + version via a signed email or Signal message before custodian A + authorises the tag push that triggers signing. No single custodian + can ship a release on their own, because the handshake is the + authorisation step — not merely a courtesy. + +The policy exists so that a compromise of a single machine, inbox, or +GitHub token cannot produce a valid Fathom release. + +## Rotation cadence + +- **Scheduled**: rotate the keypair annually. Track the next rotation + date in the repository's release runbook. +- **On compromise**: rotate immediately if either custodian suspects + their copy of the key has been exposed, if a custodian's workstation + is lost or stolen, or if `MINISIGN_KEY_PRIMARY` is ever printed to + logs. + +### Rotation procedure + +1. Both custodians meet out-of-band and generate a new keypair with + `minisign -G`. +2. Update `docs/reference/release-signing-pubkey.minisig` **and** + `src/fathom/_data/release_pubkey.minisign` with the new public key, + in a single commit. +3. Distribute the new private key: update the `MINISIGN_KEY_PRIMARY` + GitHub Actions secret; custodian B stores the new offline backup. +4. Publish a dedicated release note flagging the rotation, including + the new key ID and the date from which signatures use it. +5. Retire the previous private-key material (securely erase offline + copies, delete the old GitHub secret). + +## Revocation + +minisign has no CRL or OCSP equivalent — trust in a key is defined by +the key file you hold. To revoke a compromised key: + +1. Follow the rotation procedure above, treating it as an emergency. +2. Issue a release note that explicitly invalidates every artifact + signed by the previous key and lists which published versions are + affected. +3. Recommend that users re-fetch and re-verify any still-in-use + artifacts against the new pubkey before continuing to rely on + them. + +Users who pin the pubkey (for example by vendoring +`release-signing-pubkey.minisig`) should update their pinned copy from +the new release. The embedded pubkey in `fathom/_data/` updates +automatically once users install a release signed under the new key. + + +## how-to/policy-studio.md + +# Running Policy Studio + +Policy Studio is a local browser UI over a real Fathom engine: load a ruleset, +push facts at it, and read the decision, the rules that fired, and the audit +line that came out. It is a **separate package** — `fathom-studio`, its own +version line — because the engine wheel ships no UI code. + +## Install and run + +`fathom-studio` is not published to PyPI yet. Run it from a checkout, where +it is a workspace member and `uv sync` already installs it: + +```bash +git clone https://github.com/KrakenNet/fathom.git +cd fathom +uv sync + +export FATHOM_API_TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')" +uv run fathom-studio +``` + +The process prints the URL to open and binds `127.0.0.1:8020`. `--host` and +`--port` override the bind; `FATHOM_STUDIO_PORT` sets the port from the +environment. + +Open it as `http://127.0.0.1:8020/?token=$FATHOM_API_TOKEN` **once**. The +token is validated and stored in the `fathom_token` cookie, which the SPA's +`fetch` calls and the plain HTML forms then carry for you. + +## The token gate + +The Studio mounts the production REST app in the same process, so it holds +itself to the same door: every route that loads a ruleset, drives the engine, +or mints an attestation token requires the same `FATHOM_API_TOKEN` the REST +app requires. There is no second secret. Present it as either +`Authorization: Bearer ` (for `curl` and tests) or the cookie above. + +Only three things are ungated, and none of them carry engine data: `/health`, +the SPA shell at `/`, and its static assets under `/creem`. With +`FATHOM_API_TOKEN` unset, every gated route answers 401 — an unconfigured +Studio exposes nothing. + +## What the views do + +| View | What it drives | +|---|---| +| Reasoning Bench | Loads a bundled scenario, evaluates it, and shows decision, reason, and the firing rules | +| Live Wire | A rolling decision stream and deny-rate strip. The traffic is synthetic — it samples the bundled scenarios — but every packet is a real evaluation | +| Rules | The compiled rules of the selected ruleset, with their conditions | +| Templates | The fact schemas the ruleset declares | +| Audit | The signed, hash-linked record of what the Studio has evaluated so far | + +Nine demo scenarios ship with the package, covering five example rulesets: +hello allow/deny, RBAC with modules, Bell-LaPadula classification, temporal +anomaly detection, and LangChain guardrails. Each is a copy of the matching +`examples/0N-*` directory, held byte-identical to it by a test. Point +`FATHOM_RULESET_ROOT` at your own directory to drive the Studio with your +rulesets instead. + +Decisions are never faked. `POST /studio/api/evaluate` builds a fresh +`Engine` for the chosen ruleset, asserts the facts, evaluates, and renders +what came back — the same stateless shape `/v1/evaluate` uses, so working +memory never leaks from one bench run into the next. + +## What it is not + +- **Not a sandbox.** The REST app is *mounted*, not proxied: it runs in the + Studio's process, under `/api`, sharing that module's in-memory session + store. Anything driven through `/api` — including the server-rendered + panels, which call it with a per-browser `X-Session-Id` — lands in the same + working memory a REST client of that process sees. +- **Not a stable API.** The `/studio/api/*` routes are unversioned and + excluded from [the compatibility policy](https://github.com/KrakenNet/fathom/blob/main/VERSIONING.md); + they change with the UI. +- **Not your audit log.** The Audit view's chain lives in process memory, + holds the most recent 200 records, and is signed with a keypair generated + at startup — it dies with the process. For a durable, verifiable log, use + `ChainedAttestationLog` from the library + ([Audit & Attestation](../concepts/audit-attestation.md)). +- **Not hardened for exposure.** It binds loopback by default. Keep it there. + + ## concepts/index.md # Concepts @@ -1977,7 +2625,7 @@ shortest path to a working mental model of the engine. The rest of this page walks the five in the order the engine loads them (`templates → modules → functions → rules` — see -`src/fathom/engine.py`, `Engine.from_rules`, lines 452–489) and closes with how +`src/fathom/engine.py`, `Engine.from_rules`, lines 496–570) and closes with how they fit together at evaluate time. ## Templates @@ -2325,6 +2973,14 @@ when no rules in it can fire, the next module in the list gets its turn. The first module in `focus_order` is the first to run, and `_setup_focus_stack()` emits the list unchanged to say so. +`focus_order` comes from the `focus_order:` key in a module YAML file, or +from `Engine.set_focus(...)`. If a pack declares modules and sets neither, +`Engine.load_modules` focuses every declared module in declaration order — +without that fallback the focus list is empty, no module's agenda is ever +drained, and the caller gets the default decision back from a pack whose +rules all matched. Declaration order is a floor, not a design: state +`focus_order:` whenever the sequence matters. + The practical upshot is that modules are a coarse-grained ordering tool. If `policy` comes before `logging` in the focus order, every activatable `policy` rule fires before any `logging` rule gets a chance, @@ -2530,9 +3186,10 @@ allowed-value directives come from `_CLIPS_TYPE_MAP` and (defmodule governance (import MAIN ?ALL)) ``` -Emitted by `compile_module`. `ModuleDefinition` carries a `priority` -integer consumed by focus-stack ordering at load time, not serialized -into the CLIPS source. +Emitted by `compile_module`. `ModuleDefinition` also carries a `priority` +integer, which is neither serialized into the CLIPS source nor read by the +runtime: focus order comes from the `focus_order:` key, or from declaration +order when that key is absent (see [Module](../reference/yaml/module.md)). `RuleDefinition` → a `defrule` qualified by its owning module: @@ -2781,6 +3438,7 @@ class AuditRecord(BaseModel): duration_us: int metadata: dict[str, str] = Field(default_factory=dict) asserted_facts: list[AssertedFact] | None = None + attestation_token: str | None = None ``` Field by field: @@ -2806,6 +3464,10 @@ Field by field: decision's rule. - **`asserted_facts`** — populated only when at least one loaded rule declares an RHS `asserts` block (see below). +- **`attestation_token`** — the JWT signed for this same evaluation, copied + onto the record so an exported line can be checked without the caller's + copy of the token. `None` on an engine constructed without an + `attestation_service`. Records are written one-per-line as JSON. JSON Lines is trivially grep-able, `jq`-able, and concatenatable; it's what most log aggregators expect. @@ -2825,13 +3487,29 @@ class AuditSink(Protocol): def write(self, record: AuditRecord) -> None: ... ``` -Two implementations ship with Fathom: +Three implementations ship with Fathom: - **`FileSink(path)`** — writes `record.model_dump_json() + "\n"` to the given file in append mode. The constructor creates parent directories and `touch`es the file, so pointing it at a fresh path Just Works. - **`NullSink`** — `write()` is a no-op. This is the default when you construct an `Engine` without passing `audit_sink`. +- **`ChainedAttestationLog(path, service)`** (`fathom.chained_log`) — the + same JSON Lines shape, but each line is signed and commits to the hash of + the line before it, so deleting or reordering entries is detectable and + not just discouraged. It needs the `attestation` extra and a signing + service: + + ```python + from fathom import Engine + from fathom.attestation import AttestationService + from fathom.chained_log import ChainedAttestationLog + + log = ChainedAttestationLog("/var/log/fathom/audit.jsonl", AttestationService.generate_keypair()) + engine = Engine(audit_sink=log) + ``` + + Verify it later with `fathom verify-chain --pubkey .pub.pem`. Anything satisfying the protocol is a valid sink. A production deployment might write to S3, publish to Kafka, call out to syslog, or fan out to @@ -2860,26 +3538,38 @@ real sink; everything else keeps working with zero ceremony. ## What gets recorded when -The recording happens inside `Engine.evaluate()`. The sequence: +The recording happens inside `Engine.evaluate()`, under the engine's +re-entrant lock, so a concurrent caller cannot interleave its own facts into +the snapshots below. The sequence: 1. **Pre-snapshot user facts** — but only if `self._has_asserting_rules` is true. That flag is set at load time when any compiled rule declares a non-empty `asserts` block. If no loaded rule can assert new facts, the snapshot is skipped entirely — there's nothing to diff against. -2. **Run inference** — `self._evaluator.evaluate()` returns an +2. **Snapshot input facts** — `self._snapshot_input_facts()` captures the + caller-supplied working memory the decision is about to be computed over. + It is taken only when an `attestation_service` is configured or the audit + log is recording, because it costs a query per template and nothing else + consumes it. This snapshot is what `input_hash` binds (see + [Attestation as signed proof](#attestation-as-signed-proof)) and what + `log: full` records. +3. **Run inference** — `self._evaluator.evaluate()` returns an `EvaluationResult` with `decision`, `reason`, `rule_trace`, - `module_trace`, and `duration_us`. -3. **Sign, if configured** — if the engine was constructed with an - `attestation_service`, call `sign(result, self._session_id)` and store the - returned JWT on `result.attestation_token`. -4. **Diff pre/post snapshots** — a second `_snapshot_user_facts()` call, + `module_trace`, and `duration_us`, plus the effective log level. +4. **Sign, if configured** — if the engine was constructed with an + `attestation_service`, call + `sign(result, self._session_id, input_facts=...)` and store the returned + JWT on `result.attestation_token`. The input facts are not optional here: + `sign` refuses `None`, because a token signed without inputs binds + nothing. +5. **Diff pre/post snapshots** — a second `_snapshot_user_facts()` call, differenced against the pre-snapshot, yields the facts the rules asserted during this evaluation. Order is preserved from the post snapshot; equality is keyed on `(template, sorted(slots.items()))`. -5. **Record** — `self._audit_log.record(result, session_id, - asserted_facts=...)` constructs the `AuditRecord` and hands it to the - sink. -6. **Metrics** — `self._metrics.record_evaluation(...)` runs in a `finally` +6. **Record** — `self._audit_log.record(result, session_id, + input_facts=..., asserted_facts=..., log_level=...)` constructs the + `AuditRecord` and hands it to the sink. +7. **Metrics** — `self._metrics.record_evaluation(...)` runs in a `finally` so metrics are updated even if recording raised. Two things worth flagging: @@ -2888,10 +3578,11 @@ Two things worth flagging: and also when asserting rules exist but none fired. An empty list is collapsed to `None`, so the record distinguishes "didn't try to capture this" from "captured nothing." -- Signing happens *before* the audit record is written. The JWT ends up - on the `EvaluationResult` the caller receives but is **not** one of the - `AuditRecord` fields — the log records the decision; the token is - returned to the caller to store or forward separately. +- Signing happens *before* the audit record is written, which is what lets + the record carry the token: the JWT is set on the `EvaluationResult` in + step 4 and copied onto `AuditRecord.attestation_token` in step 6. The + caller gets the same token on the result to forward separately if it + wants to. ## Attestation as signed proof @@ -2978,9 +3669,20 @@ What audit + attestation *do* protect against: - **Disputes about what was decided.** A signed `decision` and `rule_trace` pin down the answer and the rules that produced it. -- **Tampering with exported logs.** An attacker who modifies an audit line - after export can't re-sign it without the private key; `verify_token` - fails. +- **Tampering with the decision in an exported log.** Each line carries the + JWT signed for that evaluation, and the payload commits to `decision`, + `rule_trace`, `session_id`, `iat`, and `input_hash`. Change any of those + in the line and it no longer matches the claims `verify_token` returns; + re-signing needs the private key. + + What this does **not** cover: `reason`, `metadata`, `duration_us`, + `input_facts`, and `asserted_facts` sit outside the signed payload, so a + line whose token verifies is not thereby proof that those fields are + untouched. Nor does a per-line signature detect a line being *deleted* — + for that you need order and continuity, which is what + `fathom.chained_log.ChainedAttestationLog` and `fathom verify-chain` + provide: each entry commits to the hash of the one before it, so a + removed or reordered entry breaks the chain. - **Input substitution.** The `input_hash` commits the token to a specific set of facts — template name and slot values both. Swap a fact, change a slot, or move a fact to a different template, and the hash stops matching. @@ -3248,8 +3950,21 @@ scoping will work from. # Reference -Every public surface of Fathom is documented here. All pages under this tab -are generated from source — hand edits will be overwritten on next build. +Every public surface of Fathom is documented here. + +Most pages under this tab are generated from source — the SDK, REST, gRPC, MCP, +CLI, and rule-pack references are rewritten by `make docs-gen`, so hand edits to +them are overwritten on the next build. The YAML pages and +[Configuration](configuration.md) are hand-written and cite the files they +describe in their frontmatter. + +Which of these surfaces the project promises to keep working is +[VERSIONING.md](https://github.com/KrakenNet/fathom/blob/main/VERSIONING.md). + +## Deployment + +- [Configuration](configuration.md) — every `FATHOM_*` variable, gRPC TLS, and + the server token scopes ## SDKs @@ -3280,6 +3995,98 @@ are generated from source — hand edits will be overwritten on next build. - [CMMC](rule-packs/cmmc.md) +## reference/configuration.md + +# Configuration + +Fathom as a library takes its configuration through `Engine(...)` arguments and +needs no environment. Everything below configures the **servers** — the REST +app, the gRPC server, and the CLI's client commands. + +Each variable is read from the process environment. Where a variable is read +per request rather than once at import, the table says so: those can be changed +without a restart. + +## All variables + +| Variable | Read by | Default | Effect | +|---|---|---|---| +| `FATHOM_API_TOKEN` | REST, gRPC | *(none — required)* | The data-plane bearer token. Every `/v1/*` route and every RPC compares the presented `Authorization: Bearer …` against it with `hmac.compare_digest`. Unset means server mode refuses to authenticate anything. Read per verification. | +| `FATHOM_ADMIN_TOKEN` | REST, gRPC | *(unset)* | Scopes the reload surface. When set, `POST /v1/rules/reload` and the `Reload` RPC accept **only** this token and the data-plane token stops working for them. When unset, reload falls back to `FATHOM_API_TOKEN`. | +| `FATHOM_RULESET_ROOT` | REST | *(none — required)* | Directory that every caller-supplied ruleset path is jailed under. A request naming a path outside it is rejected with 400; an unset root is a 500, never an unjailed read. | +| `FATHOM_RULESET_PUBKEY_PATH` | REST | *(none)* | PEM path of the public key that signed rulesets are verified against. Required at startup unless the dev escape below is engaged — a missing or unreadable key is a startup failure, not a warning. | +| `FATHOM_ALLOW_UNSIGNED_RULESETS` | REST | `0` | Set to `1` **and** build the app with `require_signature=False` to accept unsigned reload payloads. Both are required; either alone keeps signature checking on. Logs a warning on every boot that uses it. | +| `FATHOM_MAX_REQUEST_BYTES` | REST | `2097152` (2 MB) | Body cap on the data-plane routes, enforced on bytes actually received rather than `Content-Length`. `/v1/evaluate` runs a quadratic join over the facts it is given, so this bounds server CPU, not just memory. A non-numeric or non-positive value falls back to the default. Read per request. | +| `FATHOM_MAX_RELOAD_BYTES` | REST, gRPC | `5242880` (5 MB) | Body cap on the reload route specifically, which the data-plane cap skips. gRPC's own receive limit is set below this. Same fallback behaviour. Read per request. | +| `FATHOM_EXPOSE_DOCS` | REST | `0` | Set to `1` to serve `/docs`, `/redoc`, and `/openapi.json`. Off by default because they publish route and schema names to unauthenticated callers. Read once at import. | +| `FATHOM_GRPC_TLS_CERT` | gRPC | *(none)* | PEM certificate path. Required with `FATHOM_GRPC_TLS_KEY` unless insecure mode is opted into. | +| `FATHOM_GRPC_TLS_KEY` | gRPC | *(none)* | PEM private-key path, paired with the certificate above. | +| `FATHOM_GRPC_ALLOW_INSECURE` | gRPC | `0` | Set to `1` to bind a plaintext port when no TLS pair is configured. See the warning below. | +| `FATHOM_METRICS` | Engine | `0` | Set to `1` to enable Prometheus metrics collection without passing `enabled=True` to `MetricsCollector`. No-ops when `prometheus_client` is not installed, so setting it is safe on an install without the `metrics` extra. | +| `FATHOM_TOKEN` | CLI | *(unset)* | Default bearer token for CLI commands that call a server. `--token` overrides it. | + +## gRPC TLS + +**The gRPC server refuses to start without TLS.** `serve()` binds a secure port +only when both `FATHOM_GRPC_TLS_CERT` and `FATHOM_GRPC_TLS_KEY` name readable +PEM files: + +```bash +export FATHOM_GRPC_TLS_CERT=/etc/fathom/tls/server.crt +export FATHOM_GRPC_TLS_KEY=/etc/fathom/tls/server.key +export FATHOM_API_TOKEN="$(cat /run/secrets/fathom-api-token)" +``` + +```python +from fathom.integrations.grpc_server import serve + +server = serve(port=50051) +server.wait_for_termination() +``` + +With neither the pair nor an explicit opt-in, `serve()` raises `RuntimeError` +naming the three variables. That is deliberate: the bearer token travels in an +RPC metadata header, so an insecure port hands every token to any passive +observer on the path. + +The opt-in exists for local development and for deployments that terminate TLS +in a sidecar on the same host: + +```bash +export FATHOM_GRPC_ALLOW_INSECURE=1 # plaintext port; token sent in the clear +``` + +Do not set it on a port reachable from another host. If a service mesh already +terminates TLS, keep the plaintext listener bound to loopback. + +Client-certificate authentication (mTLS) is not wired up: the server passes a +single key pair to `grpc.ssl_server_credentials` and does not request a client +certificate. Callers are authenticated by bearer token only. + +## Server tokens + +Both server surfaces authenticate the same way — a bearer token compared in +constant time — and both distinguish two scopes: + +- **Data plane** (`FATHOM_API_TOKEN`): evaluate, assert, query, retract. +- **Admin** (`FATHOM_ADMIN_TOKEN`): ruleset reload. + +Setting only `FATHOM_API_TOKEN` means one token does both. Setting both means +a leaked data-plane token cannot swap the policy the server enforces, which is +the split worth having in production. See +[Hot-reloading rulesets](../how-to/hot-reload.md) for the rest of the reload +security model. + +## What is not configurable by environment + +- **Rule paths and engine options** — `Engine(...)` arguments: audit sink, + attestation service, session id, evaluation `run_limit`. +- **Ports and worker counts** — `serve(port=…, max_workers=…)` for gRPC, and + the ASGI server's own flags for REST (`uvicorn --port`). +- **Per-rule behaviour** — salience, module focus, and log level are properties + of the ruleset, not of the deployment. + + ## reference/python-sdk/index.md # Python SDK Reference @@ -3288,15 +4095,31 @@ Generated from `fathom.__all__` at docs-build time via mkdocstrings. ## Public symbols -- [`Engine`](engine.md) +- [`AssertSpec`](assertspec.md) +- [`AssertedFact`](assertedfact.md) +- [`AttestationError`](attestationerror.md) +- [`AttestationService`](attestationservice.md) +- [`AuditLog`](auditlog.md) +- [`AuditRecord`](auditrecord.md) +- [`AuditSink`](auditsink.md) +- [`ChainedAttestationLog`](chainedattestationlog.md) - [`CompilationError`](compilationerror.md) +- [`Engine`](engine.md) - [`EvaluationError`](evaluationerror.md) - [`EvaluationLimitError`](evaluationlimiterror.md) +- [`EvaluationResult`](evaluationresult.md) +- [`FactStore`](factstore.md) +- [`FathomError`](fathomerror.md) +- [`FileSink`](filesink.md) +- [`FleetConnectionError`](fleetconnectionerror.md) +- [`FleetEngine`](fleetengine.md) +- [`FleetError`](fleeterror.md) +- [`InMemoryFactStore`](inmemoryfactstore.md) +- [`NullSink`](nullsink.md) - [`ScopeError`](scopeerror.md) - [`ValidationError`](validationerror.md) -- [`AssertSpec`](assertspec.md) -- [`AssertedFact`](assertedfact.md) -- [`EvaluationResult`](evaluationresult.md) +- [`verify_chain`](verify_chain.md) +- [`verify_token`](verify_token.md) ## reference/go-sdk/index.md @@ -3505,8 +4328,25 @@ templates: Pydantic-level rejections (raised at `TemplateDefinition(...)` or during YAML load): -- `name` that does not match `^[A-Za-z_][A-Za-z0-9_\-]*$` → - `ValueError` from `_name_must_be_clips_ident`. +- Any key not listed in the tables above, on either model — both + `TemplateDefinition` and `SlotDefinition` are `extra="forbid"`, so a + misspelled field is an error rather than a silently dropped line. +- `TemplateDefinition.name` that does not match + `^[A-Za-z_][A-Za-z0-9_\-]*$` → `ValueError` from + `_name_must_be_clips_ident`. +- `SlotDefinition.name` that does not match the same pattern → `ValueError` + from `_validate_clips_ident`. +- On a `symbol` slot, an `allowed_values` entry that is not a CLIPS + identifier → `ValueError`. Symbol allowed values are emitted **unquoted**, + so an unchecked entry closes the `deftemplate` and appends constructs of + its own. +- On a `symbol` slot, a string `default` that is not a CLIPS identifier → + `ValueError`, for the same reason. +- On an `integer` or `float` slot, a string `default` of any kind → + `ValueError`: the value is interpolated verbatim, so it has to be numeric. + +`string` slots are exempt from the last three: their values are escaped and +quoted on the way out, so no content in them can escape the construct. Compile-time rejections (raised by `Compiler.compile_template`): @@ -3517,10 +4357,6 @@ Compile-time rejections (raised by `Compiler.compile_template`): `min_length=1` constraint, so an empty list passes model validation and only fails at compile time. -No validator is applied to `SlotDefinition.name`, `slot.allowed_values`, -or `slot.default` — the compiler trusts these fields and emits them -verbatim or via `_escape_clips_string`. - ## What is not emitted These YAML fields are accepted by the model but do not appear in the @@ -3939,9 +4775,12 @@ modules: ## Priority and the focus stack `ModuleDefinition.priority` is a stored integer (default `0`) that the -runtime **does not read** when setting focus. Focus order is driven -exclusively by the explicit `focus_order:` list at the top of the module -YAML file (or by a later `Engine.set_focus(...)` call). +runtime **does not read** when setting focus. Focus order is driven by +the explicit `focus_order:` list at the top of the module YAML file (or +by a later `Engine.set_focus(...)` call). When no `focus_order:` is +given anywhere in the pack, `load_modules` falls back to the order the +modules were declared in — see +[When `focus_order` is omitted](#when-focus_order-is-omitted). At evaluation time, `_setup_focus_stack` in `src/fathom/evaluator.py` emits a single `(focus ...)` eval listing the modules in the order they @@ -3965,6 +4804,27 @@ What `priority` is actually used for today: See [Runtime & Working Memory](../../concepts/runtime-and-working-memory.md) for the full focus-stack mechanics. +### When `focus_order` is omitted + +A pack that declares modules and no `focus_order:` is focused on every +declared module, in declaration order: files in load order, and within a +file the order the `modules:` list is written. `Engine.load_modules` +applies this once, after every module file is parsed, and only if +nothing has set a focus order already — so a `focus_order:` in any file, +or an earlier `Engine.set_focus(...)`, wins outright. + +The fallback exists because omitting `focus_order:` is not a request for +a different order; it is the absence of a request. CLIPS drains only the +agenda of the module that holds the focus, so with no focus set at all, +rules scoped to a declared module never fire and the caller silently +receives the default decision — a wrong answer rather than an error. + +Declaration order is not a substitute for stating the order you want. +It is stable, but it moves when you add a module or rename a file, and +because the evaluator is last-write-wins, the module that lands last +writes the decision. Any pack whose modules must run in a particular +sequence should say so with `focus_order:`. + ### Declaring focus order ```yaml @@ -4020,8 +4880,8 @@ compiled CLIPS `defmodule`: - `ModuleDefinition.description` — metadata only. - `ModuleDefinition.priority` — metadata only; does not influence - focus-stack ordering in the current runtime. If you need - deterministic module ordering, set `focus_order:` explicitly. + focus-stack ordering in the current runtime. Ordering comes from + `focus_order:`, or from declaration order when that key is absent. ## See also @@ -4232,6 +5092,9 @@ prevent collisions with the runtime's own bindings. | `type: raw` with `body is None` | `compile_function` | `CompilationError` | | `type: classification` without `hierarchy_ref` | `compile_function` | `CompilationError` | | `type: classification` with unknown `hierarchy_ref` | `compile_function` | `CompilationError` | +| Any key not defined on the model, on `FunctionDefinition` or `HierarchyDefinition` | Pydantic (`extra="forbid"`) | `ValidationError` | +| `HierarchyDefinition.name` that is not a valid CLIPS identifier | Pydantic validator | `ValueError` | +| A `levels` or `compartments` member that is not a valid CLIPS identifier | Pydantic validator | `ValueError` | ## What is not emitted @@ -4299,13 +5162,13 @@ the SDK. ### SDK — `Engine.assert_fact` / `Engine.assert_facts` -Single (`src/fathom/engine.py` line 757): +Single (`src/fathom/engine.py` line 1179): ```python engine.assert_fact("access-request", {"subject": "alice", "action": "read"}) ``` -Atomic batch (`src/fathom/engine.py` line 775) — every fact is +Atomic batch (`src/fathom/engine.py` line 1235) — every fact is validated first; if any fails, **none** are asserted: ```python @@ -4658,7 +5521,7 @@ Each entry declares a **Status** of one of: **Status:** Partial. **Location:** `packages/fathom-go/` — a hand-written REST client plus -generated gRPC bindings. Package contents: `client.go` (180 lines), +generated gRPC bindings. Package contents: `client.go` (181 lines), `client_test.go` (818 lines), `grpc_test.go` (230 lines, build-tagged `integration`), `tools.go`, `go.mod`, `go.sum`, `Makefile`, and `proto/` with `fathom.pb.go` + `fathom_grpc.pb.go`. `go.mod` declares @@ -4799,6 +5662,36 @@ known-bad decisions would fail open on anything it had not heard of. Install via `pip install fathom-rules[langchain]`, `fathom-rules[crewai]`, `fathom-rules[openai-agents]`, or `fathom-rules[google-adk]`. +## Policy Studio — `packages/fathom-studio/` + +**Status:** Partial. + +**Location:** `packages/fathom-studio/` — package identity `fathom-studio` at +`0.1.0`, a uv workspace member of this repo. It depends on `fathom-rules` +like any other consumer; the engine wheel ships no Studio code. + +**What works today:** A browser UI over a real engine — five views (Reasoning +Bench, Live Wire, Rules, Templates, Audit) served as a zero-build React SPA, +a JSON backend under `/studio/api`, server-rendered HTMX panels, and the +production REST app mounted in the same process under `/api`. Nine demo +scenarios ship as package data, held byte-identical to the repo's +`examples/0N-*` directories by a test. Its pytest suite runs in CI: root +`testpaths` includes `packages/fathom-studio/tests`, so the required `test` +job covers it. + +**What is missing:** + +- **No published release.** `fathom-studio` is not on PyPI and no workflow + builds or publishes it; run it from a checkout with `uv run fathom-studio`. +- **No stability promise.** The `/studio/api/*` routes are unversioned and + explicitly excluded from + [VERSIONING.md](https://github.com/KrakenNet/fathom/blob/main/VERSIONING.md). +- **In-memory audit only.** The Audit view's chain is process-local, capped + at 200 records, and signed with a keypair minted at startup. The durable + equivalent is `fathom.chained_log.ChainedAttestationLog`. + +**How to use today:** [Running Policy Studio](../how-to/policy-studio.md). + ## Known blockers - **Proto ↔ `go.mod` path alignment** — previously flagged as @@ -4809,14 +5702,16 @@ Install via `pip install fathom-rules[langchain]`, `fathom-rules[crewai]`, `go_package = "github.com/KrakenNet/fathom-go/proto;fathomv1"`, matching `packages/fathom-go/go.mod:1`. Generated bindings now live in `packages/fathom-go/proto/{fathom.pb.go,fathom_grpc.pb.go}`. -- **No CI for the TypeScript or editor packages.** The Python test suite - (1551 tests, `.github/workflows/ci.yml`) and the Go suite - (`.github/workflows/go-ci.yml`, unit + `-tags integration`) both run on - every pull request. The TypeScript vitest suite (tracked as issue - [#39](https://github.com/KrakenNet/fathom/issues/39)) and the editor - (issue [#43](https://github.com/KrakenNet/fathom/issues/43)) remain - uncovered, so every "works today" claim for those two packages reduces - to "works when run locally against a developer's machine." +- **No CI for the editor package.** The Python suite + (`.github/workflows/ci.yml`, which also covers the Studio), the Go suite + (`.github/workflows/go-ci.yml`, unit + `-tags integration`) and the + TypeScript suite (`.github/workflows/ts-ci.yml`, the required `ts-test` + check, closing issue + [#39](https://github.com/KrakenNet/fathom/issues/39)) all run on every + pull request. The editor (issue + [#43](https://github.com/KrakenNet/fathom/issues/43)) remains uncovered — + it has no test script to run — so every "works today" claim for that + package reduces to "works when run locally against a developer's machine." ## See also @@ -4824,9 +5719,9 @@ Install via `pip install fathom-rules[langchain]`, `fathom-rules[crewai]`, shipped adapters (including LangChain) live here. - [REST API](./rest/index.md) — the wire protocol the Go and TypeScript SDKs target. -- [gRPC API](./grpc/index.md) — the proto surface the Go SDK does **not** - yet implement. -- [Go SDK](./go-sdk/fathom-go.md) — gomarkdoc output for the REST client +- [gRPC API](./grpc/index.md) — the proto surface, which the Go SDK now + implements through the generated bindings in `packages/fathom-go/proto/`. +- [Go SDK](./go-sdk/fathom-go.md) — gomarkdoc output for the clients described above. - [TypeScript SDK](./typescript-sdk/index.md) — typedoc output for `@fathom-rules/sdk`. diff --git a/docs/llms.txt b/docs/llms.txt index ee33935b..fdcf79e5 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -17,6 +17,9 @@ Deterministic reasoning runtime for AI agents - [Registering a Python function](https://fathom.krakn.ai/latest/how-to/register-function/) — Expose a Python callable to CLIPS rules via Engine.register_function and call it with a raw-CLIPS test clause. - [Loading a rule pack](https://fathom.krakn.ai/latest/how-to/load-rule-pack/) — Load rules from a directory or a distributed Python package via Engine.from_rules and Engine.load_pack. - [Embedding via SDK](https://fathom.krakn.ai/latest/how-to/embed-sdk/) — Embed Fathom in Python (in-process Engine), Go, or TypeScript (both HTTP clients to the REST server). +- [Hot-reloading rulesets in a running server](https://fathom.krakn.ai/latest/how-to/hot-reload/) — Sign a ruleset, POST it to /v1/rules/reload, understand the fail-closed default and dev escape, rotate the runtime pubkey, and confirm the live ruleset with `fathom status`. +- [Verifying release signatures](https://fathom.krakn.ai/latest/how-to/release-signing/) — Install minisign, verify a Fathom wheel or sdist, and understand the custody + rotation policy for the release key. +- [Running Policy Studio](https://fathom.krakn.ai/latest/how-to/policy-studio/) — Start the Studio from a checkout, get past its token gate, and know what its five views drive — the real engine, mounted in the same process. - [Concepts](https://fathom.krakn.ai/latest/concepts/) — Understanding-oriented pages on Fathom's primitives, runtime model, and deferred CLIPS features. - [Five Primitives](https://fathom.krakn.ai/latest/concepts/five-primitives/) — Templates, Facts, Rules, Modules, Functions — the author-level vocabulary Fathom compiles to CLIPS. - [Runtime & Working Memory](https://fathom.krakn.ai/latest/concepts/runtime-and-working-memory/) — How the Fathom engine runs — sessions, the evaluation loop, module focus, and why deny rules take lower salience under last-write-wins. @@ -24,6 +27,7 @@ Deterministic reasoning runtime for AI agents - [Audit & Attestation](https://fathom.krakn.ai/latest/concepts/audit-attestation/) — Why every Fathom evaluation is recorded, and how the optional Ed25519 JWT turns a log entry into portable proof. - [CLIPS Features Not In v1](https://fathom.krakn.ai/latest/concepts/not-in-v1/) — The CLIPS features Fathom deliberately omits from its authored surface in v1, and what to use instead. - [Reference](https://fathom.krakn.ai/latest/reference/) — Generated reference for every Fathom SDK, API, and tooling surface. +- [Configuration](https://fathom.krakn.ai/latest/reference/configuration/) — Every FATHOM_* environment variable the runtime reads, what it defaults to, and the gRPC TLS and token setup the servers require. - [Python SDK](https://fathom.krakn.ai/latest/reference/python-sdk/) - [Go SDK](https://fathom.krakn.ai/latest/reference/go-sdk/) — Fathom Go client reference (generated from godoc via gomarkdoc) - [TypeScript SDK](https://fathom.krakn.ai/latest/reference/typescript-sdk/) — Fathom TS client reference (generated from TSDoc via typedoc) diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md new file mode 100644 index 00000000..6263ffb1 --- /dev/null +++ b/docs/reference/configuration.md @@ -0,0 +1,103 @@ +--- +title: Configuration +summary: Every FATHOM_* environment variable the runtime reads, what it defaults to, and the gRPC TLS and token setup the servers require. +audience: [app-developers] +diataxis: reference +status: stable +sources: + - src/fathom/integrations/rest.py + - src/fathom/integrations/grpc_server.py + - src/fathom/integrations/auth.py + - src/fathom/metrics.py + - src/fathom/cli.py +last_verified: 2026-08-20 +--- + +# Configuration + +Fathom as a library takes its configuration through `Engine(...)` arguments and +needs no environment. Everything below configures the **servers** — the REST +app, the gRPC server, and the CLI's client commands. + +Each variable is read from the process environment. Where a variable is read +per request rather than once at import, the table says so: those can be changed +without a restart. + +## All variables + +| Variable | Read by | Default | Effect | +|---|---|---|---| +| `FATHOM_API_TOKEN` | REST, gRPC | *(none — required)* | The data-plane bearer token. Every `/v1/*` route and every RPC compares the presented `Authorization: Bearer …` against it with `hmac.compare_digest`. Unset means server mode refuses to authenticate anything. Read per verification. | +| `FATHOM_ADMIN_TOKEN` | REST, gRPC | *(unset)* | Scopes the reload surface. When set, `POST /v1/rules/reload` and the `Reload` RPC accept **only** this token and the data-plane token stops working for them. When unset, reload falls back to `FATHOM_API_TOKEN`. | +| `FATHOM_RULESET_ROOT` | REST | *(none — required)* | Directory that every caller-supplied ruleset path is jailed under. A request naming a path outside it is rejected with 400; an unset root is a 500, never an unjailed read. | +| `FATHOM_RULESET_PUBKEY_PATH` | REST | *(none)* | PEM path of the public key that signed rulesets are verified against. Required at startup unless the dev escape below is engaged — a missing or unreadable key is a startup failure, not a warning. | +| `FATHOM_ALLOW_UNSIGNED_RULESETS` | REST | `0` | Set to `1` **and** build the app with `require_signature=False` to accept unsigned reload payloads. Both are required; either alone keeps signature checking on. Logs a warning on every boot that uses it. | +| `FATHOM_MAX_REQUEST_BYTES` | REST | `2097152` (2 MB) | Body cap on the data-plane routes, enforced on bytes actually received rather than `Content-Length`. `/v1/evaluate` runs a quadratic join over the facts it is given, so this bounds server CPU, not just memory. A non-numeric or non-positive value falls back to the default. Read per request. | +| `FATHOM_MAX_RELOAD_BYTES` | REST, gRPC | `5242880` (5 MB) | Body cap on the reload route specifically, which the data-plane cap skips. gRPC's own receive limit is set below this. Same fallback behaviour. Read per request. | +| `FATHOM_EXPOSE_DOCS` | REST | `0` | Set to `1` to serve `/docs`, `/redoc`, and `/openapi.json`. Off by default because they publish route and schema names to unauthenticated callers. Read once at import. | +| `FATHOM_GRPC_TLS_CERT` | gRPC | *(none)* | PEM certificate path. Required with `FATHOM_GRPC_TLS_KEY` unless insecure mode is opted into. | +| `FATHOM_GRPC_TLS_KEY` | gRPC | *(none)* | PEM private-key path, paired with the certificate above. | +| `FATHOM_GRPC_ALLOW_INSECURE` | gRPC | `0` | Set to `1` to bind a plaintext port when no TLS pair is configured. See the warning below. | +| `FATHOM_METRICS` | Engine | `0` | Set to `1` to enable Prometheus metrics collection without passing `enabled=True` to `MetricsCollector`. No-ops when `prometheus_client` is not installed, so setting it is safe on an install without the `metrics` extra. | +| `FATHOM_TOKEN` | CLI | *(unset)* | Default bearer token for CLI commands that call a server. `--token` overrides it. | + +## gRPC TLS + +**The gRPC server refuses to start without TLS.** `serve()` binds a secure port +only when both `FATHOM_GRPC_TLS_CERT` and `FATHOM_GRPC_TLS_KEY` name readable +PEM files: + +```bash +export FATHOM_GRPC_TLS_CERT=/etc/fathom/tls/server.crt +export FATHOM_GRPC_TLS_KEY=/etc/fathom/tls/server.key +export FATHOM_API_TOKEN="$(cat /run/secrets/fathom-api-token)" +``` + +```python +from fathom.integrations.grpc_server import serve + +server = serve(port=50051) +server.wait_for_termination() +``` + +With neither the pair nor an explicit opt-in, `serve()` raises `RuntimeError` +naming the three variables. That is deliberate: the bearer token travels in an +RPC metadata header, so an insecure port hands every token to any passive +observer on the path. + +The opt-in exists for local development and for deployments that terminate TLS +in a sidecar on the same host: + +```bash +export FATHOM_GRPC_ALLOW_INSECURE=1 # plaintext port; token sent in the clear +``` + +Do not set it on a port reachable from another host. If a service mesh already +terminates TLS, keep the plaintext listener bound to loopback. + +Client-certificate authentication (mTLS) is not wired up: the server passes a +single key pair to `grpc.ssl_server_credentials` and does not request a client +certificate. Callers are authenticated by bearer token only. + +## Server tokens + +Both server surfaces authenticate the same way — a bearer token compared in +constant time — and both distinguish two scopes: + +- **Data plane** (`FATHOM_API_TOKEN`): evaluate, assert, query, retract. +- **Admin** (`FATHOM_ADMIN_TOKEN`): ruleset reload. + +Setting only `FATHOM_API_TOKEN` means one token does both. Setting both means +a leaked data-plane token cannot swap the policy the server enforces, which is +the split worth having in production. See +[Hot-reloading rulesets](../how-to/hot-reload.md) for the rest of the reload +security model. + +## What is not configurable by environment + +- **Rule paths and engine options** — `Engine(...)` arguments: audit sink, + attestation service, session id, evaluation `run_limit`. +- **Ports and worker counts** — `serve(port=…, max_workers=…)` for gRPC, and + the ASGI server's own flags for REST (`uvicorn --port`). +- **Per-rule behaviour** — salience, module focus, and log level are properties + of the ruleset, not of the deployment. diff --git a/docs/reference/go-sdk/fathom-go.md b/docs/reference/go-sdk/fathom-go.md index 28d9b0dd..63d3277a 100644 --- a/docs/reference/go-sdk/fathom-go.md +++ b/docs/reference/go-sdk/fathom-go.md @@ -57,7 +57,7 @@ var ErrRulesetReloaded = errors.New("fathom: ruleset reloaded; re-subscribe to b ``` -## type [AssertFactRequest]() +## type [AssertFactRequest]() AssertFactRequest is the payload for POST /v1/facts. @@ -70,7 +70,7 @@ type AssertFactRequest struct { ``` -## type [AssertFactResponse]() +## type [AssertFactResponse]() AssertFactResponse is the response from POST /v1/facts. @@ -81,7 +81,7 @@ type AssertFactResponse struct { ``` -## type [ChangeType]() +## type [ChangeType]() ChangeType mirrors the proto's fact\-change kinds. @@ -124,7 +124,7 @@ client := NewClient("http://localhost:8000", WithBearerToken("secret")) ``` -### func \(\*Client\) [AssertFact]() +### func \(\*Client\) [AssertFact]() ```go func (c *Client) AssertFact(ctx context.Context, req *AssertFactRequest) (*AssertFactResponse, error) @@ -133,7 +133,7 @@ func (c *Client) AssertFact(ctx context.Context, req *AssertFactRequest) (*Asser AssertFact asserts a single fact into the session's working memory. -### func \(\*Client\) [Evaluate]() +### func \(\*Client\) [Evaluate]() ```go func (c *Client) Evaluate(ctx context.Context, req *EvaluateRequest) (*EvaluateResponse, error) @@ -142,7 +142,7 @@ func (c *Client) Evaluate(ctx context.Context, req *EvaluateRequest) (*EvaluateR Evaluate sends facts to the engine and returns the policy decision. -### func \(\*Client\) [Query]() +### func \(\*Client\) [Query]() ```go func (c *Client) Query(ctx context.Context, req *QueryRequest) (*QueryResponse, error) @@ -151,7 +151,7 @@ func (c *Client) Query(ctx context.Context, req *QueryRequest) (*QueryResponse, Query retrieves facts from the session's working memory. -### func \(\*Client\) [Retract]() +### func \(\*Client\) [Retract]() ```go func (c *Client) Retract(ctx context.Context, req *RetractRequest) (*RetractResponse, error) @@ -200,23 +200,24 @@ type EvaluateRequest struct { ``` -## type [EvaluateResponse]() +## type [EvaluateResponse]() EvaluateResponse is the response from POST /v1/evaluate. ```go type EvaluateResponse struct { - Decision string `json:"decision"` - Reason string `json:"reason"` - RuleTrace []string `json:"rule_trace"` - ModuleTrace []string `json:"module_trace"` - DurationUS int64 `json:"duration_us"` - AttestationToken string `json:"attestation_token,omitempty"` + Decision string `json:"decision"` + Reason string `json:"reason"` + RuleTrace []string `json:"rule_trace"` + ModuleTrace []string `json:"module_trace"` + DurationUS int64 `json:"duration_us"` + Metadata map[string]string `json:"metadata,omitempty"` + AttestationToken string `json:"attestation_token,omitempty"` } ``` -## type [FactChangeEvent]() +## type [FactChangeEvent]() FactChangeEvent is a single working\-memory change yielded by SubscribeChanges. DataJSON's slot data is decoded into Data for convenience. @@ -270,7 +271,7 @@ defer c.Close() ``` -### func \(\*GRPCClient\) [AssertFact]() +### func \(\*GRPCClient\) [AssertFact]() ```go func (c *GRPCClient) AssertFact(ctx context.Context, req *AssertFactRequest) (*AssertFactResponse, error) @@ -299,7 +300,7 @@ Evaluate sends facts to the engine and returns the policy decision. The Evaluate Note: unlike the REST EvaluateResponse, the gRPC EvaluateResponse carries no attestation\_token \(the proto omits it\), so that field is always empty here. -### func \(\*GRPCClient\) [Query]() +### func \(\*GRPCClient\) [Query]() ```go func (c *GRPCClient) Query(ctx context.Context, req *QueryRequest) (*QueryResponse, error) @@ -308,7 +309,7 @@ func (c *GRPCClient) Query(ctx context.Context, req *QueryRequest) (*QueryRespon Query retrieves facts from the session's working memory. -### func \(\*GRPCClient\) [Reload]() +### func \(\*GRPCClient\) [Reload]() ```go func (c *GRPCClient) Reload(ctx context.Context, req *ReloadRequest) (*ReloadResponse, error) @@ -317,7 +318,7 @@ func (c *GRPCClient) Reload(ctx context.Context, req *ReloadRequest) (*ReloadRes Reload hot\-reloads the engine's ruleset from an inline YAML body or a server\-side path. A successful reload aborts every in\-flight SubscribeChanges stream with ErrRulesetReloaded \(ADR\-0002\). -### func \(\*GRPCClient\) [Retract]() +### func \(\*GRPCClient\) [Retract]() ```go func (c *GRPCClient) Retract(ctx context.Context, req *RetractRequest) (*RetractResponse, error) @@ -326,7 +327,7 @@ func (c *GRPCClient) Retract(ctx context.Context, req *RetractRequest) (*Retract Retract removes facts matching the request's template \+ optional filter and returns the number of retractions. -### func \(\*GRPCClient\) [SubscribeChanges]() +### func \(\*GRPCClient\) [SubscribeChanges]() ```go func (c *GRPCClient) SubscribeChanges(ctx context.Context, sessionID string, fn func(FactChangeEvent) error) error @@ -382,7 +383,7 @@ WithGRPCInsecure permits an insecure \(plaintext\) transport and allows bearer c When not set, NewGRPCClient defaults to TLS transport credentials. -## type [QueryRequest]() +## type [QueryRequest]() QueryRequest is the payload for POST /v1/query. @@ -395,7 +396,7 @@ type QueryRequest struct { ``` -## type [QueryResponse]() +## type [QueryResponse]() QueryResponse is the response from POST /v1/query. @@ -406,7 +407,7 @@ type QueryResponse struct { ``` -## type [ReloadRequest]() +## type [ReloadRequest]() ReloadRequest is the payload for the Reload RPC. Exactly one of RulesetPath or RulesetYAML should be set \(mirroring the proto's oneof source\). Signature is the optional detached signature bytes for the ruleset. @@ -419,7 +420,7 @@ type ReloadRequest struct { ``` -## type [ReloadResponse]() +## type [ReloadResponse]() ReloadResponse is the response from the Reload RPC. @@ -432,7 +433,7 @@ type ReloadResponse struct { ``` -## type [RetractRequest]() +## type [RetractRequest]() RetractRequest is the payload for DELETE /v1/facts. @@ -445,7 +446,7 @@ type RetractRequest struct { ``` -## type [RetractResponse]() +## type [RetractResponse]() RetractResponse is the response from DELETE /v1/facts. @@ -500,8 +501,10 @@ import "github.com/KrakenNet/fathom-go/proto" - [func \(x \*EvaluateRequest\) String\(\) string](<#EvaluateRequest.String>) - [type EvaluateResponse](<#EvaluateResponse>) - [func \(\*EvaluateResponse\) Descriptor\(\) \(\[\]byte, \[\]int\)](<#EvaluateResponse.Descriptor>) + - [func \(x \*EvaluateResponse\) GetAttestationToken\(\) string](<#EvaluateResponse.GetAttestationToken>) - [func \(x \*EvaluateResponse\) GetDecision\(\) string](<#EvaluateResponse.GetDecision>) - [func \(x \*EvaluateResponse\) GetDurationUs\(\) int64](<#EvaluateResponse.GetDurationUs>) + - [func \(x \*EvaluateResponse\) GetMetadata\(\) map\[string\]string](<#EvaluateResponse.GetMetadata>) - [func \(x \*EvaluateResponse\) GetModuleTrace\(\) \[\]string](<#EvaluateResponse.GetModuleTrace>) - [func \(x \*EvaluateResponse\) GetReason\(\) string](<#EvaluateResponse.GetReason>) - [func \(x \*EvaluateResponse\) GetRuleTrace\(\) \[\]string](<#EvaluateResponse.GetRuleTrace>) @@ -690,7 +693,7 @@ func RegisterFathomServiceServer(s grpc.ServiceRegistrar, srv FathomServiceServe -## type [AssertFactRequest]() +## type [AssertFactRequest]() @@ -705,7 +708,7 @@ type AssertFactRequest struct { ``` -### func \(\*AssertFactRequest\) [Descriptor]() +### func \(\*AssertFactRequest\) [Descriptor]() ```go func (*AssertFactRequest) Descriptor() ([]byte, []int) @@ -714,7 +717,7 @@ func (*AssertFactRequest) Descriptor() ([]byte, []int) Deprecated: Use AssertFactRequest.ProtoReflect.Descriptor instead. -### func \(\*AssertFactRequest\) [GetDataJson]() +### func \(\*AssertFactRequest\) [GetDataJson]() ```go func (x *AssertFactRequest) GetDataJson() string @@ -723,7 +726,7 @@ func (x *AssertFactRequest) GetDataJson() string -### func \(\*AssertFactRequest\) [GetSessionId]() +### func \(\*AssertFactRequest\) [GetSessionId]() ```go func (x *AssertFactRequest) GetSessionId() string @@ -732,7 +735,7 @@ func (x *AssertFactRequest) GetSessionId() string -### func \(\*AssertFactRequest\) [GetTemplate]() +### func \(\*AssertFactRequest\) [GetTemplate]() ```go func (x *AssertFactRequest) GetTemplate() string @@ -741,7 +744,7 @@ func (x *AssertFactRequest) GetTemplate() string -### func \(\*AssertFactRequest\) [ProtoMessage]() +### func \(\*AssertFactRequest\) [ProtoMessage]() ```go func (*AssertFactRequest) ProtoMessage() @@ -750,7 +753,7 @@ func (*AssertFactRequest) ProtoMessage() -### func \(\*AssertFactRequest\) [ProtoReflect]() +### func \(\*AssertFactRequest\) [ProtoReflect]() ```go func (x *AssertFactRequest) ProtoReflect() protoreflect.Message @@ -759,7 +762,7 @@ func (x *AssertFactRequest) ProtoReflect() protoreflect.Message -### func \(\*AssertFactRequest\) [Reset]() +### func \(\*AssertFactRequest\) [Reset]() ```go func (x *AssertFactRequest) Reset() @@ -768,7 +771,7 @@ func (x *AssertFactRequest) Reset() -### func \(\*AssertFactRequest\) [String]() +### func \(\*AssertFactRequest\) [String]() ```go func (x *AssertFactRequest) String() string @@ -777,7 +780,7 @@ func (x *AssertFactRequest) String() string -## type [AssertFactResponse]() +## type [AssertFactResponse]() @@ -789,7 +792,7 @@ type AssertFactResponse struct { ``` -### func \(\*AssertFactResponse\) [Descriptor]() +### func \(\*AssertFactResponse\) [Descriptor]() ```go func (*AssertFactResponse) Descriptor() ([]byte, []int) @@ -798,7 +801,7 @@ func (*AssertFactResponse) Descriptor() ([]byte, []int) Deprecated: Use AssertFactResponse.ProtoReflect.Descriptor instead. -### func \(\*AssertFactResponse\) [GetSuccess]() +### func \(\*AssertFactResponse\) [GetSuccess]() ```go func (x *AssertFactResponse) GetSuccess() bool @@ -807,7 +810,7 @@ func (x *AssertFactResponse) GetSuccess() bool -### func \(\*AssertFactResponse\) [ProtoMessage]() +### func \(\*AssertFactResponse\) [ProtoMessage]() ```go func (*AssertFactResponse) ProtoMessage() @@ -816,7 +819,7 @@ func (*AssertFactResponse) ProtoMessage() -### func \(\*AssertFactResponse\) [ProtoReflect]() +### func \(\*AssertFactResponse\) [ProtoReflect]() ```go func (x *AssertFactResponse) ProtoReflect() protoreflect.Message @@ -825,7 +828,7 @@ func (x *AssertFactResponse) ProtoReflect() protoreflect.Message -### func \(\*AssertFactResponse\) [Reset]() +### func \(\*AssertFactResponse\) [Reset]() ```go func (x *AssertFactResponse) Reset() @@ -834,7 +837,7 @@ func (x *AssertFactResponse) Reset() -### func \(\*AssertFactResponse\) [String]() +### func \(\*AssertFactResponse\) [String]() ```go func (x *AssertFactResponse) String() string @@ -1002,23 +1005,34 @@ func (x *EvaluateRequest) String() string -## type [EvaluateResponse]() +## type [EvaluateResponse]() ```go type EvaluateResponse struct { - Decision string `protobuf:"bytes,1,opt,name=decision,proto3" json:"decision,omitempty"` - Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` + + // Optional so that "no rule produced a decision" is distinguishable from + // a rule that decided the empty string. proto3 has no other way to say it: + // an unset non-optional string arrives as "" at every client. + Decision *string `protobuf:"bytes,1,opt,name=decision,proto3,oneof" json:"decision,omitempty"` + Reason *string `protobuf:"bytes,2,opt,name=reason,proto3,oneof" json:"reason,omitempty"` RuleTrace []string `protobuf:"bytes,3,rep,name=rule_trace,json=ruleTrace,proto3" json:"rule_trace,omitempty"` ModuleTrace []string `protobuf:"bytes,4,rep,name=module_trace,json=moduleTrace,proto3" json:"module_trace,omitempty"` DurationUs int64 `protobuf:"varint,5,opt,name=duration_us,json=durationUs,proto3" json:"duration_us,omitempty"` + // The `then.metadata` of the rule that wrote the decision. Carried here so + // a gRPC caller sees the same result shape a REST caller does; control ids + // and other rule-authored context live in it. + Metadata map[string]string `protobuf:"bytes,6,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Ed25519 JWT over this evaluation, present only when the server was built + // with an attestation service. + AttestationToken *string `protobuf:"bytes,7,opt,name=attestation_token,json=attestationToken,proto3,oneof" json:"attestation_token,omitempty"` // contains filtered or unexported fields } ``` -### func \(\*EvaluateResponse\) [Descriptor]() +### func \(\*EvaluateResponse\) [Descriptor]() ```go func (*EvaluateResponse) Descriptor() ([]byte, []int) @@ -1026,8 +1040,17 @@ func (*EvaluateResponse) Descriptor() ([]byte, []int) Deprecated: Use EvaluateResponse.ProtoReflect.Descriptor instead. + +### func \(\*EvaluateResponse\) [GetAttestationToken]() + +```go +func (x *EvaluateResponse) GetAttestationToken() string +``` + + + -### func \(\*EvaluateResponse\) [GetDecision]() +### func \(\*EvaluateResponse\) [GetDecision]() ```go func (x *EvaluateResponse) GetDecision() string @@ -1036,7 +1059,7 @@ func (x *EvaluateResponse) GetDecision() string -### func \(\*EvaluateResponse\) [GetDurationUs]() +### func \(\*EvaluateResponse\) [GetDurationUs]() ```go func (x *EvaluateResponse) GetDurationUs() int64 @@ -1044,8 +1067,17 @@ func (x *EvaluateResponse) GetDurationUs() int64 + +### func \(\*EvaluateResponse\) [GetMetadata]() + +```go +func (x *EvaluateResponse) GetMetadata() map[string]string +``` + + + -### func \(\*EvaluateResponse\) [GetModuleTrace]() +### func \(\*EvaluateResponse\) [GetModuleTrace]() ```go func (x *EvaluateResponse) GetModuleTrace() []string @@ -1054,7 +1086,7 @@ func (x *EvaluateResponse) GetModuleTrace() []string -### func \(\*EvaluateResponse\) [GetReason]() +### func \(\*EvaluateResponse\) [GetReason]() ```go func (x *EvaluateResponse) GetReason() string @@ -1063,7 +1095,7 @@ func (x *EvaluateResponse) GetReason() string -### func \(\*EvaluateResponse\) [GetRuleTrace]() +### func \(\*EvaluateResponse\) [GetRuleTrace]() ```go func (x *EvaluateResponse) GetRuleTrace() []string @@ -1072,7 +1104,7 @@ func (x *EvaluateResponse) GetRuleTrace() []string -### func \(\*EvaluateResponse\) [ProtoMessage]() +### func \(\*EvaluateResponse\) [ProtoMessage]() ```go func (*EvaluateResponse) ProtoMessage() @@ -1081,7 +1113,7 @@ func (*EvaluateResponse) ProtoMessage() -### func \(\*EvaluateResponse\) [ProtoReflect]() +### func \(\*EvaluateResponse\) [ProtoReflect]() ```go func (x *EvaluateResponse) ProtoReflect() protoreflect.Message @@ -1090,7 +1122,7 @@ func (x *EvaluateResponse) ProtoReflect() protoreflect.Message -### func \(\*EvaluateResponse\) [Reset]() +### func \(\*EvaluateResponse\) [Reset]() ```go func (x *EvaluateResponse) Reset() @@ -1099,7 +1131,7 @@ func (x *EvaluateResponse) Reset() -### func \(\*EvaluateResponse\) [String]() +### func \(\*EvaluateResponse\) [String]() ```go func (x *EvaluateResponse) String() string @@ -1108,7 +1140,7 @@ func (x *EvaluateResponse) String() string -## type [FactChange]() +## type [FactChange]() @@ -1123,7 +1155,7 @@ type FactChange struct { ``` -### func \(\*FactChange\) [Descriptor]() +### func \(\*FactChange\) [Descriptor]() ```go func (*FactChange) Descriptor() ([]byte, []int) @@ -1132,7 +1164,7 @@ func (*FactChange) Descriptor() ([]byte, []int) Deprecated: Use FactChange.ProtoReflect.Descriptor instead. -### func \(\*FactChange\) [GetChangeType]() +### func \(\*FactChange\) [GetChangeType]() ```go func (x *FactChange) GetChangeType() ChangeType @@ -1141,7 +1173,7 @@ func (x *FactChange) GetChangeType() ChangeType -### func \(\*FactChange\) [GetDataJson]() +### func \(\*FactChange\) [GetDataJson]() ```go func (x *FactChange) GetDataJson() string @@ -1150,7 +1182,7 @@ func (x *FactChange) GetDataJson() string -### func \(\*FactChange\) [GetTemplate]() +### func \(\*FactChange\) [GetTemplate]() ```go func (x *FactChange) GetTemplate() string @@ -1159,7 +1191,7 @@ func (x *FactChange) GetTemplate() string -### func \(\*FactChange\) [ProtoMessage]() +### func \(\*FactChange\) [ProtoMessage]() ```go func (*FactChange) ProtoMessage() @@ -1168,7 +1200,7 @@ func (*FactChange) ProtoMessage() -### func \(\*FactChange\) [ProtoReflect]() +### func \(\*FactChange\) [ProtoReflect]() ```go func (x *FactChange) ProtoReflect() protoreflect.Message @@ -1177,7 +1209,7 @@ func (x *FactChange) ProtoReflect() protoreflect.Message -### func \(\*FactChange\) [Reset]() +### func \(\*FactChange\) [Reset]() ```go func (x *FactChange) Reset() @@ -1186,7 +1218,7 @@ func (x *FactChange) Reset() -### func \(\*FactChange\) [String]() +### func \(\*FactChange\) [String]() ```go func (x *FactChange) String() string @@ -1346,7 +1378,7 @@ type FathomService_SubscribeChangesServer = grpc.ServerStreamingServer[FactChang ``` -## type [QueryRequest]() +## type [QueryRequest]() @@ -1361,7 +1393,7 @@ type QueryRequest struct { ``` -### func \(\*QueryRequest\) [Descriptor]() +### func \(\*QueryRequest\) [Descriptor]() ```go func (*QueryRequest) Descriptor() ([]byte, []int) @@ -1370,7 +1402,7 @@ func (*QueryRequest) Descriptor() ([]byte, []int) Deprecated: Use QueryRequest.ProtoReflect.Descriptor instead. -### func \(\*QueryRequest\) [GetFilterJson]() +### func \(\*QueryRequest\) [GetFilterJson]() ```go func (x *QueryRequest) GetFilterJson() string @@ -1379,7 +1411,7 @@ func (x *QueryRequest) GetFilterJson() string -### func \(\*QueryRequest\) [GetSessionId]() +### func \(\*QueryRequest\) [GetSessionId]() ```go func (x *QueryRequest) GetSessionId() string @@ -1388,7 +1420,7 @@ func (x *QueryRequest) GetSessionId() string -### func \(\*QueryRequest\) [GetTemplate]() +### func \(\*QueryRequest\) [GetTemplate]() ```go func (x *QueryRequest) GetTemplate() string @@ -1397,7 +1429,7 @@ func (x *QueryRequest) GetTemplate() string -### func \(\*QueryRequest\) [ProtoMessage]() +### func \(\*QueryRequest\) [ProtoMessage]() ```go func (*QueryRequest) ProtoMessage() @@ -1406,7 +1438,7 @@ func (*QueryRequest) ProtoMessage() -### func \(\*QueryRequest\) [ProtoReflect]() +### func \(\*QueryRequest\) [ProtoReflect]() ```go func (x *QueryRequest) ProtoReflect() protoreflect.Message @@ -1415,7 +1447,7 @@ func (x *QueryRequest) ProtoReflect() protoreflect.Message -### func \(\*QueryRequest\) [Reset]() +### func \(\*QueryRequest\) [Reset]() ```go func (x *QueryRequest) Reset() @@ -1424,7 +1456,7 @@ func (x *QueryRequest) Reset() -### func \(\*QueryRequest\) [String]() +### func \(\*QueryRequest\) [String]() ```go func (x *QueryRequest) String() string @@ -1433,7 +1465,7 @@ func (x *QueryRequest) String() string -## type [QueryResponse]() +## type [QueryResponse]() @@ -1447,7 +1479,7 @@ type QueryResponse struct { ``` -### func \(\*QueryResponse\) [Descriptor]() +### func \(\*QueryResponse\) [Descriptor]() ```go func (*QueryResponse) Descriptor() ([]byte, []int) @@ -1456,7 +1488,7 @@ func (*QueryResponse) Descriptor() ([]byte, []int) Deprecated: Use QueryResponse.ProtoReflect.Descriptor instead. -### func \(\*QueryResponse\) [GetFactsJson]() +### func \(\*QueryResponse\) [GetFactsJson]() ```go func (x *QueryResponse) GetFactsJson() []string @@ -1465,7 +1497,7 @@ func (x *QueryResponse) GetFactsJson() []string -### func \(\*QueryResponse\) [ProtoMessage]() +### func \(\*QueryResponse\) [ProtoMessage]() ```go func (*QueryResponse) ProtoMessage() @@ -1474,7 +1506,7 @@ func (*QueryResponse) ProtoMessage() -### func \(\*QueryResponse\) [ProtoReflect]() +### func \(\*QueryResponse\) [ProtoReflect]() ```go func (x *QueryResponse) ProtoReflect() protoreflect.Message @@ -1483,7 +1515,7 @@ func (x *QueryResponse) ProtoReflect() protoreflect.Message -### func \(\*QueryResponse\) [Reset]() +### func \(\*QueryResponse\) [Reset]() ```go func (x *QueryResponse) Reset() @@ -1492,7 +1524,7 @@ func (x *QueryResponse) Reset() -### func \(\*QueryResponse\) [String]() +### func \(\*QueryResponse\) [String]() ```go func (x *QueryResponse) String() string @@ -1501,7 +1533,7 @@ func (x *QueryResponse) String() string -## type [ReloadRequest]() +## type [ReloadRequest]() @@ -1519,7 +1551,7 @@ type ReloadRequest struct { ``` -### func \(\*ReloadRequest\) [Descriptor]() +### func \(\*ReloadRequest\) [Descriptor]() ```go func (*ReloadRequest) Descriptor() ([]byte, []int) @@ -1528,7 +1560,7 @@ func (*ReloadRequest) Descriptor() ([]byte, []int) Deprecated: Use ReloadRequest.ProtoReflect.Descriptor instead. -### func \(\*ReloadRequest\) [GetRulesetPath]() +### func \(\*ReloadRequest\) [GetRulesetPath]() ```go func (x *ReloadRequest) GetRulesetPath() string @@ -1537,7 +1569,7 @@ func (x *ReloadRequest) GetRulesetPath() string -### func \(\*ReloadRequest\) [GetRulesetYaml]() +### func \(\*ReloadRequest\) [GetRulesetYaml]() ```go func (x *ReloadRequest) GetRulesetYaml() string @@ -1546,7 +1578,7 @@ func (x *ReloadRequest) GetRulesetYaml() string -### func \(\*ReloadRequest\) [GetSignature]() +### func \(\*ReloadRequest\) [GetSignature]() ```go func (x *ReloadRequest) GetSignature() []byte @@ -1555,7 +1587,7 @@ func (x *ReloadRequest) GetSignature() []byte -### func \(\*ReloadRequest\) [GetSource]() +### func \(\*ReloadRequest\) [GetSource]() ```go func (x *ReloadRequest) GetSource() isReloadRequest_Source @@ -1564,7 +1596,7 @@ func (x *ReloadRequest) GetSource() isReloadRequest_Source -### func \(\*ReloadRequest\) [ProtoMessage]() +### func \(\*ReloadRequest\) [ProtoMessage]() ```go func (*ReloadRequest) ProtoMessage() @@ -1573,7 +1605,7 @@ func (*ReloadRequest) ProtoMessage() -### func \(\*ReloadRequest\) [ProtoReflect]() +### func \(\*ReloadRequest\) [ProtoReflect]() ```go func (x *ReloadRequest) ProtoReflect() protoreflect.Message @@ -1582,7 +1614,7 @@ func (x *ReloadRequest) ProtoReflect() protoreflect.Message -### func \(\*ReloadRequest\) [Reset]() +### func \(\*ReloadRequest\) [Reset]() ```go func (x *ReloadRequest) Reset() @@ -1591,7 +1623,7 @@ func (x *ReloadRequest) Reset() -### func \(\*ReloadRequest\) [String]() +### func \(\*ReloadRequest\) [String]() ```go func (x *ReloadRequest) String() string @@ -1600,7 +1632,7 @@ func (x *ReloadRequest) String() string -## type [ReloadRequest\\\_RulesetPath]() +## type [ReloadRequest\\\_RulesetPath]() @@ -1611,7 +1643,7 @@ type ReloadRequest_RulesetPath struct { ``` -## type [ReloadRequest\\\_RulesetYaml]() +## type [ReloadRequest\\\_RulesetYaml]() @@ -1622,7 +1654,7 @@ type ReloadRequest_RulesetYaml struct { ``` -## type [ReloadResponse]() +## type [ReloadResponse]() @@ -1636,7 +1668,7 @@ type ReloadResponse struct { ``` -### func \(\*ReloadResponse\) [Descriptor]() +### func \(\*ReloadResponse\) [Descriptor]() ```go func (*ReloadResponse) Descriptor() ([]byte, []int) @@ -1645,7 +1677,7 @@ func (*ReloadResponse) Descriptor() ([]byte, []int) Deprecated: Use ReloadResponse.ProtoReflect.Descriptor instead. -### func \(\*ReloadResponse\) [GetAttestationToken]() +### func \(\*ReloadResponse\) [GetAttestationToken]() ```go func (x *ReloadResponse) GetAttestationToken() string @@ -1654,7 +1686,7 @@ func (x *ReloadResponse) GetAttestationToken() string -### func \(\*ReloadResponse\) [GetRulesetHashAfter]() +### func \(\*ReloadResponse\) [GetRulesetHashAfter]() ```go func (x *ReloadResponse) GetRulesetHashAfter() string @@ -1663,7 +1695,7 @@ func (x *ReloadResponse) GetRulesetHashAfter() string -### func \(\*ReloadResponse\) [GetRulesetHashBefore]() +### func \(\*ReloadResponse\) [GetRulesetHashBefore]() ```go func (x *ReloadResponse) GetRulesetHashBefore() string @@ -1672,7 +1704,7 @@ func (x *ReloadResponse) GetRulesetHashBefore() string -### func \(\*ReloadResponse\) [ProtoMessage]() +### func \(\*ReloadResponse\) [ProtoMessage]() ```go func (*ReloadResponse) ProtoMessage() @@ -1681,7 +1713,7 @@ func (*ReloadResponse) ProtoMessage() -### func \(\*ReloadResponse\) [ProtoReflect]() +### func \(\*ReloadResponse\) [ProtoReflect]() ```go func (x *ReloadResponse) ProtoReflect() protoreflect.Message @@ -1690,7 +1722,7 @@ func (x *ReloadResponse) ProtoReflect() protoreflect.Message -### func \(\*ReloadResponse\) [Reset]() +### func \(\*ReloadResponse\) [Reset]() ```go func (x *ReloadResponse) Reset() @@ -1699,7 +1731,7 @@ func (x *ReloadResponse) Reset() -### func \(\*ReloadResponse\) [String]() +### func \(\*ReloadResponse\) [String]() ```go func (x *ReloadResponse) String() string @@ -1708,7 +1740,7 @@ func (x *ReloadResponse) String() string -## type [RetractRequest]() +## type [RetractRequest]() @@ -1723,7 +1755,7 @@ type RetractRequest struct { ``` -### func \(\*RetractRequest\) [Descriptor]() +### func \(\*RetractRequest\) [Descriptor]() ```go func (*RetractRequest) Descriptor() ([]byte, []int) @@ -1732,7 +1764,7 @@ func (*RetractRequest) Descriptor() ([]byte, []int) Deprecated: Use RetractRequest.ProtoReflect.Descriptor instead. -### func \(\*RetractRequest\) [GetFilterJson]() +### func \(\*RetractRequest\) [GetFilterJson]() ```go func (x *RetractRequest) GetFilterJson() string @@ -1741,7 +1773,7 @@ func (x *RetractRequest) GetFilterJson() string -### func \(\*RetractRequest\) [GetSessionId]() +### func \(\*RetractRequest\) [GetSessionId]() ```go func (x *RetractRequest) GetSessionId() string @@ -1750,7 +1782,7 @@ func (x *RetractRequest) GetSessionId() string -### func \(\*RetractRequest\) [GetTemplate]() +### func \(\*RetractRequest\) [GetTemplate]() ```go func (x *RetractRequest) GetTemplate() string @@ -1759,7 +1791,7 @@ func (x *RetractRequest) GetTemplate() string -### func \(\*RetractRequest\) [ProtoMessage]() +### func \(\*RetractRequest\) [ProtoMessage]() ```go func (*RetractRequest) ProtoMessage() @@ -1768,7 +1800,7 @@ func (*RetractRequest) ProtoMessage() -### func \(\*RetractRequest\) [ProtoReflect]() +### func \(\*RetractRequest\) [ProtoReflect]() ```go func (x *RetractRequest) ProtoReflect() protoreflect.Message @@ -1777,7 +1809,7 @@ func (x *RetractRequest) ProtoReflect() protoreflect.Message -### func \(\*RetractRequest\) [Reset]() +### func \(\*RetractRequest\) [Reset]() ```go func (x *RetractRequest) Reset() @@ -1786,7 +1818,7 @@ func (x *RetractRequest) Reset() -### func \(\*RetractRequest\) [String]() +### func \(\*RetractRequest\) [String]() ```go func (x *RetractRequest) String() string @@ -1795,7 +1827,7 @@ func (x *RetractRequest) String() string -## type [RetractResponse]() +## type [RetractResponse]() @@ -1807,7 +1839,7 @@ type RetractResponse struct { ``` -### func \(\*RetractResponse\) [Descriptor]() +### func \(\*RetractResponse\) [Descriptor]() ```go func (*RetractResponse) Descriptor() ([]byte, []int) @@ -1816,7 +1848,7 @@ func (*RetractResponse) Descriptor() ([]byte, []int) Deprecated: Use RetractResponse.ProtoReflect.Descriptor instead. -### func \(\*RetractResponse\) [GetRetractedCount]() +### func \(\*RetractResponse\) [GetRetractedCount]() ```go func (x *RetractResponse) GetRetractedCount() int32 @@ -1825,7 +1857,7 @@ func (x *RetractResponse) GetRetractedCount() int32 -### func \(\*RetractResponse\) [ProtoMessage]() +### func \(\*RetractResponse\) [ProtoMessage]() ```go func (*RetractResponse) ProtoMessage() @@ -1834,7 +1866,7 @@ func (*RetractResponse) ProtoMessage() -### func \(\*RetractResponse\) [ProtoReflect]() +### func \(\*RetractResponse\) [ProtoReflect]() ```go func (x *RetractResponse) ProtoReflect() protoreflect.Message @@ -1843,7 +1875,7 @@ func (x *RetractResponse) ProtoReflect() protoreflect.Message -### func \(\*RetractResponse\) [Reset]() +### func \(\*RetractResponse\) [Reset]() ```go func (x *RetractResponse) Reset() @@ -1852,7 +1884,7 @@ func (x *RetractResponse) Reset() -### func \(\*RetractResponse\) [String]() +### func \(\*RetractResponse\) [String]() ```go func (x *RetractResponse) String() string @@ -1861,7 +1893,7 @@ func (x *RetractResponse) String() string -## type [SubscribeRequest]() +## type [SubscribeRequest]() @@ -1873,7 +1905,7 @@ type SubscribeRequest struct { ``` -### func \(\*SubscribeRequest\) [Descriptor]() +### func \(\*SubscribeRequest\) [Descriptor]() ```go func (*SubscribeRequest) Descriptor() ([]byte, []int) @@ -1882,7 +1914,7 @@ func (*SubscribeRequest) Descriptor() ([]byte, []int) Deprecated: Use SubscribeRequest.ProtoReflect.Descriptor instead. -### func \(\*SubscribeRequest\) [GetSessionId]() +### func \(\*SubscribeRequest\) [GetSessionId]() ```go func (x *SubscribeRequest) GetSessionId() string @@ -1891,7 +1923,7 @@ func (x *SubscribeRequest) GetSessionId() string -### func \(\*SubscribeRequest\) [ProtoMessage]() +### func \(\*SubscribeRequest\) [ProtoMessage]() ```go func (*SubscribeRequest) ProtoMessage() @@ -1900,7 +1932,7 @@ func (*SubscribeRequest) ProtoMessage() -### func \(\*SubscribeRequest\) [ProtoReflect]() +### func \(\*SubscribeRequest\) [ProtoReflect]() ```go func (x *SubscribeRequest) ProtoReflect() protoreflect.Message @@ -1909,7 +1941,7 @@ func (x *SubscribeRequest) ProtoReflect() protoreflect.Message -### func \(\*SubscribeRequest\) [Reset]() +### func \(\*SubscribeRequest\) [Reset]() ```go func (x *SubscribeRequest) Reset() @@ -1918,7 +1950,7 @@ func (x *SubscribeRequest) Reset() -### func \(\*SubscribeRequest\) [String]() +### func \(\*SubscribeRequest\) [String]() ```go func (x *SubscribeRequest) String() string diff --git a/docs/reference/grpc/fathom.md b/docs/reference/grpc/fathom.md index fd01ee7d..8469522f 100644 --- a/docs/reference/grpc/fathom.md +++ b/docs/reference/grpc/fathom.md @@ -8,6 +8,7 @@ - [AssertFactResponse](#fathom-v1-AssertFactResponse) - [EvaluateRequest](#fathom-v1-EvaluateRequest) - [EvaluateResponse](#fathom-v1-EvaluateResponse) + - [EvaluateResponse.MetadataEntry](#fathom-v1-EvaluateResponse-MetadataEntry) - [FactChange](#fathom-v1-FactChange) - [FactInput](#fathom-v1-FactInput) - [QueryRequest](#fathom-v1-QueryRequest) @@ -90,11 +91,29 @@ | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| decision | [string](#string) | | | -| reason | [string](#string) | | | +| decision | [string](#string) | optional | Optional so that "no rule produced a decision" is distinguishable from a rule that decided the empty string. proto3 has no other way to say it: an unset non-optional string arrives as "" at every client. | +| reason | [string](#string) | optional | | | rule_trace | [string](#string) | repeated | | | module_trace | [string](#string) | repeated | | | duration_us | [int64](#int64) | | | +| metadata | [EvaluateResponse.MetadataEntry](#fathom-v1-EvaluateResponse-MetadataEntry) | repeated | The `then.metadata` of the rule that wrote the decision. Carried here so a gRPC caller sees the same result shape a REST caller does; control ids and other rule-authored context live in it. | +| attestation_token | [string](#string) | optional | Ed25519 JWT over this evaluation, present only when the server was built with an attestation service. | + + + + + + + + +### EvaluateResponse.MetadataEntry + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| key | [string](#string) | | | +| value | [string](#string) | | | diff --git a/docs/reference/grpc/fathom.proto b/docs/reference/grpc/fathom.proto index ca1774f8..f80efe88 100644 --- a/docs/reference/grpc/fathom.proto +++ b/docs/reference/grpc/fathom.proto @@ -52,11 +52,21 @@ message EvaluateRequest { } message EvaluateResponse { - string decision = 1; - string reason = 2; + // Optional so that "no rule produced a decision" is distinguishable from + // a rule that decided the empty string. proto3 has no other way to say it: + // an unset non-optional string arrives as "" at every client. + optional string decision = 1; + optional string reason = 2; repeated string rule_trace = 3; repeated string module_trace = 4; int64 duration_us = 5; + // The `then.metadata` of the rule that wrote the decision. Carried here so + // a gRPC caller sees the same result shape a REST caller does; control ids + // and other rule-authored context live in it. + map metadata = 6; + // Ed25519 JWT over this evaluation, present only when the server was built + // with an attestation service. + optional string attestation_token = 7; } // --------------------------------------------------------------------------- diff --git a/docs/reference/index.md b/docs/reference/index.md index 8f5839ba..6dcd18c9 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -4,13 +4,26 @@ summary: Generated reference for every Fathom SDK, API, and tooling surface. audience: [app-developers, rule-authors, contributors] diataxis: reference status: stable -last_verified: 2026-04-15 +last_verified: 2026-08-20 --- # Reference -Every public surface of Fathom is documented here. All pages under this tab -are generated from source — hand edits will be overwritten on next build. +Every public surface of Fathom is documented here. + +Most pages under this tab are generated from source — the SDK, REST, gRPC, MCP, +CLI, and rule-pack references are rewritten by `make docs-gen`, so hand edits to +them are overwritten on the next build. The YAML pages and +[Configuration](configuration.md) are hand-written and cite the files they +describe in their frontmatter. + +Which of these surfaces the project promises to keep working is +[VERSIONING.md](https://github.com/KrakenNet/fathom/blob/main/VERSIONING.md). + +## Deployment + +- [Configuration](configuration.md) — every `FATHOM_*` variable, gRPC TLS, and + the server token scopes ## SDKs diff --git a/docs/reference/planned-integrations.md b/docs/reference/planned-integrations.md index 27a7119d..d821c745 100644 --- a/docs/reference/planned-integrations.md +++ b/docs/reference/planned-integrations.md @@ -10,12 +10,13 @@ sources: - packages/fathom-ts/package.json - packages/fathom-ts/src/client.ts - packages/fathom-editor/package.json + - packages/fathom-studio/pyproject.toml - src/fathom/integrations/langchain.py - src/fathom/integrations/crewai.py - src/fathom/integrations/openai_agents.py - src/fathom/integrations/google_adk.py - protos/fathom.proto -last_verified: 2026-08-19 +last_verified: 2026-08-20 --- # Planned Integrations @@ -39,7 +40,7 @@ Each entry declares a **Status** of one of: **Status:** Partial. **Location:** `packages/fathom-go/` — a hand-written REST client plus -generated gRPC bindings. Package contents: `client.go` (180 lines), +generated gRPC bindings. Package contents: `client.go` (181 lines), `client_test.go` (818 lines), `grpc_test.go` (230 lines, build-tagged `integration`), `tools.go`, `go.mod`, `go.sum`, `Makefile`, and `proto/` with `fathom.pb.go` + `fathom_grpc.pb.go`. `go.mod` declares @@ -180,6 +181,36 @@ known-bad decisions would fail open on anything it had not heard of. Install via `pip install fathom-rules[langchain]`, `fathom-rules[crewai]`, `fathom-rules[openai-agents]`, or `fathom-rules[google-adk]`. +## Policy Studio — `packages/fathom-studio/` + +**Status:** Partial. + +**Location:** `packages/fathom-studio/` — package identity `fathom-studio` at +`0.1.0`, a uv workspace member of this repo. It depends on `fathom-rules` +like any other consumer; the engine wheel ships no Studio code. + +**What works today:** A browser UI over a real engine — five views (Reasoning +Bench, Live Wire, Rules, Templates, Audit) served as a zero-build React SPA, +a JSON backend under `/studio/api`, server-rendered HTMX panels, and the +production REST app mounted in the same process under `/api`. Nine demo +scenarios ship as package data, held byte-identical to the repo's +`examples/0N-*` directories by a test. Its pytest suite runs in CI: root +`testpaths` includes `packages/fathom-studio/tests`, so the required `test` +job covers it. + +**What is missing:** + +- **No published release.** `fathom-studio` is not on PyPI and no workflow + builds or publishes it; run it from a checkout with `uv run fathom-studio`. +- **No stability promise.** The `/studio/api/*` routes are unversioned and + explicitly excluded from + [VERSIONING.md](https://github.com/KrakenNet/fathom/blob/main/VERSIONING.md). +- **In-memory audit only.** The Audit view's chain is process-local, capped + at 200 records, and signed with a keypair minted at startup. The durable + equivalent is `fathom.chained_log.ChainedAttestationLog`. + +**How to use today:** [Running Policy Studio](../how-to/policy-studio.md). + ## Known blockers - **Proto ↔ `go.mod` path alignment** — previously flagged as @@ -190,14 +221,16 @@ Install via `pip install fathom-rules[langchain]`, `fathom-rules[crewai]`, `go_package = "github.com/KrakenNet/fathom-go/proto;fathomv1"`, matching `packages/fathom-go/go.mod:1`. Generated bindings now live in `packages/fathom-go/proto/{fathom.pb.go,fathom_grpc.pb.go}`. -- **No CI for the TypeScript or editor packages.** The Python test suite - (1551 tests, `.github/workflows/ci.yml`) and the Go suite - (`.github/workflows/go-ci.yml`, unit + `-tags integration`) both run on - every pull request. The TypeScript vitest suite (tracked as issue - [#39](https://github.com/KrakenNet/fathom/issues/39)) and the editor - (issue [#43](https://github.com/KrakenNet/fathom/issues/43)) remain - uncovered, so every "works today" claim for those two packages reduces - to "works when run locally against a developer's machine." +- **No CI for the editor package.** The Python suite + (`.github/workflows/ci.yml`, which also covers the Studio), the Go suite + (`.github/workflows/go-ci.yml`, unit + `-tags integration`) and the + TypeScript suite (`.github/workflows/ts-ci.yml`, the required `ts-test` + check, closing issue + [#39](https://github.com/KrakenNet/fathom/issues/39)) all run on every + pull request. The editor (issue + [#43](https://github.com/KrakenNet/fathom/issues/43)) remains uncovered — + it has no test script to run — so every "works today" claim for that + package reduces to "works when run locally against a developer's machine." ## See also @@ -205,9 +238,9 @@ Install via `pip install fathom-rules[langchain]`, `fathom-rules[crewai]`, shipped adapters (including LangChain) live here. - [REST API](./rest/index.md) — the wire protocol the Go and TypeScript SDKs target. -- [gRPC API](./grpc/index.md) — the proto surface the Go SDK does **not** - yet implement. -- [Go SDK](./go-sdk/fathom-go.md) — gomarkdoc output for the REST client +- [gRPC API](./grpc/index.md) — the proto surface, which the Go SDK now + implements through the generated bindings in `packages/fathom-go/proto/`. +- [Go SDK](./go-sdk/fathom-go.md) — gomarkdoc output for the clients described above. - [TypeScript SDK](./typescript-sdk/index.md) — typedoc output for `@fathom-rules/sdk`. diff --git a/docs/reference/python-sdk/attestationerror.md b/docs/reference/python-sdk/attestationerror.md new file mode 100644 index 00000000..5e25ad3d --- /dev/null +++ b/docs/reference/python-sdk/attestationerror.md @@ -0,0 +1,3 @@ +# `fathom.AttestationError` + +::: fathom.AttestationError diff --git a/docs/reference/python-sdk/attestationservice.md b/docs/reference/python-sdk/attestationservice.md new file mode 100644 index 00000000..968e0a2a --- /dev/null +++ b/docs/reference/python-sdk/attestationservice.md @@ -0,0 +1,3 @@ +# `fathom.AttestationService` + +::: fathom.AttestationService diff --git a/docs/reference/python-sdk/auditlog.md b/docs/reference/python-sdk/auditlog.md new file mode 100644 index 00000000..0a5ae9f3 --- /dev/null +++ b/docs/reference/python-sdk/auditlog.md @@ -0,0 +1,3 @@ +# `fathom.AuditLog` + +::: fathom.AuditLog diff --git a/docs/reference/python-sdk/auditrecord.md b/docs/reference/python-sdk/auditrecord.md new file mode 100644 index 00000000..0e3ddf77 --- /dev/null +++ b/docs/reference/python-sdk/auditrecord.md @@ -0,0 +1,3 @@ +# `fathom.AuditRecord` + +::: fathom.AuditRecord diff --git a/docs/reference/python-sdk/auditsink.md b/docs/reference/python-sdk/auditsink.md new file mode 100644 index 00000000..eff10862 --- /dev/null +++ b/docs/reference/python-sdk/auditsink.md @@ -0,0 +1,3 @@ +# `fathom.AuditSink` + +::: fathom.AuditSink diff --git a/docs/reference/python-sdk/chainedattestationlog.md b/docs/reference/python-sdk/chainedattestationlog.md new file mode 100644 index 00000000..6b3134a9 --- /dev/null +++ b/docs/reference/python-sdk/chainedattestationlog.md @@ -0,0 +1,3 @@ +# `fathom.ChainedAttestationLog` + +::: fathom.ChainedAttestationLog diff --git a/docs/reference/python-sdk/factstore.md b/docs/reference/python-sdk/factstore.md new file mode 100644 index 00000000..582de2a6 --- /dev/null +++ b/docs/reference/python-sdk/factstore.md @@ -0,0 +1,3 @@ +# `fathom.FactStore` + +::: fathom.FactStore diff --git a/docs/reference/python-sdk/fathomerror.md b/docs/reference/python-sdk/fathomerror.md new file mode 100644 index 00000000..0ab72f17 --- /dev/null +++ b/docs/reference/python-sdk/fathomerror.md @@ -0,0 +1,3 @@ +# `fathom.FathomError` + +::: fathom.FathomError diff --git a/docs/reference/python-sdk/filesink.md b/docs/reference/python-sdk/filesink.md new file mode 100644 index 00000000..220d1704 --- /dev/null +++ b/docs/reference/python-sdk/filesink.md @@ -0,0 +1,3 @@ +# `fathom.FileSink` + +::: fathom.FileSink diff --git a/docs/reference/python-sdk/fleetconnectionerror.md b/docs/reference/python-sdk/fleetconnectionerror.md new file mode 100644 index 00000000..0986edb0 --- /dev/null +++ b/docs/reference/python-sdk/fleetconnectionerror.md @@ -0,0 +1,3 @@ +# `fathom.FleetConnectionError` + +::: fathom.FleetConnectionError diff --git a/docs/reference/python-sdk/fleetengine.md b/docs/reference/python-sdk/fleetengine.md new file mode 100644 index 00000000..7e0b5c22 --- /dev/null +++ b/docs/reference/python-sdk/fleetengine.md @@ -0,0 +1,3 @@ +# `fathom.FleetEngine` + +::: fathom.FleetEngine diff --git a/docs/reference/python-sdk/fleeterror.md b/docs/reference/python-sdk/fleeterror.md new file mode 100644 index 00000000..6bdb7154 --- /dev/null +++ b/docs/reference/python-sdk/fleeterror.md @@ -0,0 +1,3 @@ +# `fathom.FleetError` + +::: fathom.FleetError diff --git a/docs/reference/python-sdk/index.md b/docs/reference/python-sdk/index.md index 19ee16d5..b053bc4b 100644 --- a/docs/reference/python-sdk/index.md +++ b/docs/reference/python-sdk/index.md @@ -4,12 +4,28 @@ Generated from `fathom.__all__` at docs-build time via mkdocstrings. ## Public symbols -- [`Engine`](engine.md) +- [`AssertSpec`](assertspec.md) +- [`AssertedFact`](assertedfact.md) +- [`AttestationError`](attestationerror.md) +- [`AttestationService`](attestationservice.md) +- [`AuditLog`](auditlog.md) +- [`AuditRecord`](auditrecord.md) +- [`AuditSink`](auditsink.md) +- [`ChainedAttestationLog`](chainedattestationlog.md) - [`CompilationError`](compilationerror.md) +- [`Engine`](engine.md) - [`EvaluationError`](evaluationerror.md) - [`EvaluationLimitError`](evaluationlimiterror.md) +- [`EvaluationResult`](evaluationresult.md) +- [`FactStore`](factstore.md) +- [`FathomError`](fathomerror.md) +- [`FileSink`](filesink.md) +- [`FleetConnectionError`](fleetconnectionerror.md) +- [`FleetEngine`](fleetengine.md) +- [`FleetError`](fleeterror.md) +- [`InMemoryFactStore`](inmemoryfactstore.md) +- [`NullSink`](nullsink.md) - [`ScopeError`](scopeerror.md) - [`ValidationError`](validationerror.md) -- [`AssertSpec`](assertspec.md) -- [`AssertedFact`](assertedfact.md) -- [`EvaluationResult`](evaluationresult.md) +- [`verify_chain`](verify_chain.md) +- [`verify_token`](verify_token.md) diff --git a/docs/reference/python-sdk/inmemoryfactstore.md b/docs/reference/python-sdk/inmemoryfactstore.md new file mode 100644 index 00000000..35a784a3 --- /dev/null +++ b/docs/reference/python-sdk/inmemoryfactstore.md @@ -0,0 +1,3 @@ +# `fathom.InMemoryFactStore` + +::: fathom.InMemoryFactStore diff --git a/docs/reference/python-sdk/nullsink.md b/docs/reference/python-sdk/nullsink.md new file mode 100644 index 00000000..57a93e10 --- /dev/null +++ b/docs/reference/python-sdk/nullsink.md @@ -0,0 +1,3 @@ +# `fathom.NullSink` + +::: fathom.NullSink diff --git a/docs/reference/python-sdk/verify_chain.md b/docs/reference/python-sdk/verify_chain.md new file mode 100644 index 00000000..e160917b --- /dev/null +++ b/docs/reference/python-sdk/verify_chain.md @@ -0,0 +1,3 @@ +# `fathom.verify_chain` + +::: fathom.verify_chain diff --git a/docs/reference/python-sdk/verify_token.md b/docs/reference/python-sdk/verify_token.md new file mode 100644 index 00000000..519878a6 --- /dev/null +++ b/docs/reference/python-sdk/verify_token.md @@ -0,0 +1,3 @@ +# `fathom.verify_token` + +::: fathom.verify_token diff --git a/docs/reference/rest/fathom.postman_collection.json b/docs/reference/rest/fathom.postman_collection.json index 5f2744b5..7ef6e7d0 100644 --- a/docs/reference/rest/fathom.postman_collection.json +++ b/docs/reference/rest/fathom.postman_collection.json @@ -579,7 +579,7 @@ "response": [ { "_postman_previewlanguage": "json", - "body": "{\n \"attestation_token\": \"string\",\n \"decision\": \"string\",\n \"duration_us\": 0,\n \"module_trace\": [\n \"string\",\n \"string\"\n ],\n \"reason\": \"string\",\n \"rule_trace\": [\n \"string\",\n \"string\"\n ]\n}", + "body": "{\n \"attestation_token\": \"string\",\n \"decision\": \"string\",\n \"duration_us\": 0,\n \"metadata\": {\n \"\": \"\"\n },\n \"module_trace\": [\n \"string\",\n \"string\"\n ],\n \"reason\": \"string\",\n \"rule_trace\": [\n \"string\",\n \"string\"\n ]\n}", "code": 200, "cookie": [], "header": [ diff --git a/docs/reference/rest/openapi.json b/docs/reference/rest/openapi.json index bb13b02a..045baf77 100644 --- a/docs/reference/rest/openapi.json +++ b/docs/reference/rest/openapi.json @@ -139,7 +139,7 @@ "type": "object" }, "EvaluateResponse": { - "description": "REST API response body from the evaluate endpoint.", + "description": "REST API response body from the evaluate endpoint.\n\nMirrors :class:`EvaluationResult` field for field, and the gRPC\n``EvaluateResponse`` message alongside it. A caller who moves between the\ntwo transports, or between the library and either of them, should not have\nto discover that one of them drops a field the others carry.", "properties": { "attestation_token": { "anyOf": [ @@ -167,6 +167,13 @@ "title": "Duration Us", "type": "integer" }, + "metadata": { + "additionalProperties": { + "type": "string" + }, + "title": "Metadata", + "type": "object" + }, "module_trace": { "items": { "type": "string" diff --git a/docs/reference/typescript-sdk/classes/FathomClient.md b/docs/reference/typescript-sdk/classes/FathomClient.md index af0dab08..44c49954 100644 --- a/docs/reference/typescript-sdk/classes/FathomClient.md +++ b/docs/reference/typescript-sdk/classes/FathomClient.md @@ -6,7 +6,7 @@ # Class: FathomClient -Defined in: [client.ts:145](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L145) +Defined in: [client.ts:147](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L147) Promise-based client for the Fathom policy engine. @@ -30,7 +30,7 @@ console.log(result.decision); // "allow" | "deny" | "escalate" | null > **new FathomClient**(`options`): `FathomClient` -Defined in: [client.ts:149](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L149) +Defined in: [client.ts:151](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L151) #### Parameters @@ -48,7 +48,7 @@ Defined in: [client.ts:149](https://github.com/KrakenNet/fathom/blob/master/pack > **assertFact**(`req`): `Promise`\<[`AssertFactResponse`](../interfaces/AssertFactResponse.md)\> -Defined in: [client.ts:177](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L177) +Defined in: [client.ts:179](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L179) Assert a single fact into the session's working memory. @@ -68,7 +68,7 @@ Assert a single fact into the session's working memory. > **evaluate**(`req`): `Promise`\<[`EvaluateResponse`](../interfaces/EvaluateResponse.md)\> -Defined in: [client.ts:172](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L172) +Defined in: [client.ts:174](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L174) Send facts to the engine and return the policy decision. @@ -88,7 +88,7 @@ Send facts to the engine and return the policy decision. > **query**(`req`): `Promise`\<[`QueryResponse`](../interfaces/QueryResponse.md)\> -Defined in: [client.ts:182](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L182) +Defined in: [client.ts:184](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L184) Retrieve facts from the session's working memory. @@ -108,7 +108,7 @@ Retrieve facts from the session's working memory. > **retract**(`req`): `Promise`\<[`RetractResponse`](../interfaces/RetractResponse.md)\> -Defined in: [client.ts:190](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L190) +Defined in: [client.ts:192](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L192) Retract facts matching the request's template + optional filter from the session's working memory. Returns the number of facts removed. diff --git a/docs/reference/typescript-sdk/interfaces/AssertFactRequest.md b/docs/reference/typescript-sdk/interfaces/AssertFactRequest.md index a5594106..5f4f2865 100644 --- a/docs/reference/typescript-sdk/interfaces/AssertFactRequest.md +++ b/docs/reference/typescript-sdk/interfaces/AssertFactRequest.md @@ -6,7 +6,7 @@ # Interface: AssertFactRequest -Defined in: [client.ts:42](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L42) +Defined in: [client.ts:44](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L44) Payload for POST /v1/facts. @@ -16,7 +16,7 @@ Payload for POST /v1/facts. > **data**: `Record`\<`string`, `unknown`\> -Defined in: [client.ts:45](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L45) +Defined in: [client.ts:47](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L47) *** @@ -24,7 +24,7 @@ Defined in: [client.ts:45](https://github.com/KrakenNet/fathom/blob/master/packa > **session\_id**: `string` -Defined in: [client.ts:43](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L43) +Defined in: [client.ts:45](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L45) *** @@ -32,4 +32,4 @@ Defined in: [client.ts:43](https://github.com/KrakenNet/fathom/blob/master/packa > **template**: `string` -Defined in: [client.ts:44](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L44) +Defined in: [client.ts:46](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L46) diff --git a/docs/reference/typescript-sdk/interfaces/AssertFactResponse.md b/docs/reference/typescript-sdk/interfaces/AssertFactResponse.md index 1af7f437..4ce4ec6f 100644 --- a/docs/reference/typescript-sdk/interfaces/AssertFactResponse.md +++ b/docs/reference/typescript-sdk/interfaces/AssertFactResponse.md @@ -6,7 +6,7 @@ # Interface: AssertFactResponse -Defined in: [client.ts:49](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L49) +Defined in: [client.ts:51](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L51) Response from POST /v1/facts. @@ -16,4 +16,4 @@ Response from POST /v1/facts. > **success**: `boolean` -Defined in: [client.ts:50](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L50) +Defined in: [client.ts:52](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L52) diff --git a/docs/reference/typescript-sdk/interfaces/EvaluateResponse.md b/docs/reference/typescript-sdk/interfaces/EvaluateResponse.md index cf58f3db..37522a7d 100644 --- a/docs/reference/typescript-sdk/interfaces/EvaluateResponse.md +++ b/docs/reference/typescript-sdk/interfaces/EvaluateResponse.md @@ -16,7 +16,7 @@ Response from POST /v1/evaluate. > `optional` **attestation\_token?**: `string` \| `null` -Defined in: [client.ts:38](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L38) +Defined in: [client.ts:40](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L40) *** @@ -36,6 +36,16 @@ Defined in: [client.ts:37](https://github.com/KrakenNet/fathom/blob/master/packa *** +### metadata + +> **metadata**: `Record`\<`string`, `string`\> + +Defined in: [client.ts:39](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L39) + +`then.metadata` of the rule that decided; `{}` when it wrote none. + +*** + ### module\_trace > **module\_trace**: `string`[] diff --git a/docs/reference/typescript-sdk/interfaces/FathomClientOptions.md b/docs/reference/typescript-sdk/interfaces/FathomClientOptions.md index 1339c40d..4a0dd73f 100644 --- a/docs/reference/typescript-sdk/interfaces/FathomClientOptions.md +++ b/docs/reference/typescript-sdk/interfaces/FathomClientOptions.md @@ -6,7 +6,7 @@ # Interface: FathomClientOptions -Defined in: [client.ts:82](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L82) +Defined in: [client.ts:84](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L84) Configuration for [FathomClient](../classes/FathomClient.md). @@ -16,7 +16,7 @@ Configuration for [FathomClient](../classes/FathomClient.md). > **baseURL**: `string` -Defined in: [client.ts:84](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L84) +Defined in: [client.ts:86](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L86) Base URL of the Fathom API server (e.g. "http://localhost:8000"). @@ -26,7 +26,7 @@ Base URL of the Fathom API server (e.g. "http://localhost:8000"). > `optional` **bearerToken?**: `string` -Defined in: [client.ts:92](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L92) +Defined in: [client.ts:94](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L94) Optional bearer token. When set, the client injects `Authorization: Bearer ` on every request. Takes precedence @@ -38,6 +38,6 @@ over any `Authorization` header supplied via [headers](#headers). > `optional` **headers?**: `Record`\<`string`, `string`\> -Defined in: [client.ts:86](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L86) +Defined in: [client.ts:88](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L88) Optional headers sent with every request. diff --git a/docs/reference/typescript-sdk/interfaces/QueryRequest.md b/docs/reference/typescript-sdk/interfaces/QueryRequest.md index 71b1e58b..e966f437 100644 --- a/docs/reference/typescript-sdk/interfaces/QueryRequest.md +++ b/docs/reference/typescript-sdk/interfaces/QueryRequest.md @@ -6,7 +6,7 @@ # Interface: QueryRequest -Defined in: [client.ts:54](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L54) +Defined in: [client.ts:56](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L56) Payload for POST /v1/query. @@ -16,7 +16,7 @@ Payload for POST /v1/query. > `optional` **filter?**: `Record`\<`string`, `unknown`\> -Defined in: [client.ts:57](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L57) +Defined in: [client.ts:59](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L59) *** @@ -24,7 +24,7 @@ Defined in: [client.ts:57](https://github.com/KrakenNet/fathom/blob/master/packa > **session\_id**: `string` -Defined in: [client.ts:55](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L55) +Defined in: [client.ts:57](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L57) *** @@ -32,4 +32,4 @@ Defined in: [client.ts:55](https://github.com/KrakenNet/fathom/blob/master/packa > **template**: `string` -Defined in: [client.ts:56](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L56) +Defined in: [client.ts:58](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L58) diff --git a/docs/reference/typescript-sdk/interfaces/QueryResponse.md b/docs/reference/typescript-sdk/interfaces/QueryResponse.md index 6c07efcd..90f2b9cb 100644 --- a/docs/reference/typescript-sdk/interfaces/QueryResponse.md +++ b/docs/reference/typescript-sdk/interfaces/QueryResponse.md @@ -6,7 +6,7 @@ # Interface: QueryResponse -Defined in: [client.ts:61](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L61) +Defined in: [client.ts:63](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L63) Response from POST /v1/query. @@ -16,4 +16,4 @@ Response from POST /v1/query. > **facts**: `Record`\<`string`, `unknown`\>[] -Defined in: [client.ts:62](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L62) +Defined in: [client.ts:64](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L64) diff --git a/docs/reference/typescript-sdk/interfaces/RetractRequest.md b/docs/reference/typescript-sdk/interfaces/RetractRequest.md index 5ef8f475..30dc6af2 100644 --- a/docs/reference/typescript-sdk/interfaces/RetractRequest.md +++ b/docs/reference/typescript-sdk/interfaces/RetractRequest.md @@ -6,7 +6,7 @@ # Interface: RetractRequest -Defined in: [client.ts:66](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L66) +Defined in: [client.ts:68](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L68) Payload for DELETE /v1/facts. @@ -16,7 +16,7 @@ Payload for DELETE /v1/facts. > `optional` **filter?**: `Record`\<`string`, `unknown`\> -Defined in: [client.ts:69](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L69) +Defined in: [client.ts:71](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L71) *** @@ -24,7 +24,7 @@ Defined in: [client.ts:69](https://github.com/KrakenNet/fathom/blob/master/packa > **session\_id**: `string` -Defined in: [client.ts:67](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L67) +Defined in: [client.ts:69](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L69) *** @@ -32,4 +32,4 @@ Defined in: [client.ts:67](https://github.com/KrakenNet/fathom/blob/master/packa > **template**: `string` -Defined in: [client.ts:68](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L68) +Defined in: [client.ts:70](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L70) diff --git a/docs/reference/typescript-sdk/interfaces/RetractResponse.md b/docs/reference/typescript-sdk/interfaces/RetractResponse.md index de6f1ab2..ffb94632 100644 --- a/docs/reference/typescript-sdk/interfaces/RetractResponse.md +++ b/docs/reference/typescript-sdk/interfaces/RetractResponse.md @@ -6,7 +6,7 @@ # Interface: RetractResponse -Defined in: [client.ts:73](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L73) +Defined in: [client.ts:75](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L75) Response from DELETE /v1/facts. @@ -16,4 +16,4 @@ Response from DELETE /v1/facts. > **retracted\_count**: `number` -Defined in: [client.ts:74](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L74) +Defined in: [client.ts:76](https://github.com/KrakenNet/fathom/blob/master/packages/fathom-ts/src/client.ts#L76) diff --git a/docs/reference/yaml/fact.md b/docs/reference/yaml/fact.md index b67f3c7c..d019c8e9 100644 --- a/docs/reference/yaml/fact.md +++ b/docs/reference/yaml/fact.md @@ -9,7 +9,7 @@ sources: - src/fathom/engine.py - src/fathom/facts.py - src/fathom/integrations/rest.py -last_verified: 2026-06-06 +last_verified: 2026-08-20 --- # Fact @@ -56,13 +56,13 @@ the SDK. ### SDK — `Engine.assert_fact` / `Engine.assert_facts` -Single (`src/fathom/engine.py` line 757): +Single (`src/fathom/engine.py` line 1179): ```python engine.assert_fact("access-request", {"subject": "alice", "action": "read"}) ``` -Atomic batch (`src/fathom/engine.py` line 775) — every fact is +Atomic batch (`src/fathom/engine.py` line 1235) — every fact is validated first; if any fails, **none** are asserted: ```python diff --git a/docs/reference/yaml/function.md b/docs/reference/yaml/function.md index f040bb2d..038a3d88 100644 --- a/docs/reference/yaml/function.md +++ b/docs/reference/yaml/function.md @@ -7,7 +7,7 @@ status: stable sources: - src/fathom/models.py - src/fathom/compiler.py -last_verified: 2026-06-05 +last_verified: 2026-08-20 --- # Function @@ -206,6 +206,9 @@ prevent collisions with the runtime's own bindings. | `type: raw` with `body is None` | `compile_function` | `CompilationError` | | `type: classification` without `hierarchy_ref` | `compile_function` | `CompilationError` | | `type: classification` with unknown `hierarchy_ref` | `compile_function` | `CompilationError` | +| Any key not defined on the model, on `FunctionDefinition` or `HierarchyDefinition` | Pydantic (`extra="forbid"`) | `ValidationError` | +| `HierarchyDefinition.name` that is not a valid CLIPS identifier | Pydantic validator | `ValueError` | +| A `levels` or `compartments` member that is not a valid CLIPS identifier | Pydantic validator | `ValueError` | ## What is not emitted diff --git a/docs/reference/yaml/index.md b/docs/reference/yaml/index.md index 173eb993..81f0b340 100644 --- a/docs/reference/yaml/index.md +++ b/docs/reference/yaml/index.md @@ -3,7 +3,7 @@ title: YAML Reference sources: - src/fathom/models.py - src/fathom/compiler.py -last_verified: 2026-06-05 +last_verified: 2026-08-20 --- # YAML Reference diff --git a/docs/reference/yaml/module.md b/docs/reference/yaml/module.md index a522d0e7..2de0db65 100644 --- a/docs/reference/yaml/module.md +++ b/docs/reference/yaml/module.md @@ -8,7 +8,8 @@ sources: - src/fathom/models.py - src/fathom/compiler.py - src/fathom/evaluator.py -last_verified: 2026-08-19 + - src/fathom/engine.py +last_verified: 2026-08-20 --- # Module @@ -70,9 +71,12 @@ modules: ## Priority and the focus stack `ModuleDefinition.priority` is a stored integer (default `0`) that the -runtime **does not read** when setting focus. Focus order is driven -exclusively by the explicit `focus_order:` list at the top of the module -YAML file (or by a later `Engine.set_focus(...)` call). +runtime **does not read** when setting focus. Focus order is driven by +the explicit `focus_order:` list at the top of the module YAML file (or +by a later `Engine.set_focus(...)` call). When no `focus_order:` is +given anywhere in the pack, `load_modules` falls back to the order the +modules were declared in — see +[When `focus_order` is omitted](#when-focus_order-is-omitted). At evaluation time, `_setup_focus_stack` in `src/fathom/evaluator.py` emits a single `(focus ...)` eval listing the modules in the order they @@ -96,6 +100,27 @@ What `priority` is actually used for today: See [Runtime & Working Memory](../../concepts/runtime-and-working-memory.md) for the full focus-stack mechanics. +### When `focus_order` is omitted + +A pack that declares modules and no `focus_order:` is focused on every +declared module, in declaration order: files in load order, and within a +file the order the `modules:` list is written. `Engine.load_modules` +applies this once, after every module file is parsed, and only if +nothing has set a focus order already — so a `focus_order:` in any file, +or an earlier `Engine.set_focus(...)`, wins outright. + +The fallback exists because omitting `focus_order:` is not a request for +a different order; it is the absence of a request. CLIPS drains only the +agenda of the module that holds the focus, so with no focus set at all, +rules scoped to a declared module never fire and the caller silently +receives the default decision — a wrong answer rather than an error. + +Declaration order is not a substitute for stating the order you want. +It is stable, but it moves when you add a module or rename a file, and +because the evaluator is last-write-wins, the module that lands last +writes the decision. Any pack whose modules must run in a particular +sequence should say so with `focus_order:`. + ### Declaring focus order ```yaml @@ -151,8 +176,8 @@ compiled CLIPS `defmodule`: - `ModuleDefinition.description` — metadata only. - `ModuleDefinition.priority` — metadata only; does not influence - focus-stack ordering in the current runtime. If you need - deterministic module ordering, set `focus_order:` explicitly. + focus-stack ordering in the current runtime. Ordering comes from + `focus_order:`, or from declaration order when that key is absent. ## See also diff --git a/docs/reference/yaml/rule.md b/docs/reference/yaml/rule.md index cb758041..0e9d4b5c 100644 --- a/docs/reference/yaml/rule.md +++ b/docs/reference/yaml/rule.md @@ -7,7 +7,7 @@ status: stable sources: - src/fathom/models.py - src/fathom/compiler.py -last_verified: 2026-06-05 +last_verified: 2026-08-20 --- # Rule diff --git a/docs/reference/yaml/template.md b/docs/reference/yaml/template.md index feee3f66..22c3d890 100644 --- a/docs/reference/yaml/template.md +++ b/docs/reference/yaml/template.md @@ -8,7 +8,7 @@ sources: - src/fathom/models.py - src/fathom/compiler.py - src/fathom/facts.py -last_verified: 2026-06-05 +last_verified: 2026-08-20 --- # Template @@ -109,8 +109,25 @@ templates: Pydantic-level rejections (raised at `TemplateDefinition(...)` or during YAML load): -- `name` that does not match `^[A-Za-z_][A-Za-z0-9_\-]*$` → - `ValueError` from `_name_must_be_clips_ident`. +- Any key not listed in the tables above, on either model — both + `TemplateDefinition` and `SlotDefinition` are `extra="forbid"`, so a + misspelled field is an error rather than a silently dropped line. +- `TemplateDefinition.name` that does not match + `^[A-Za-z_][A-Za-z0-9_\-]*$` → `ValueError` from + `_name_must_be_clips_ident`. +- `SlotDefinition.name` that does not match the same pattern → `ValueError` + from `_validate_clips_ident`. +- On a `symbol` slot, an `allowed_values` entry that is not a CLIPS + identifier → `ValueError`. Symbol allowed values are emitted **unquoted**, + so an unchecked entry closes the `deftemplate` and appends constructs of + its own. +- On a `symbol` slot, a string `default` that is not a CLIPS identifier → + `ValueError`, for the same reason. +- On an `integer` or `float` slot, a string `default` of any kind → + `ValueError`: the value is interpolated verbatim, so it has to be numeric. + +`string` slots are exempt from the last three: their values are escaped and +quoted on the way out, so no content in them can escape the construct. Compile-time rejections (raised by `Compiler.compile_template`): @@ -121,10 +138,6 @@ Compile-time rejections (raised by `Compiler.compile_template`): `min_length=1` constraint, so an empty list passes model validation and only fails at compile time. -No validator is applied to `SlotDefinition.name`, `slot.allowed_values`, -or `slot.default` — the compiler trusts these fields and emits them -verbatim or via `_escape_clips_string`. - ## What is not emitted These YAML fields are accepted by the model but do not appear in the diff --git a/docs/tutorials/hello-world.md b/docs/tutorials/hello-world.md index f024af36..fa9b64d6 100644 --- a/docs/tutorials/hello-world.md +++ b/docs/tutorials/hello-world.md @@ -4,7 +4,7 @@ summary: Install Fathom, write a template and a rule in YAML, evaluate a fact, a audience: [app-developers, rule-authors] diataxis: tutorial status: stable -last_verified: 2026-06-05 +last_verified: 2026-08-20 sources: - src/fathom/engine.py - src/fathom/compiler.py diff --git a/docs/tutorials/modules-and-salience.md b/docs/tutorials/modules-and-salience.md index 2f1a82a5..078c68b9 100644 --- a/docs/tutorials/modules-and-salience.md +++ b/docs/tutorials/modules-and-salience.md @@ -4,7 +4,7 @@ summary: Use modules and salience to build a fail-closed policy where deny alway audience: [app-developers, rule-authors] diataxis: tutorial status: stable -last_verified: 2026-08-19 +last_verified: 2026-08-20 sources: - src/fathom/models.py - src/fathom/compiler.py @@ -76,7 +76,10 @@ focus_order: The optional `focus_order` list tells the engine which modules to activate and in what order: the first name runs first. It maps straight onto the CLIPS `(focus ...)` command, which gives the focus to its first argument and queues -the rest behind it. +the rest behind it. Omit it and the engine focuses every declared module in +declaration order, which keeps a pack working but leaves the order to depend +on how the files happen to be written — write it out whenever the sequence +carries meaning. Every listed module runs — an earlier one does not short-circuit the ones after it. Because decisions are last-write-wins, the module listed last is diff --git a/docs/tutorials/working-memory.md b/docs/tutorials/working-memory.md index 976640ec..e7e8da0e 100644 --- a/docs/tutorials/working-memory.md +++ b/docs/tutorials/working-memory.md @@ -4,7 +4,7 @@ summary: Assert facts incrementally across multiple evaluate() calls and learn h audience: [app-developers, rule-authors] diataxis: tutorial status: stable -last_verified: 2026-06-05 +last_verified: 2026-08-20 sources: - src/fathom/engine.py - src/fathom/models.py diff --git a/mkdocs.yml b/mkdocs.yml index a5c54743..40e088ba 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -107,6 +107,9 @@ nav: - Registering a Python function: how-to/register-function.md - Loading a rule pack: how-to/load-rule-pack.md - Embedding via SDK: how-to/embed-sdk.md + - Hot-reloading rulesets: how-to/hot-reload.md + - Verifying a signed release: how-to/release-signing.md + - Running Policy Studio: how-to/policy-studio.md - Concepts: - Overview: concepts/index.md - Five Primitives: concepts/five-primitives.md @@ -116,6 +119,7 @@ nav: - CLIPS Features Not In v1: concepts/not-in-v1.md - Reference: - Overview: reference/index.md + - Configuration: reference/configuration.md - Python SDK: reference/python-sdk/index.md - Go SDK: reference/go-sdk/index.md - TypeScript SDK: reference/typescript-sdk/index.md diff --git a/packages/fathom-go/Makefile b/packages/fathom-go/Makefile index e20b8cb3..75c8f841 100644 --- a/packages/fathom-go/Makefile +++ b/packages/fathom-go/Makefile @@ -2,7 +2,10 @@ # # Prerequisites: # - openapi-generator-cli (https://openapi-generator.tech) -# - protoc + protoc-gen-go + protoc-gen-go-grpc (for gRPC stubs) +# - protoc + protoc-gen-go + protoc-gen-go-grpc (for gRPC stubs). +# Use the versions go-ci.yml pins, or `verify-grpc` fails on the version +# comment the generators write into each header: protoc 25.3, +# protoc-gen-go v1.36.11, protoc-gen-go-grpc v1.6.1. # # Usage: # make generate — regenerate REST client from OpenAPI spec diff --git a/packages/fathom-go/client.go b/packages/fathom-go/client.go index fd7779d1..8a6566a9 100644 --- a/packages/fathom-go/client.go +++ b/packages/fathom-go/client.go @@ -62,12 +62,13 @@ type EvaluateRequest struct { // EvaluateResponse is the response from POST /v1/evaluate. type EvaluateResponse struct { - Decision string `json:"decision"` - Reason string `json:"reason"` - RuleTrace []string `json:"rule_trace"` - ModuleTrace []string `json:"module_trace"` - DurationUS int64 `json:"duration_us"` - AttestationToken string `json:"attestation_token,omitempty"` + Decision string `json:"decision"` + Reason string `json:"reason"` + RuleTrace []string `json:"rule_trace"` + ModuleTrace []string `json:"module_trace"` + DurationUS int64 `json:"duration_us"` + Metadata map[string]string `json:"metadata,omitempty"` + AttestationToken string `json:"attestation_token,omitempty"` } // Evaluate sends facts to the engine and returns the policy decision. diff --git a/packages/fathom-go/grpc_client.go b/packages/fathom-go/grpc_client.go index 8390d73e..e2855bf5 100644 --- a/packages/fathom-go/grpc_client.go +++ b/packages/fathom-go/grpc_client.go @@ -152,11 +152,13 @@ func (c *GRPCClient) Evaluate(ctx context.Context, req *EvaluateRequest) (*Evalu return nil, fmt.Errorf("fathom: evaluate: %w", err) } return &EvaluateResponse{ - Decision: resp.GetDecision(), - Reason: resp.GetReason(), - RuleTrace: resp.GetRuleTrace(), - ModuleTrace: resp.GetModuleTrace(), - DurationUS: resp.GetDurationUs(), + Decision: resp.GetDecision(), + Reason: resp.GetReason(), + RuleTrace: resp.GetRuleTrace(), + ModuleTrace: resp.GetModuleTrace(), + DurationUS: resp.GetDurationUs(), + Metadata: resp.GetMetadata(), + AttestationToken: resp.GetAttestationToken(), }, nil } diff --git a/packages/fathom-go/grpc_client_test.go b/packages/fathom-go/grpc_client_test.go index 064c6733..042e5db2 100644 --- a/packages/fathom-go/grpc_client_test.go +++ b/packages/fathom-go/grpc_client_test.go @@ -16,6 +16,7 @@ import ( "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/proto" ) // ---------- Mock server ---------- @@ -209,11 +210,13 @@ func TestGRPC_Evaluate_HappyPath(t *testing.T) { t.Errorf("ruleset: got %q, want authz", req.GetRuleset()) } return &pb.EvaluateResponse{ - Decision: "allow", - Reason: "ok", - RuleTrace: []string{"r1"}, - ModuleTrace: []string{"m1"}, - DurationUs: 42, + Decision: proto.String("allow"), + Reason: proto.String("ok"), + RuleTrace: []string{"r1"}, + ModuleTrace: []string{"m1"}, + DurationUs: 42, + Metadata: map[string]string{"control": "AC-3"}, + AttestationToken: proto.String("jwt.token.here"), }, nil }, } @@ -232,6 +235,12 @@ func TestGRPC_Evaluate_HappyPath(t *testing.T) { if len(got.RuleTrace) != 1 || len(got.ModuleTrace) != 1 || got.DurationUS != 42 { t.Errorf("traces/duration mismatch: %+v", got) } + if got.Metadata["control"] != "AC-3" { + t.Errorf("metadata: got %v, want control=AC-3", got.Metadata) + } + if got.AttestationToken != "jwt.token.here" { + t.Errorf("attestation_token: got %q", got.AttestationToken) + } } func TestGRPC_Evaluate_StatusSurfaced(t *testing.T) { diff --git a/packages/fathom-go/proto/fathom.pb.go b/packages/fathom-go/proto/fathom.pb.go index dea2aeb6..debc50fd 100644 --- a/packages/fathom-go/proto/fathom.pb.go +++ b/packages/fathom-go/proto/fathom.pb.go @@ -190,14 +190,24 @@ func (x *EvaluateRequest) GetFacts() []*FactInput { } type EvaluateResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Decision string `protobuf:"bytes,1,opt,name=decision,proto3" json:"decision,omitempty"` - Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` - RuleTrace []string `protobuf:"bytes,3,rep,name=rule_trace,json=ruleTrace,proto3" json:"rule_trace,omitempty"` - ModuleTrace []string `protobuf:"bytes,4,rep,name=module_trace,json=moduleTrace,proto3" json:"module_trace,omitempty"` - DurationUs int64 `protobuf:"varint,5,opt,name=duration_us,json=durationUs,proto3" json:"duration_us,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + // Optional so that "no rule produced a decision" is distinguishable from + // a rule that decided the empty string. proto3 has no other way to say it: + // an unset non-optional string arrives as "" at every client. + Decision *string `protobuf:"bytes,1,opt,name=decision,proto3,oneof" json:"decision,omitempty"` + Reason *string `protobuf:"bytes,2,opt,name=reason,proto3,oneof" json:"reason,omitempty"` + RuleTrace []string `protobuf:"bytes,3,rep,name=rule_trace,json=ruleTrace,proto3" json:"rule_trace,omitempty"` + ModuleTrace []string `protobuf:"bytes,4,rep,name=module_trace,json=moduleTrace,proto3" json:"module_trace,omitempty"` + DurationUs int64 `protobuf:"varint,5,opt,name=duration_us,json=durationUs,proto3" json:"duration_us,omitempty"` + // The `then.metadata` of the rule that wrote the decision. Carried here so + // a gRPC caller sees the same result shape a REST caller does; control ids + // and other rule-authored context live in it. + Metadata map[string]string `protobuf:"bytes,6,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Ed25519 JWT over this evaluation, present only when the server was built + // with an attestation service. + AttestationToken *string `protobuf:"bytes,7,opt,name=attestation_token,json=attestationToken,proto3,oneof" json:"attestation_token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *EvaluateResponse) Reset() { @@ -231,15 +241,15 @@ func (*EvaluateResponse) Descriptor() ([]byte, []int) { } func (x *EvaluateResponse) GetDecision() string { - if x != nil { - return x.Decision + if x != nil && x.Decision != nil { + return *x.Decision } return "" } func (x *EvaluateResponse) GetReason() string { - if x != nil { - return x.Reason + if x != nil && x.Reason != nil { + return *x.Reason } return "" } @@ -265,6 +275,20 @@ func (x *EvaluateResponse) GetDurationUs() int64 { return 0 } +func (x *EvaluateResponse) GetMetadata() map[string]string { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *EvaluateResponse) GetAttestationToken() string { + if x != nil && x.AttestationToken != nil { + return *x.AttestationToken + } + return "" +} + type AssertFactRequest struct { state protoimpl.MessageState `protogen:"open.v1"` SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` @@ -848,15 +872,23 @@ const file_fathom_proto_rawDesc = "" + "\n" + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x18\n" + "\aruleset\x18\x02 \x01(\tR\aruleset\x12*\n" + - "\x05facts\x18\x03 \x03(\v2\x14.fathom.v1.FactInputR\x05facts\"\xa9\x01\n" + - "\x10EvaluateResponse\x12\x1a\n" + - "\bdecision\x18\x01 \x01(\tR\bdecision\x12\x16\n" + - "\x06reason\x18\x02 \x01(\tR\x06reason\x12\x1d\n" + + "\x05facts\x18\x03 \x03(\v2\x14.fathom.v1.FactInputR\x05facts\"\x97\x03\n" + + "\x10EvaluateResponse\x12\x1f\n" + + "\bdecision\x18\x01 \x01(\tH\x00R\bdecision\x88\x01\x01\x12\x1b\n" + + "\x06reason\x18\x02 \x01(\tH\x01R\x06reason\x88\x01\x01\x12\x1d\n" + "\n" + "rule_trace\x18\x03 \x03(\tR\truleTrace\x12!\n" + "\fmodule_trace\x18\x04 \x03(\tR\vmoduleTrace\x12\x1f\n" + "\vduration_us\x18\x05 \x01(\x03R\n" + - "durationUs\"k\n" + + "durationUs\x12E\n" + + "\bmetadata\x18\x06 \x03(\v2).fathom.v1.EvaluateResponse.MetadataEntryR\bmetadata\x120\n" + + "\x11attestation_token\x18\a \x01(\tH\x02R\x10attestationToken\x88\x01\x01\x1a;\n" + + "\rMetadataEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\v\n" + + "\t_decisionB\t\n" + + "\a_reasonB\x14\n" + + "\x12_attestation_token\"k\n" + "\x11AssertFactRequest\x12\x1d\n" + "\n" + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x1a\n" + @@ -928,7 +960,7 @@ func file_fathom_proto_rawDescGZIP() []byte { } var file_fathom_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_fathom_proto_msgTypes = make([]protoimpl.MessageInfo, 13) +var file_fathom_proto_msgTypes = make([]protoimpl.MessageInfo, 14) var file_fathom_proto_goTypes = []any{ (ChangeType)(0), // 0: fathom.v1.ChangeType (*FactInput)(nil), // 1: fathom.v1.FactInput @@ -944,27 +976,29 @@ var file_fathom_proto_goTypes = []any{ (*FactChange)(nil), // 11: fathom.v1.FactChange (*ReloadRequest)(nil), // 12: fathom.v1.ReloadRequest (*ReloadResponse)(nil), // 13: fathom.v1.ReloadResponse + nil, // 14: fathom.v1.EvaluateResponse.MetadataEntry } var file_fathom_proto_depIdxs = []int32{ 1, // 0: fathom.v1.EvaluateRequest.facts:type_name -> fathom.v1.FactInput - 0, // 1: fathom.v1.FactChange.change_type:type_name -> fathom.v1.ChangeType - 2, // 2: fathom.v1.FathomService.Evaluate:input_type -> fathom.v1.EvaluateRequest - 4, // 3: fathom.v1.FathomService.AssertFact:input_type -> fathom.v1.AssertFactRequest - 6, // 4: fathom.v1.FathomService.Query:input_type -> fathom.v1.QueryRequest - 8, // 5: fathom.v1.FathomService.Retract:input_type -> fathom.v1.RetractRequest - 10, // 6: fathom.v1.FathomService.SubscribeChanges:input_type -> fathom.v1.SubscribeRequest - 12, // 7: fathom.v1.FathomService.Reload:input_type -> fathom.v1.ReloadRequest - 3, // 8: fathom.v1.FathomService.Evaluate:output_type -> fathom.v1.EvaluateResponse - 5, // 9: fathom.v1.FathomService.AssertFact:output_type -> fathom.v1.AssertFactResponse - 7, // 10: fathom.v1.FathomService.Query:output_type -> fathom.v1.QueryResponse - 9, // 11: fathom.v1.FathomService.Retract:output_type -> fathom.v1.RetractResponse - 11, // 12: fathom.v1.FathomService.SubscribeChanges:output_type -> fathom.v1.FactChange - 13, // 13: fathom.v1.FathomService.Reload:output_type -> fathom.v1.ReloadResponse - 8, // [8:14] is the sub-list for method output_type - 2, // [2:8] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name + 14, // 1: fathom.v1.EvaluateResponse.metadata:type_name -> fathom.v1.EvaluateResponse.MetadataEntry + 0, // 2: fathom.v1.FactChange.change_type:type_name -> fathom.v1.ChangeType + 2, // 3: fathom.v1.FathomService.Evaluate:input_type -> fathom.v1.EvaluateRequest + 4, // 4: fathom.v1.FathomService.AssertFact:input_type -> fathom.v1.AssertFactRequest + 6, // 5: fathom.v1.FathomService.Query:input_type -> fathom.v1.QueryRequest + 8, // 6: fathom.v1.FathomService.Retract:input_type -> fathom.v1.RetractRequest + 10, // 7: fathom.v1.FathomService.SubscribeChanges:input_type -> fathom.v1.SubscribeRequest + 12, // 8: fathom.v1.FathomService.Reload:input_type -> fathom.v1.ReloadRequest + 3, // 9: fathom.v1.FathomService.Evaluate:output_type -> fathom.v1.EvaluateResponse + 5, // 10: fathom.v1.FathomService.AssertFact:output_type -> fathom.v1.AssertFactResponse + 7, // 11: fathom.v1.FathomService.Query:output_type -> fathom.v1.QueryResponse + 9, // 12: fathom.v1.FathomService.Retract:output_type -> fathom.v1.RetractResponse + 11, // 13: fathom.v1.FathomService.SubscribeChanges:output_type -> fathom.v1.FactChange + 13, // 14: fathom.v1.FathomService.Reload:output_type -> fathom.v1.ReloadResponse + 9, // [9:15] is the sub-list for method output_type + 3, // [3:9] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name } func init() { file_fathom_proto_init() } @@ -972,6 +1006,7 @@ func file_fathom_proto_init() { if File_fathom_proto != nil { return } + file_fathom_proto_msgTypes[2].OneofWrappers = []any{} file_fathom_proto_msgTypes[11].OneofWrappers = []any{ (*ReloadRequest_RulesetPath)(nil), (*ReloadRequest_RulesetYaml)(nil), @@ -982,7 +1017,7 @@ func file_fathom_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_fathom_proto_rawDesc), len(file_fathom_proto_rawDesc)), NumEnums: 1, - NumMessages: 13, + NumMessages: 14, NumExtensions: 0, NumServices: 1, }, diff --git a/packages/fathom-ts/src/client.ts b/packages/fathom-ts/src/client.ts index eb0718b3..69e0a6aa 100644 --- a/packages/fathom-ts/src/client.ts +++ b/packages/fathom-ts/src/client.ts @@ -35,6 +35,8 @@ export interface EvaluateResponse { rule_trace: string[]; module_trace: string[]; duration_us: number; + /** `then.metadata` of the rule that decided; `{}` when it wrote none. */ + metadata: Record; attestation_token?: string | null; } diff --git a/packages/fathom-ts/test/client.test.ts b/packages/fathom-ts/test/client.test.ts index f0b049f0..416f7d77 100644 --- a/packages/fathom-ts/test/client.test.ts +++ b/packages/fathom-ts/test/client.test.ts @@ -70,6 +70,7 @@ describe("FathomClient.evaluate", () => { rule_trace: ["r1"], module_trace: ["MAIN"], duration_us: 42, + metadata: { control: "AC-3" }, attestation_token: null, }, calls, @@ -92,6 +93,7 @@ describe("FathomClient.evaluate", () => { }); expect(result.decision).toBe("allow"); expect(result.module_trace).toEqual(["MAIN"]); + expect(result.metadata).toEqual({ control: "AC-3" }); }); it("injects Authorization header when bearerToken is set", async () => { diff --git a/protos/fathom.proto b/protos/fathom.proto index ca1774f8..f80efe88 100644 --- a/protos/fathom.proto +++ b/protos/fathom.proto @@ -52,11 +52,21 @@ message EvaluateRequest { } message EvaluateResponse { - string decision = 1; - string reason = 2; + // Optional so that "no rule produced a decision" is distinguishable from + // a rule that decided the empty string. proto3 has no other way to say it: + // an unset non-optional string arrives as "" at every client. + optional string decision = 1; + optional string reason = 2; repeated string rule_trace = 3; repeated string module_trace = 4; int64 duration_us = 5; + // The `then.metadata` of the rule that wrote the decision. Carried here so + // a gRPC caller sees the same result shape a REST caller does; control ids + // and other rule-authored context live in it. + map metadata = 6; + // Ed25519 JWT over this evaluation, present only when the server was built + // with an attestation service. + optional string attestation_token = 7; } // --------------------------------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index 8be9219b..abbabe56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,11 @@ keywords = [ "ai-safety", ] classifiers = [ - "Development Status :: 3 - Alpha", + # Beta, not Production/Stable: the covered surface is declared and gated + # (VERSIONING.md, tests/test_public_surface.py) and the release path is + # signed and reproducible, but the 1.0 readiness audit has not been done. + # "5 - Production/Stable" is a claim about that audit, not about this work. + "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "License :: OSI Approved :: MIT License", diff --git a/release-please-config.json b/release-please-config.json index c69902d2..e4757f07 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -3,7 +3,10 @@ "release-type": "python", "include-v-in-tag": true, "include-component-in-tag": false, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, "skip-changelog": true, + "signoff": "se-jo-ma <46336194+se-jo-ma@users.noreply.github.com>", "packages": { ".": { "package-name": "fathom-rules", diff --git a/scripts/benchmark.py b/scripts/benchmark.py index e19d662c..13effc5d 100644 --- a/scripts/benchmark.py +++ b/scripts/benchmark.py @@ -20,12 +20,14 @@ mornings. The p95 is printed so a real regression stays visible in the log even while it is still under the bar. * **CI applies a slack factor.** The published targets describe a developer - machine, which is what a reader of README is holding. A GitHub runner - measured ~1.7x slower on the compilation case, so enforcing the published - numbers there verbatim would fail on the machine rather than on the code. - ``--slack`` multiplies every limit; CI passes 1.5 and prints it. A regression - large enough to matter clears that factor easily -- the last one found here - was 2x. + machine, which is what a reader of README is holding. A GitHub shared runner + measured 1.2x to 1.9x slower than that machine across five consecutive runs + of the same job, so enforcing the published numbers there verbatim would + fail on the hardware rather than on the code. ``--slack`` multiplies every + limit and is printed in the header; CI passes 2.0, which covers the + measured spread with a little room. A regression large enough to matter + clears that factor easily -- the last one found here was 2x on the + developer machine, which lands at 4x of the published target on a runner. """ from __future__ import annotations @@ -121,7 +123,12 @@ def _write_pack(root: Path, rule_count: int) -> Path: ) (root / "modules" / "m.yaml").write_text( - yaml.safe_dump({"modules": [{"name": "bench", "description": "Benchmark rules"}]}), + yaml.safe_dump( + { + "modules": [{"name": "bench", "description": "Benchmark rules"}], + "focus_order": ["bench"], + } + ), encoding="utf-8", ) @@ -192,6 +199,9 @@ def _cases(iterations: int, workdir: Path) -> list[Case]: one_rule = _write_pack(workdir / "one", 1) hundred_rules = _write_pack(workdir / "hundred", 100) + _assert_rules_fire(one_rule) + _assert_rules_fire(hundred_rules) + single_engine = Engine.from_rules(str(one_rule)) hundred_engine = Engine.from_rules(str(hundred_rules)) assertion_engine = Engine.from_rules(str(one_rule)) @@ -246,6 +256,23 @@ def _cases(iterations: int, workdir: Path) -> list[Case]: ] +def _assert_rules_fire(pack: Path) -> None: + """Refuse to report evaluation timings for a pack that fires nothing. + + The first version of this script built a pack with a declared module and + no ``focus_order``, which at the time meant CLIPS never drained that + module's agenda: both evaluation cases timed an empty agenda and reported + healthy microseconds for doing nothing. A benchmark that cannot tell those + apart is worse than no benchmark. + """ + result = Engine.from_rules(str(pack)).evaluate_once(FACTS) + if not result.rule_trace: + raise SystemExit( + f"benchmark pack at {pack} fired no rules; the evaluation timings " + "would measure an empty agenda" + ) + + def run(iterations: int, warmup: int, workdir: Path, slack: float = 1.0) -> list[Result]: results = [] for case in _cases(iterations, workdir): diff --git a/scripts/check_doc_sources.py b/scripts/check_doc_sources.py index de08f051..a6e98436 100644 --- a/scripts/check_doc_sources.py +++ b/scripts/check_doc_sources.py @@ -1,6 +1,13 @@ """Drift gate: fail if any page's cited sources were modified after the page's last_verified date, or if a cited source file is missing. +Two scopes. With no arguments every page is checked -- the maintainer sweep, +which answers "what has gone stale anywhere". With ``--changed-vs REF`` only +the sources this branch touched are considered, which is the scope a merge +gate needs: editing a source ages every page citing it, so a whole-repo gate +would fail the next unrelated pull request instead of the one that caused +the drift. + Honors `.git-blame-ignore-revs` (the standard git convention used by `git blame` and `git config blame.ignoreRevsFile`) so that format-only or otherwise non-content commits don't trip the gate. Add a SHA to @@ -17,6 +24,7 @@ """ from __future__ import annotations +import argparse import re import subprocess import sys @@ -105,7 +113,12 @@ def _last_commit_date( return None, saw_commit -def _check_page(page: Path, repo: Path, ignore: set[str]) -> list[str]: +def _check_page( + page: Path, + repo: Path, + ignore: set[str], + scope: set[str] | None = None, +) -> list[str]: try: fm = _read_frontmatter(page) except ValueError as exc: @@ -120,6 +133,8 @@ def _check_page(page: Path, repo: Path, ignore: set[str]) -> list[str]: return [f"{page}: last_verified missing or not a date"] errors: list[str] = [] for src in sources: + if scope is not None and src not in scope: + continue src_path = repo / src if not src_path.exists(): errors.append(f"{page}: cited source {src!r} does not exist") @@ -139,18 +154,62 @@ def _check_page(page: Path, repo: Path, ignore: set[str]) -> list[str]: return errors +def _changed_files(repo: Path, base: str) -> set[str] | None: + """Repo-relative paths that differ between *base* and the working tree. + + Returns None if the diff cannot be taken (unknown ref, shallow clone), + which the caller treats as "check everything" rather than "check nothing": + a gate that silently passes on a bad ref is worse than a noisy one. + """ + result = subprocess.run( + ["git", "diff", "--name-only", f"{base}...HEAD"], + cwd=repo, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return None + return {line.strip() for line in result.stdout.splitlines() if line.strip()} + + def main(argv: list[str]) -> int: - repo = Path(argv[1] if len(argv) > 1 else ".").resolve() + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("repo", nargs="?", default=".") + parser.add_argument( + "--changed-vs", + metavar="REF", + help=( + "only report pages whose cited sources this branch actually changed " + "since REF. Without it every page is checked, which is the right " + "shape for a maintainer sweep but the wrong one for a merge gate: " + "a source edit ages every page citing it, and the failure would " + "land on whoever opens the next unrelated pull request." + ), + ) + args = parser.parse_args(argv[1:]) + + repo = Path(args.repo).resolve() docs = repo / "docs" if not docs.is_dir(): print(f"error: {docs} not a directory", file=sys.stderr) return 2 + + scope: set[str] | None = None + if args.changed_vs: + scope = _changed_files(repo, args.changed_vs) + if scope is None: + print( + f"warning: cannot diff against {args.changed_vs!r}; checking every page", + file=sys.stderr, + ) + ignore = _read_ignore_revs(repo) had_errors = False for page in sorted(docs.rglob("*.md")): if "/superpowers/" in page.as_posix(): continue - errors = _check_page(page, repo, ignore) + errors = _check_page(page, repo, ignore, scope) for e in errors: print(e, file=sys.stderr) if errors: diff --git a/src/fathom/__init__.py b/src/fathom/__init__.py index c4bae936..a708bcc9 100644 --- a/src/fathom/__init__.py +++ b/src/fathom/__init__.py @@ -1,26 +1,93 @@ -"""Fathom - Deterministic reasoning runtime for AI agents.""" +"""Fathom - Deterministic reasoning runtime for AI agents. +``__all__`` is the package's covered public surface: the names this project +promises to keep working, and the exact list `VERSIONING.md` describes. A +symbol reachable by import but absent from here — or from the ``__all__`` of +a submodule — is internal, and may move or change in any release. +""" + +from __future__ import annotations + +import importlib +from typing import TYPE_CHECKING, Any + +from fathom.audit import AuditLog, AuditSink, FileSink, NullSink from fathom.engine import Engine from fathom.errors import ( + AttestationError, CompilationError, EvaluationError, EvaluationLimitError, + FathomError, + FleetConnectionError, + FleetError, ScopeError, ValidationError, ) -from fathom.models import AssertedFact, AssertSpec, EvaluationResult +from fathom.fleet import FactStore, FleetEngine, InMemoryFactStore +from fathom.models import AssertedFact, AssertSpec, AuditRecord, EvaluationResult + +if TYPE_CHECKING: # pragma: no cover - import-time typing only + from fathom.attestation import AttestationService, verify_token + from fathom.chained_log import ChainedAttestationLog, verify_chain __version__ = "0.8.0" # x-release-please-version +#: Names served on first access instead of at import. Both modules import +#: ``jwt`` and ``cryptography``, which ship in the optional ``attestation`` +#: extra -- eagerly importing them would make ``import fathom`` fail for every +#: install that did not ask for signing. +_LAZY_EXPORTS: dict[str, str] = { + "AttestationService": "fathom.attestation", + "verify_token": "fathom.attestation", + "ChainedAttestationLog": "fathom.chained_log", + "verify_chain": "fathom.chained_log", +} + __all__ = [ - "__version__", - "Engine", + "AssertSpec", + "AssertedFact", + "AttestationError", + "AttestationService", + "AuditLog", + "AuditRecord", + "AuditSink", + "ChainedAttestationLog", "CompilationError", + "Engine", "EvaluationError", "EvaluationLimitError", + "EvaluationResult", + "FactStore", + "FathomError", + "FileSink", + "FleetConnectionError", + "FleetEngine", + "FleetError", + "InMemoryFactStore", + "NullSink", "ScopeError", "ValidationError", - "AssertSpec", - "AssertedFact", - "EvaluationResult", + "__version__", + "verify_chain", + "verify_token", ] + + +def __getattr__(name: str) -> Any: + """Resolve the attestation exports on first access (PEP 562).""" + module_name = _LAZY_EXPORTS.get(name) + if module_name is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + try: + value = getattr(importlib.import_module(module_name), name) + except ImportError as exc: # missing optional dependency, not a typo + raise ImportError( + f"fathom.{name} needs the 'attestation' extra: pip install 'fathom-rules[attestation]'" + ) from exc + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(__all__) diff --git a/src/fathom/attestation.py b/src/fathom/attestation.py index 1b7c1da0..3e414621 100644 --- a/src/fathom/attestation.py +++ b/src/fathom/attestation.py @@ -25,6 +25,9 @@ from fathom.models import EvaluationResult +__all__ = ["AttestationService", "verify_token"] + + class AttestationService: """Signs evaluation results using Ed25519 JWT tokens.""" diff --git a/src/fathom/audit.py b/src/fathom/audit.py index f1096a94..8b888557 100644 --- a/src/fathom/audit.py +++ b/src/fathom/audit.py @@ -17,6 +17,9 @@ AuditPayload = AuditRecord | Mapping[str, Any] +__all__ = ["AuditLog", "AuditPayload", "AuditSink", "FileSink", "NullSink"] + + @runtime_checkable class AuditSink(Protocol): """Protocol for audit record sinks.""" @@ -115,5 +118,6 @@ def record( duration_us=result.duration_us, metadata=result.metadata, asserted_facts=asserted_facts, + attestation_token=result.attestation_token, ) self._sink.write(audit) diff --git a/src/fathom/chained_log.py b/src/fathom/chained_log.py index 703e0899..0bc596e4 100644 --- a/src/fathom/chained_log.py +++ b/src/fathom/chained_log.py @@ -75,6 +75,18 @@ _META_RECORD_TYPES = {GENESIS_RECORD_TYPE, CHECKPOINT_RECORD_TYPE} +__all__ = [ + "AnchorEvent", + "ChainVerification", + "ChainedAttestationLog", + "ChainedRecord", + "key_fingerprint", + "load_or_create_key", + "verify_chain", + "write_private_key_atomic", +] + + def _canonical(obj: Any) -> bytes: return json.dumps(obj, sort_keys=True, separators=(",", ":")).encode() @@ -397,6 +409,20 @@ def append(self, record: dict[str, Any]) -> ChainedRecord: self.checkpoint() return chained + def write(self, record: Any) -> None: + """Append an audit record, satisfying :class:`fathom.audit.AuditSink`. + + Lets the chained log stand in for ``FileSink`` wherever a sink is + taken -- ``Engine(audit_sink=ChainedAttestationLog(...))`` -- so + evaluation records land in a chain whose lines commit to their + predecessors, rather than in a flat file where a deletion leaves no + trace. Pydantic records are dumped in JSON mode so the line stays + canonicalisable; mappings (the transports' hot-reload events) pass + through as-is. + """ + dump = getattr(record, "model_dump", None) + self.append(dump(mode="json") if dump is not None else dict(record)) + def checkpoint(self) -> ChainedRecord: """Append a signed checkpoint record pinning the current head. diff --git a/src/fathom/engine.py b/src/fathom/engine.py index f9a4b406..14d70885 100644 --- a/src/fathom/engine.py +++ b/src/fathom/engine.py @@ -41,6 +41,9 @@ logger = logging.getLogger(__name__) # Reserved prefix for fathom-internal CLIPS functions (see Engine.register_function). +__all__ = ["Engine", "RESERVED_FUNCTION_PREFIX"] + + RESERVED_FUNCTION_PREFIX = "fathom-" # Default activation budget for a single evaluation. A ruleset whose rules @@ -636,6 +639,16 @@ def load_modules(self, path: str) -> None: count += 1 if focus_order: self.set_focus(focus_order) + + # A pack that declares modules but no `focus_order` used to fire + # nothing at all. CLIPS only drains the agenda of the module that + # holds the focus, so every rule scoped to a declared module sat + # unfired and the caller silently got the default decision -- a + # wrong answer, not an error. The module reference described the + # omission as merely non-deterministic ordering, which is what it + # should be: focus the declared modules in declaration order. + if not self._focus_order and self._module_registry: + self.set_focus(list(self._module_registry)) finally: self._lock.release() if count: diff --git a/src/fathom/errors.py b/src/fathom/errors.py index d6f3da68..e5e84e1a 100644 --- a/src/fathom/errors.py +++ b/src/fathom/errors.py @@ -8,6 +8,18 @@ from typing import Any +__all__ = [ + "AttestationError", + "CompilationError", + "EvaluationError", + "EvaluationLimitError", + "FathomError", + "FleetConnectionError", + "FleetError", + "ScopeError", + "ValidationError", +] + class FathomError(Exception): """Base exception for all Fathom errors.""" diff --git a/src/fathom/fleet.py b/src/fathom/fleet.py index c8ba97ee..188688dc 100644 --- a/src/fathom/fleet.py +++ b/src/fathom/fleet.py @@ -28,6 +28,9 @@ FactId = str +__all__ = ["FactStore", "FleetEngine", "InMemoryFactStore"] + + @runtime_checkable class FactStore(Protocol): """Protocol defining the async fact-storage interface.""" diff --git a/src/fathom/integrations/grpc_server.py b/src/fathom/integrations/grpc_server.py index bc540b90..6b3521be 100644 --- a/src/fathom/integrations/grpc_server.py +++ b/src/fathom/integrations/grpc_server.py @@ -140,7 +140,10 @@ def _engine_for(self, session_id: str, ruleset: str = "", context: Any = None) - ) raise # unreachable; abort raises try: - return self._session_store.get_or_create(session_id, ruleset) + # Same reason as REST: signing is a property of the Engine, so a + # servicer configured with an attestation service has to hand it + # to the sessions it creates or `attestation_token` never arrives. + return self._session_store.get_or_create(session_id, ruleset, self._attestation) except SessionRulesetMismatchError as exc: if context is None: raise @@ -223,13 +226,23 @@ def Evaluate( # noqa: N802 — gRPC convention context.abort(grpc.StatusCode.INTERNAL, f"evaluation_error: {exc}") raise # unreachable; abort raises - return fathom_pb2.EvaluateResponse( - decision=result.decision or "", - reason=result.reason or "", + # `decision` and `reason` are proto3 `optional`, so "no rule decided" + # is left unset rather than sent as "". Assigning None to a proto + # string field raises, so each is set only when the engine produced + # one -- that is what makes the distinction reach the client. + response = fathom_pb2.EvaluateResponse( rule_trace=list(result.rule_trace), module_trace=list(result.module_trace), duration_us=result.duration_us, + metadata=dict(result.metadata), ) + if result.decision is not None: + response.decision = result.decision + if result.reason is not None: + response.reason = result.reason + if result.attestation_token is not None: + response.attestation_token = result.attestation_token + return response def AssertFact( # noqa: N802 — gRPC convention self, diff --git a/src/fathom/integrations/rest.py b/src/fathom/integrations/rest.py index 03c413aa..09e644a8 100644 --- a/src/fathom/integrations/rest.py +++ b/src/fathom/integrations/rest.py @@ -21,6 +21,8 @@ from starlette.types import ASGIApp, Message, Receive, Scope, Send + from fathom.attestation import AttestationService + try: import prometheus_client from prometheus_fastapi_instrumentator import Instrumentator @@ -138,7 +140,11 @@ def _resolve_user_ruleset(user_path: str) -> str: raise HTTPException(status_code=400, detail=str(exc)) from exc -def _session_engine(session_id: str, rules_path: str) -> Engine: +def _session_engine( + session_id: str, + rules_path: str, + attestation_service: AttestationService | None = None, +) -> Engine: """Return the session Engine, mapping store rejections onto HTTP status. A session is bound to the ruleset it was created with; addressing it @@ -146,7 +152,7 @@ def _session_engine(session_id: str, rules_path: str) -> Engine: wrong policy. """ try: - return session_store.get_or_create(session_id, rules_path) + return session_store.get_or_create(session_id, rules_path, attestation_service) except SessionRulesetMismatchError as exc: raise HTTPException( status_code=409, @@ -379,10 +385,15 @@ def evaluate(request: EvaluateRequest) -> EvaluateResponse: connection. """ resolved = _resolve_user_ruleset(request.ruleset) + # Signing is per-engine, so the service has to be handed to the engine at + # construction. Without this the response's `attestation_token` was a + # field that could never be non-null: declared in the schema, published in + # the OpenAPI document, and unreachable. + attestation = getattr(app.state, "attestation", None) if request.session_id: - engine = _session_engine(request.session_id, resolved) + engine = _session_engine(request.session_id, resolved, attestation) else: - engine = Engine.from_rules(resolved) + engine = Engine.from_rules(resolved, attestation_service=attestation) try: result = engine.evaluate_once( @@ -411,6 +422,8 @@ def evaluate(request: EvaluateRequest) -> EvaluateResponse: rule_trace=result.rule_trace, module_trace=result.module_trace, duration_us=result.duration_us, + metadata=result.metadata, + attestation_token=result.attestation_token, ) diff --git a/src/fathom/integrations/sessions.py b/src/fathom/integrations/sessions.py index 372c5972..9e9767f1 100644 --- a/src/fathom/integrations/sessions.py +++ b/src/fathom/integrations/sessions.py @@ -20,6 +20,7 @@ from fathom.metrics import MetricsCollector if TYPE_CHECKING: + from fathom.attestation import AttestationService from fathom.engine import Engine # A session holds a compiled CLIPS environment (~1.7 MB of RSS each), so @@ -111,9 +112,24 @@ def _cleanup_expired(self) -> None: if expired: self._record_session_count() - def get_or_create(self, session_id: str, rules_path: str = "") -> Engine: + def get_or_create( + self, + session_id: str, + rules_path: str = "", + attestation_service: AttestationService | None = None, + ) -> Engine: """Return the Engine bound to *session_id*, creating it if needed. + Args: + session_id: Caller-supplied session identifier. + rules_path: Ruleset this session is bound to. + attestation_service: Signs the results of evaluations on this + session. Applied at creation only — a session that already + exists keeps the service it was built with, so injecting one + into a running server affects sessions opened after that + point rather than silently changing what earlier callers + have been receiving. + Raises: SessionRulesetMismatchError: The session exists but was created for a different ruleset. @@ -136,7 +152,11 @@ def get_or_create(self, session_id: str, rules_path: str = "") -> Engine: if len(self._sessions) >= self._max_sessions: raise SessionLimitError("Maximum session limit reached") - engine = Engine.from_rules(rules_path) if rules_path else Engine() + engine = ( + Engine.from_rules(rules_path, attestation_service=attestation_service) + if rules_path + else Engine(attestation_service=attestation_service) + ) self._sessions[session_id] = _Session(engine, rules_path, time.time()) self._record_session_count() return engine diff --git a/src/fathom/models.py b/src/fathom/models.py index 09d78b07..39e3597e 100644 --- a/src/fathom/models.py +++ b/src/fathom/models.py @@ -132,6 +132,40 @@ def _validate_expression(expr: str) -> str: return expr +__all__ = [ + "ActionType", + "AssertFactRequest", + "AssertFactResponse", + "AssertSpec", + "AssertedFact", + "AuditRecord", + "CompileRequest", + "CompileResponse", + "ConditionEntry", + "ErrorResponse", + "EvaluateRequest", + "EvaluateResponse", + "EvaluationResult", + "FactChangeNotification", + "FactInput", + "FactPattern", + "FunctionDefinition", + "HierarchyDefinition", + "LogLevel", + "ModuleDefinition", + "QueryFactsRequest", + "QueryFactsResponse", + "RetractFactsRequest", + "RetractFactsResponse", + "RuleDefinition", + "RulesetDefinition", + "SlotDefinition", + "SlotType", + "TemplateDefinition", + "ThenBlock", +] + + # --- Core Models (Section 3) --- @@ -546,6 +580,10 @@ class AuditRecord(BaseModel): duration_us: int metadata: dict[str, str] = Field(default_factory=dict) asserted_facts: list[AssertedFact] | None = None + #: The evaluation's attestation JWT, when the engine was given an + #: attestation service. Carried on the record so an exported line can be + #: verified on its own; ``None`` on an engine that does not sign. + attestation_token: str | None = None # --- REST Models (Phase 2) --- @@ -584,13 +622,23 @@ class EvaluateRequest(BaseModel): class EvaluateResponse(BaseModel): - """REST API response body from the evaluate endpoint.""" + """REST API response body from the evaluate endpoint. + + Mirrors :class:`EvaluationResult` field for field, and the gRPC + ``EvaluateResponse`` message alongside it. A caller who moves between the + two transports, or between the library and either of them, should not have + to discover that one of them drops a field the others carry. + """ decision: str | None reason: str | None rule_trace: list[str] module_trace: list[str] duration_us: int + #: ``then.metadata`` of the rule that wrote the decision. + metadata: dict[str, str] = Field(default_factory=dict) + #: Ed25519 JWT over this evaluation. Non-null only when the server was + #: given an attestation service. attestation_token: str | None = None diff --git a/src/fathom/proto/fathom_pb2.py b/src/fathom/proto/fathom_pb2.py index b3eb7157..86693c03 100644 --- a/src/fathom/proto/fathom_pb2.py +++ b/src/fathom/proto/fathom_pb2.py @@ -24,7 +24,7 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0c\x66\x61thom.proto\x12\tfathom.v1\"0\n\tFactInput\x12\x10\n\x08template\x18\x01 \x01(\t\x12\x11\n\tdata_json\x18\x02 \x01(\t\"[\n\x0f\x45valuateRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x0f\n\x07ruleset\x18\x02 \x01(\t\x12#\n\x05\x66\x61\x63ts\x18\x03 \x03(\x0b\x32\x14.fathom.v1.FactInput\"s\n\x10\x45valuateResponse\x12\x10\n\x08\x64\x65\x63ision\x18\x01 \x01(\t\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x12\n\nrule_trace\x18\x03 \x03(\t\x12\x14\n\x0cmodule_trace\x18\x04 \x03(\t\x12\x13\n\x0b\x64uration_us\x18\x05 \x01(\x03\"L\n\x11\x41ssertFactRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x10\n\x08template\x18\x02 \x01(\t\x12\x11\n\tdata_json\x18\x03 \x01(\t\"%\n\x12\x41ssertFactResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\"I\n\x0cQueryRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x10\n\x08template\x18\x02 \x01(\t\x12\x13\n\x0b\x66ilter_json\x18\x03 \x01(\t\"#\n\rQueryResponse\x12\x12\n\nfacts_json\x18\x01 \x03(\t\"K\n\x0eRetractRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x10\n\x08template\x18\x02 \x01(\t\x12\x13\n\x0b\x66ilter_json\x18\x03 \x01(\t\"*\n\x0fRetractResponse\x12\x17\n\x0fretracted_count\x18\x01 \x01(\x05\"&\n\x10SubscribeRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\"]\n\nFactChange\x12*\n\x0b\x63hange_type\x18\x01 \x01(\x0e\x32\x15.fathom.v1.ChangeType\x12\x10\n\x08template\x18\x02 \x01(\t\x12\x11\n\tdata_json\x18\x03 \x01(\t\"\\\n\rReloadRequest\x12\x16\n\x0cruleset_path\x18\x01 \x01(\tH\x00\x12\x16\n\x0cruleset_yaml\x18\x02 \x01(\tH\x00\x12\x11\n\tsignature\x18\x03 \x01(\x0c\x42\x08\n\x06source\"d\n\x0eReloadResponse\x12\x1b\n\x13ruleset_hash_before\x18\x01 \x01(\t\x12\x1a\n\x12ruleset_hash_after\x18\x02 \x01(\t\x12\x19\n\x11\x61ttestation_token\x18\x03 \x01(\t*B\n\nChangeType\x12\x1b\n\x17\x43HANGE_TYPE_UNSPECIFIED\x10\x00\x12\n\n\x06\x41SSERT\x10\x01\x12\x0b\n\x07RETRACT\x10\x02\x32\xa6\x03\n\rFathomService\x12\x43\n\x08\x45valuate\x12\x1a.fathom.v1.EvaluateRequest\x1a\x1b.fathom.v1.EvaluateResponse\x12I\n\nAssertFact\x12\x1c.fathom.v1.AssertFactRequest\x1a\x1d.fathom.v1.AssertFactResponse\x12:\n\x05Query\x12\x17.fathom.v1.QueryRequest\x1a\x18.fathom.v1.QueryResponse\x12@\n\x07Retract\x12\x19.fathom.v1.RetractRequest\x1a\x1a.fathom.v1.RetractResponse\x12H\n\x10SubscribeChanges\x12\x1b.fathom.v1.SubscribeRequest\x1a\x15.fathom.v1.FactChange0\x01\x12=\n\x06Reload\x12\x18.fathom.v1.ReloadRequest\x1a\x19.fathom.v1.ReloadResponseB>\n\rcom.fathom.v1Z-github.com/KrakenNet/fathom-go/proto;fathomv1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0c\x66\x61thom.proto\x12\tfathom.v1\"0\n\tFactInput\x12\x10\n\x08template\x18\x01 \x01(\t\x12\x11\n\tdata_json\x18\x02 \x01(\t\"[\n\x0f\x45valuateRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x0f\n\x07ruleset\x18\x02 \x01(\t\x12#\n\x05\x66\x61\x63ts\x18\x03 \x03(\x0b\x32\x14.fathom.v1.FactInput\"\xb9\x02\n\x10\x45valuateResponse\x12\x15\n\x08\x64\x65\x63ision\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x13\n\x06reason\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x12\n\nrule_trace\x18\x03 \x03(\t\x12\x14\n\x0cmodule_trace\x18\x04 \x03(\t\x12\x13\n\x0b\x64uration_us\x18\x05 \x01(\x03\x12;\n\x08metadata\x18\x06 \x03(\x0b\x32).fathom.v1.EvaluateResponse.MetadataEntry\x12\x1e\n\x11\x61ttestation_token\x18\x07 \x01(\tH\x02\x88\x01\x01\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\x0b\n\t_decisionB\t\n\x07_reasonB\x14\n\x12_attestation_token\"L\n\x11\x41ssertFactRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x10\n\x08template\x18\x02 \x01(\t\x12\x11\n\tdata_json\x18\x03 \x01(\t\"%\n\x12\x41ssertFactResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\"I\n\x0cQueryRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x10\n\x08template\x18\x02 \x01(\t\x12\x13\n\x0b\x66ilter_json\x18\x03 \x01(\t\"#\n\rQueryResponse\x12\x12\n\nfacts_json\x18\x01 \x03(\t\"K\n\x0eRetractRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x10\n\x08template\x18\x02 \x01(\t\x12\x13\n\x0b\x66ilter_json\x18\x03 \x01(\t\"*\n\x0fRetractResponse\x12\x17\n\x0fretracted_count\x18\x01 \x01(\x05\"&\n\x10SubscribeRequest\x12\x12\n\nsession_id\x18\x01 \x01(\t\"]\n\nFactChange\x12*\n\x0b\x63hange_type\x18\x01 \x01(\x0e\x32\x15.fathom.v1.ChangeType\x12\x10\n\x08template\x18\x02 \x01(\t\x12\x11\n\tdata_json\x18\x03 \x01(\t\"\\\n\rReloadRequest\x12\x16\n\x0cruleset_path\x18\x01 \x01(\tH\x00\x12\x16\n\x0cruleset_yaml\x18\x02 \x01(\tH\x00\x12\x11\n\tsignature\x18\x03 \x01(\x0c\x42\x08\n\x06source\"d\n\x0eReloadResponse\x12\x1b\n\x13ruleset_hash_before\x18\x01 \x01(\t\x12\x1a\n\x12ruleset_hash_after\x18\x02 \x01(\t\x12\x19\n\x11\x61ttestation_token\x18\x03 \x01(\t*B\n\nChangeType\x12\x1b\n\x17\x43HANGE_TYPE_UNSPECIFIED\x10\x00\x12\n\n\x06\x41SSERT\x10\x01\x12\x0b\n\x07RETRACT\x10\x02\x32\xa6\x03\n\rFathomService\x12\x43\n\x08\x45valuate\x12\x1a.fathom.v1.EvaluateRequest\x1a\x1b.fathom.v1.EvaluateResponse\x12I\n\nAssertFact\x12\x1c.fathom.v1.AssertFactRequest\x1a\x1d.fathom.v1.AssertFactResponse\x12:\n\x05Query\x12\x17.fathom.v1.QueryRequest\x1a\x18.fathom.v1.QueryResponse\x12@\n\x07Retract\x12\x19.fathom.v1.RetractRequest\x1a\x1a.fathom.v1.RetractResponse\x12H\n\x10SubscribeChanges\x12\x1b.fathom.v1.SubscribeRequest\x1a\x15.fathom.v1.FactChange0\x01\x12=\n\x06Reload\x12\x18.fathom.v1.ReloadRequest\x1a\x19.fathom.v1.ReloadResponseB>\n\rcom.fathom.v1Z-github.com/KrakenNet/fathom-go/proto;fathomv1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -32,34 +32,38 @@ if not _descriptor._USE_C_DESCRIPTORS: _globals['DESCRIPTOR']._loaded_options = None _globals['DESCRIPTOR']._serialized_options = b'\n\rcom.fathom.v1Z-github.com/KrakenNet/fathom-go/proto;fathomv1' - _globals['_CHANGETYPE']._serialized_start=968 - _globals['_CHANGETYPE']._serialized_end=1034 + _globals['_EVALUATERESPONSE_METADATAENTRY']._loaded_options = None + _globals['_EVALUATERESPONSE_METADATAENTRY']._serialized_options = b'8\001' + _globals['_CHANGETYPE']._serialized_start=1167 + _globals['_CHANGETYPE']._serialized_end=1233 _globals['_FACTINPUT']._serialized_start=27 _globals['_FACTINPUT']._serialized_end=75 _globals['_EVALUATEREQUEST']._serialized_start=77 _globals['_EVALUATEREQUEST']._serialized_end=168 - _globals['_EVALUATERESPONSE']._serialized_start=170 - _globals['_EVALUATERESPONSE']._serialized_end=285 - _globals['_ASSERTFACTREQUEST']._serialized_start=287 - _globals['_ASSERTFACTREQUEST']._serialized_end=363 - _globals['_ASSERTFACTRESPONSE']._serialized_start=365 - _globals['_ASSERTFACTRESPONSE']._serialized_end=402 - _globals['_QUERYREQUEST']._serialized_start=404 - _globals['_QUERYREQUEST']._serialized_end=477 - _globals['_QUERYRESPONSE']._serialized_start=479 - _globals['_QUERYRESPONSE']._serialized_end=514 - _globals['_RETRACTREQUEST']._serialized_start=516 - _globals['_RETRACTREQUEST']._serialized_end=591 - _globals['_RETRACTRESPONSE']._serialized_start=593 - _globals['_RETRACTRESPONSE']._serialized_end=635 - _globals['_SUBSCRIBEREQUEST']._serialized_start=637 - _globals['_SUBSCRIBEREQUEST']._serialized_end=675 - _globals['_FACTCHANGE']._serialized_start=677 - _globals['_FACTCHANGE']._serialized_end=770 - _globals['_RELOADREQUEST']._serialized_start=772 - _globals['_RELOADREQUEST']._serialized_end=864 - _globals['_RELOADRESPONSE']._serialized_start=866 - _globals['_RELOADRESPONSE']._serialized_end=966 - _globals['_FATHOMSERVICE']._serialized_start=1037 - _globals['_FATHOMSERVICE']._serialized_end=1459 + _globals['_EVALUATERESPONSE']._serialized_start=171 + _globals['_EVALUATERESPONSE']._serialized_end=484 + _globals['_EVALUATERESPONSE_METADATAENTRY']._serialized_start=391 + _globals['_EVALUATERESPONSE_METADATAENTRY']._serialized_end=438 + _globals['_ASSERTFACTREQUEST']._serialized_start=486 + _globals['_ASSERTFACTREQUEST']._serialized_end=562 + _globals['_ASSERTFACTRESPONSE']._serialized_start=564 + _globals['_ASSERTFACTRESPONSE']._serialized_end=601 + _globals['_QUERYREQUEST']._serialized_start=603 + _globals['_QUERYREQUEST']._serialized_end=676 + _globals['_QUERYRESPONSE']._serialized_start=678 + _globals['_QUERYRESPONSE']._serialized_end=713 + _globals['_RETRACTREQUEST']._serialized_start=715 + _globals['_RETRACTREQUEST']._serialized_end=790 + _globals['_RETRACTRESPONSE']._serialized_start=792 + _globals['_RETRACTRESPONSE']._serialized_end=834 + _globals['_SUBSCRIBEREQUEST']._serialized_start=836 + _globals['_SUBSCRIBEREQUEST']._serialized_end=874 + _globals['_FACTCHANGE']._serialized_start=876 + _globals['_FACTCHANGE']._serialized_end=969 + _globals['_RELOADREQUEST']._serialized_start=971 + _globals['_RELOADREQUEST']._serialized_end=1063 + _globals['_RELOADRESPONSE']._serialized_start=1065 + _globals['_RELOADRESPONSE']._serialized_end=1165 + _globals['_FATHOMSERVICE']._serialized_start=1236 + _globals['_FATHOMSERVICE']._serialized_end=1658 # @@protoc_insertion_point(module_scope) diff --git a/src/fathom/proto/fathom_pb2.pyi b/src/fathom/proto/fathom_pb2.pyi index 5650bdf2..aff8e9b2 100644 --- a/src/fathom/proto/fathom_pb2.pyi +++ b/src/fathom/proto/fathom_pb2.pyi @@ -34,18 +34,29 @@ class EvaluateRequest(_message.Message): def __init__(self, session_id: _Optional[str] = ..., ruleset: _Optional[str] = ..., facts: _Optional[_Iterable[_Union[FactInput, _Mapping]]] = ...) -> None: ... class EvaluateResponse(_message.Message): - __slots__ = ("decision", "reason", "rule_trace", "module_trace", "duration_us") + __slots__ = ("decision", "reason", "rule_trace", "module_trace", "duration_us", "metadata", "attestation_token") + class MetadataEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... DECISION_FIELD_NUMBER: _ClassVar[int] REASON_FIELD_NUMBER: _ClassVar[int] RULE_TRACE_FIELD_NUMBER: _ClassVar[int] MODULE_TRACE_FIELD_NUMBER: _ClassVar[int] DURATION_US_FIELD_NUMBER: _ClassVar[int] + METADATA_FIELD_NUMBER: _ClassVar[int] + ATTESTATION_TOKEN_FIELD_NUMBER: _ClassVar[int] decision: str reason: str rule_trace: _containers.RepeatedScalarFieldContainer[str] module_trace: _containers.RepeatedScalarFieldContainer[str] duration_us: int - def __init__(self, decision: _Optional[str] = ..., reason: _Optional[str] = ..., rule_trace: _Optional[_Iterable[str]] = ..., module_trace: _Optional[_Iterable[str]] = ..., duration_us: _Optional[int] = ...) -> None: ... + metadata: _containers.ScalarMap[str, str] + attestation_token: str + def __init__(self, decision: _Optional[str] = ..., reason: _Optional[str] = ..., rule_trace: _Optional[_Iterable[str]] = ..., module_trace: _Optional[_Iterable[str]] = ..., duration_us: _Optional[int] = ..., metadata: _Optional[_Mapping[str, str]] = ..., attestation_token: _Optional[str] = ...) -> None: ... class AssertFactRequest(_message.Message): __slots__ = ("session_id", "template", "data_json") diff --git a/tests/test_audit.py b/tests/test_audit.py index 4244300c..d1bf63b2 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -719,3 +719,74 @@ def test_audit_records_still_use_the_model_serialiser(self, tmp_path: Any) -> No row = json.loads(path.read_text(encoding="utf-8").strip()) assert row["decision"] == "deny" assert "timestamp" in row + + +class TestAuditRecordCarriesTheAttestation: + """An exported line is verifiable on its own, not only with the caller's copy. + + ``AuditRecord`` used to omit the JWT the same evaluation produced, so a + line lifted out of the log proved nothing without the token the caller + happened to keep beside it. + """ + + def test_token_is_none_on_an_engine_that_does_not_sign(self, tmp_path: Path) -> None: + path = tmp_path / "a.jsonl" + Engine(audit_sink=FileSink(path)).evaluate() + assert json.loads(path.read_text(encoding="utf-8").strip())["attestation_token"] is None + + def test_signed_engine_writes_a_verifiable_line(self, tmp_path: Path) -> None: + from fathom.attestation import AttestationService, verify_token + + service = AttestationService.generate_keypair() + path = tmp_path / "a.jsonl" + engine = Engine(audit_sink=FileSink(path), attestation_service=service) + result = engine.evaluate() + + row = json.loads(path.read_text(encoding="utf-8").strip()) + assert row["attestation_token"] == result.attestation_token + claims = verify_token(row["attestation_token"], service.public_key) + assert claims["decision"] == row["decision"] + assert claims["rule_trace"] == row["rules_fired"] + + +class TestChainedLogAsASink: + """``ChainedAttestationLog`` is usable wherever an ``AuditSink`` is taken.""" + + def test_engine_evaluations_land_in_a_verifiable_chain(self, tmp_path: Path) -> None: + from fathom.attestation import AttestationService + from fathom.chained_log import ChainedAttestationLog, verify_chain + + service = AttestationService.generate_keypair() + path = tmp_path / "chain.jsonl" + log = ChainedAttestationLog(path, service) + assert isinstance(log, AuditSink) + + engine = Engine(audit_sink=log) + engine.evaluate() + engine.evaluate() + log.close() + + result = verify_chain(path, path.with_suffix(".jsonl.pub.pem")) + assert result.ok, result.error + # `count` is the highest seq; genesis is seq 0, so two evaluations + # leave the head at 2. + assert result.count == 2 + assert len(path.read_text(encoding="utf-8").splitlines()) == 3 + + def test_a_deleted_line_breaks_the_chain(self, tmp_path: Path) -> None: + from fathom.attestation import AttestationService + from fathom.chained_log import ChainedAttestationLog, verify_chain + + service = AttestationService.generate_keypair() + path = tmp_path / "chain.jsonl" + log = ChainedAttestationLog(path, service) + engine = Engine(audit_sink=log) + engine.evaluate() + engine.evaluate() + log.close() + + lines = path.read_text(encoding="utf-8").splitlines() + path.write_text("\n".join(lines[:1] + lines[2:]) + "\n", encoding="utf-8") + + result = verify_chain(path, path.with_suffix(".jsonl.pub.pem")) + assert not result.ok diff --git a/tests/test_engine.py b/tests/test_engine.py index 5701103a..4ddbf4b8 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -144,3 +144,50 @@ def test_load_modules_focus_order_is_file_order_independent(tmp_path: Path) -> N engine = Engine() engine.load_modules(str(modules_dir)) assert engine.focus_order == ["beta", "alpha"] + + +def test_declared_modules_fire_without_an_explicit_focus_order(tmp_path: Path) -> None: + """A pack with modules and no `focus_order` used to fire nothing at all. + + CLIPS drains only the focused module's agenda, so every rule scoped to a + declared module sat unfired and the caller got the default decision back + — a wrong answer that looked like a legitimate deny. + """ + from fathom.engine import Engine + + _write_pack(tmp_path, "modules:\n - name: gov\n priority: 100\n") + rules_dir = tmp_path / "rules" + rules_dir.mkdir() + (rules_dir / "r.yaml").write_text(_rules_yaml("permit", "allow", "on the list")) + + engine = Engine.from_rules(str(tmp_path)) + assert engine.focus_order == ["gov"] + + result = engine.evaluate_once([("agent", {"id": "alice"})]) + assert result.decision == "allow" + assert result.rule_trace == ["gov::permit"] + + +def test_implicit_focus_follows_declaration_order(tmp_path: Path) -> None: + """Declaration order, not sort order: the default has to be deterministic.""" + from fathom.engine import Engine + + _write_pack( + tmp_path, + "modules:\n - name: zeta\n priority: 100\n - name: alpha\n priority: 90\n", + ) + engine = Engine.from_rules(str(tmp_path)) + assert engine.focus_order == ["zeta", "alpha"] + + +def test_explicit_focus_order_still_wins(tmp_path: Path) -> None: + """The implicit default must never override what the author wrote.""" + from fathom.engine import Engine + + _write_pack( + tmp_path, + "modules:\n - name: gov\n priority: 100\n - name: sec\n priority: 90\n" + "focus_order: [sec]\n", + ) + engine = Engine.from_rules(str(tmp_path)) + assert engine.focus_order == ["sec"] diff --git a/tests/test_grpc_evaluate_shape.py b/tests/test_grpc_evaluate_shape.py new file mode 100644 index 00000000..6f84ac1d --- /dev/null +++ b/tests/test_grpc_evaluate_shape.py @@ -0,0 +1,133 @@ +"""What ``Evaluate`` puts on the wire: metadata, attestation, unset fields. + +The RPC response used to carry less than the REST one — no rule metadata, no +attestation token, and no way to tell "no rule decided" from a decision of +``""``. These tests pin the aligned shape. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import TYPE_CHECKING, Any + +import pytest + +from fathom.attestation import AttestationService, verify_token +from fathom.engine import Engine +from fathom.integrations.grpc_server import FathomServicer + +if TYPE_CHECKING: # pragma: no cover - annotation-only import + from pathlib import Path + +_TEMPLATES = """ +templates: + - name: agent + slots: + - name: id + type: string + required: true +""" + +_MODULES = """ +modules: + - name: governance +focus_order: + - governance +""" + +_RULES = """ +module: governance +rules: + - name: tag-known-agent + when: + - template: agent + conditions: + - slot: id + expression: "equals(a1)" + then: + action: allow + reason: "known agent" + metadata: + policy: clearance-v1 + control: AC-3 +""" + + +class _FakeContext: + """Minimal ServicerContext double: ``abort`` records and raises.""" + + def __init__(self) -> None: + self.metadata: tuple[tuple[str, str], ...] = (("authorization", "Bearer testtok"),) + self.aborted: tuple[object, str] | None = None + + def invocation_metadata(self) -> tuple[tuple[str, str], ...]: + return self.metadata + + def abort(self, code: object, detail: str) -> None: + self.aborted = (code, detail) + raise RuntimeError(f"aborted: {detail}") + + +@pytest.fixture +def ruleset_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """A jail root holding one ruleset whose rule writes metadata.""" + pack = tmp_path / "tagged" + for subdir, name, body in ( + ("templates", "agent.yaml", _TEMPLATES), + ("modules", "modules.yaml", _MODULES), + ("rules", "tagged.yaml", _RULES), + ): + (pack / subdir).mkdir(parents=True) + (pack / subdir / name).write_text(body, encoding="utf-8") + + monkeypatch.setenv("FATHOM_API_TOKEN", "testtok") + monkeypatch.setenv("FATHOM_RULESET_ROOT", str(tmp_path)) + return tmp_path + + +def _evaluate(svc: FathomServicer, ctx: _FakeContext, fact_id: str = "a1") -> Any: + return svc.Evaluate( + SimpleNamespace( + session_id="S", + ruleset="tagged", + facts=[SimpleNamespace(template="agent", data_json=f'{{"id": "{fact_id}"}}')], + ), + ctx, + ) + + +class TestEvaluateResponseShape: + def test_rule_metadata_reaches_the_client(self, ruleset_root: Path) -> None: + response = _evaluate(FathomServicer(), _FakeContext()) + assert response.decision == "allow" + assert dict(response.metadata) == {"policy": "clearance-v1", "control": "AC-3"} + + def test_no_decision_leaves_the_field_unset(self, ruleset_root: Path) -> None: + """``optional`` is what separates "unset" from a decision of ``""``. + + An engine built without a default decision is the case that produces + one: no rule fires, nothing is decided, and the client must be able + to see that rather than read an empty string as a verdict. + """ + svc = FathomServicer(default_engine=Engine(default_decision=None)) + response = svc.Evaluate( + SimpleNamespace(session_id="", ruleset="", facts=[]), + _FakeContext(), + ) + assert response.rule_trace == [] + assert not response.HasField("decision") + assert not response.HasField("reason") + assert response.decision == "" + + def test_token_is_unset_without_an_attestation_service(self, ruleset_root: Path) -> None: + response = _evaluate(FathomServicer(), _FakeContext()) + assert not response.HasField("attestation_token") + + def test_configured_service_signs_the_evaluation(self, ruleset_root: Path) -> None: + service = AttestationService.generate_keypair() + response = _evaluate(FathomServicer(attestation=service), _FakeContext()) + + assert response.HasField("attestation_token") + claims = verify_token(response.attestation_token, service.public_key) + assert claims["decision"] == "allow" + assert claims["rule_trace"] == list(response.rule_trace) diff --git a/tests/test_public_surface.py b/tests/test_public_surface.py new file mode 100644 index 00000000..326a0958 --- /dev/null +++ b/tests/test_public_surface.py @@ -0,0 +1,70 @@ +"""Bind the declared public surface to the one the package actually exposes. + +`VERSIONING.md` promises that everything in `fathom.__all__` keeps working and +that everything else may move without notice. A promise nobody checks drifts: +the list rots as symbols are added, and callers cannot tell which half of the +document is current. These tests fail on that drift instead. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +import fathom +from fathom import attestation, audit, chained_log, engine, errors, fleet, models + +VERSIONING = Path(__file__).resolve().parents[1] / "VERSIONING.md" + +_COVERED_BLOCK = re.compile( + r"\n(.*?)\n", + re.S, +) +_BULLET = re.compile(r"^- `([^`]+)`$", re.M) + +#: Every module VERSIONING.md names as having its own covered ``__all__``. +DOCUMENTED_MODULES = [attestation, audit, chained_log, engine, errors, fleet, models] + + +def _documented_symbols() -> list[str]: + match = _COVERED_BLOCK.search(VERSIONING.read_text(encoding="utf-8")) + assert match is not None, "VERSIONING.md is missing its COVERED SYMBOLS markers" + return _BULLET.findall(match.group(1)) + + +def test_versioning_md_lists_exactly_the_exported_symbols() -> None: + documented = _documented_symbols() + assert documented == sorted(fathom.__all__), ( + "VERSIONING.md and fathom.__all__ disagree. Adding a symbol to __all__ " + "commits the project to keeping it working, so the list in " + "VERSIONING.md has to be updated in the same change." + ) + + +@pytest.mark.parametrize("name", sorted(fathom.__all__)) +def test_every_exported_symbol_resolves(name: str) -> None: + """Including the lazy ones, which a typo would turn into an AttributeError.""" + assert getattr(fathom, name, None) is not None + + +@pytest.mark.parametrize("module", DOCUMENTED_MODULES, ids=lambda m: m.__name__) +def test_documented_modules_declare_a_surface(module: object) -> None: + names = getattr(module, "__all__", None) + assert names, f"{module.__name__} is named in VERSIONING.md but declares no __all__" + assert list(names) == sorted(names), f"{module.__name__}.__all__ is not sorted" + for name in names: + assert hasattr(module, name), f"{module.__name__}.__all__ names a missing {name!r}" + + +def test_top_level_exports_come_from_a_module_that_declares_them() -> None: + """A re-export must be public where it is defined, not only at the top. + + Exporting a name from ``fathom`` while its own module keeps it internal + leaves two answers to "is this covered?", and the wrong one is reachable + by the import path the docs use. + """ + surfaces = {n for m in DOCUMENTED_MODULES for n in getattr(m, "__all__", ())} + unbacked = [n for n in fathom.__all__ if not n.startswith("__") and n not in surfaces] + assert not unbacked, f"exported from fathom but not from their own module: {unbacked}" diff --git a/tests/test_rest.py b/tests/test_rest.py index ab0e94b8..1854cc2f 100644 --- a/tests/test_rest.py +++ b/tests/test_rest.py @@ -4,13 +4,18 @@ import os import time +from typing import TYPE_CHECKING import pytest from fastapi.testclient import TestClient +from fathom.attestation import AttestationService, verify_token from fathom.integrations.rest import SessionStore, app, session_store from fathom.integrations.sessions import SessionLimitError +if TYPE_CHECKING: # pragma: no cover - annotation-only import + from pathlib import Path + FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures") @@ -618,6 +623,7 @@ def test_all_response_fields_present( "rule_trace", "module_trace", "duration_us", + "metadata", "attestation_token", } assert set(data.keys()) == expected_keys @@ -639,6 +645,171 @@ def test_empty_facts_returns_valid_response( assert "duration_us" in data +# --------------------------------------------------------------------------- +# 6b. Decision metadata and attestation on the evaluate response +# --------------------------------------------------------------------------- + + +@pytest.fixture +def metadata_ruleset(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> str: + """A one-rule ruleset whose ``then`` clause carries metadata. + + The shared fixtures ruleset has no ``then.metadata``, so a rule that + writes some is built here rather than added to a ruleset every other + test asserts against. + """ + (tmp_path / "templates").mkdir() + (tmp_path / "templates" / "agent.yaml").write_text( + "templates:\n" + " - name: agent\n" + " slots:\n" + " - name: id\n" + " type: string\n" + " required: true\n", + encoding="utf-8", + ) + (tmp_path / "modules").mkdir() + (tmp_path / "modules" / "modules.yaml").write_text( + "modules:\n - name: governance\nfocus_order:\n - governance\n", + encoding="utf-8", + ) + (tmp_path / "rules").mkdir() + (tmp_path / "rules" / "tagged.yaml").write_text( + "module: governance\n" + "rules:\n" + " - name: tag-every-agent\n" + " when:\n" + " - template: agent\n" + " conditions:\n" + " - slot: id\n" + ' expression: "equals(a1)"\n' + " then:\n" + " action: allow\n" + ' reason: "agent present"\n' + " metadata:\n" + " policy: clearance-v1\n" + " control: AC-3\n", + encoding="utf-8", + ) + monkeypatch.setenv("FATHOM_RULESET_ROOT", str(tmp_path)) + return str(tmp_path) + + +class TestEvaluateMetadata: + """``then.metadata`` of the firing rule reaches the HTTP response.""" + + def test_metadata_is_returned( + self, + client: TestClient, + auth_headers: dict[str, str], + metadata_ruleset: str, + ) -> None: + response = client.post( + "/v1/evaluate", + json={ + "facts": [{"template": "agent", "data": {"id": "a1"}}], + "ruleset": "", + }, + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["decision"] == "allow" + assert data["metadata"] == {"policy": "clearance-v1", "control": "AC-3"} + + def test_metadata_is_empty_when_no_rule_writes_any( + self, client: TestClient, auth_headers: dict[str, str] + ) -> None: + response = client.post( + "/v1/evaluate", + json={ + "facts": [{"template": "agent", "data": {"id": "a1", "clearance": "secret"}}], + "ruleset": "", + }, + headers=auth_headers, + ) + assert response.json()["metadata"] == {} + + +class TestEvaluateAttestation: + """A configured attestation service signs what ``/v1/evaluate`` returns.""" + + @pytest.fixture + def attestation(self, monkeypatch: pytest.MonkeyPatch) -> AttestationService: + """Put a real signing service on ``app.state`` for one test.""" + service = AttestationService.generate_keypair() + monkeypatch.setattr(app.state, "attestation", service, raising=False) + return service + + def test_token_is_null_without_a_service( + self, + client: TestClient, + auth_headers: dict[str, str], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + # `app` is module-level and other suites leave a service on its + # state, so the no-service path has to be asked for explicitly. + monkeypatch.setattr(app.state, "attestation", None, raising=False) + response = client.post( + "/v1/evaluate", + json={"facts": [], "ruleset": ""}, + headers=auth_headers, + ) + assert response.json()["attestation_token"] is None + + def test_stateless_evaluate_is_signed( + self, + client: TestClient, + auth_headers: dict[str, str], + attestation: AttestationService, + ) -> None: + response = client.post( + "/v1/evaluate", + json={ + "facts": [ + {"template": "agent", "data": {"id": "a1", "clearance": "secret"}}, + { + "template": "data_request", + "data": { + "agent_id": "a1", + "classification": "top-secret", + "resource": "doc-1", + }, + }, + ], + "ruleset": "", + }, + headers=auth_headers, + ) + data = response.json() + assert data["attestation_token"] is not None + claims = verify_token(data["attestation_token"], attestation.public_key) + assert claims["decision"] == data["decision"] == "deny" + assert claims["rule_trace"] == data["rule_trace"] + + def test_session_evaluate_is_signed( + self, + client: TestClient, + auth_headers: dict[str, str], + attestation: AttestationService, + ) -> None: + """The service reaches session engines too, which build their own.""" + response = client.post( + "/v1/evaluate", + json={ + "facts": [{"template": "agent", "data": {"id": "a1", "clearance": "secret"}}], + "ruleset": "", + "session_id": "signed-session", + }, + headers=auth_headers, + ) + data = response.json() + assert data["attestation_token"] is not None + claims = verify_token(data["attestation_token"], attestation.public_key) + # The claim carries the engine's own session id, not the REST one. + assert claims["decision"] == data["decision"] + + # --------------------------------------------------------------------------- # 7. Working-memory endpoints: POST /v1/facts, /v1/query, DELETE /v1/facts # --------------------------------------------------------------------------- diff --git a/tests/test_scripts/test_check_doc_sources.py b/tests/test_scripts/test_check_doc_sources.py index b8180f5b..673e38c7 100644 --- a/tests/test_scripts/test_check_doc_sources.py +++ b/tests/test_scripts/test_check_doc_sources.py @@ -4,9 +4,14 @@ from pathlib import Path -def _run(repo: Path) -> subprocess.CompletedProcess[str]: +def _run(repo: Path, *args: str) -> subprocess.CompletedProcess[str]: return subprocess.run( - [sys.executable, str(Path.cwd() / "scripts" / "check_doc_sources.py"), str(repo)], + [ + sys.executable, + str(Path.cwd() / "scripts" / "check_doc_sources.py"), + str(repo), + *args, + ], capture_output=True, text=True, check=False, @@ -152,3 +157,66 @@ def test_page_without_sources_is_skipped(tmp_path: Path) -> None: _commit(tmp_path, "docs") result = _run(tmp_path) assert result.returncode == 0 + + +# --- --changed-vs: the merge-gate scope -------------------------------------- +# +# A source edit ages every page citing it, so the whole-repo sweep is the wrong +# gate for a pull request: it fails whoever opens the next unrelated one. +# `--changed-vs REF` narrows the check to the sources the branch itself touched. + + +def _two_page_repo(root: Path) -> None: + """Two pages, two sources, both pages stale against their own source.""" + _init_repo(root) + (root / "src").mkdir() + (root / "src" / "module.py").write_text("x = 1\n") + (root / "src" / "other.py").write_text("y = 1\n") + _commit(root, "src") + (root / "docs").mkdir() + (root / "docs" / "page.md").write_text(PAGE_TEMPLATE.format(verified="2000-01-01")) + (root / "docs" / "other.md").write_text( + PAGE_TEMPLATE.format(verified="2000-01-01").replace("src/module.py", "src/other.py") + ) + _commit(root, "docs") + subprocess.run(["git", "branch", "base"], cwd=root, check=True) + + +def test_changed_vs_reports_only_the_sources_the_branch_touched(tmp_path: Path) -> None: + _two_page_repo(tmp_path) + (tmp_path / "src" / "module.py").write_text("x = 2\n") + _commit(tmp_path, "touch module only") + + scoped = _run(tmp_path, "--changed-vs", "base") + assert scoped.returncode == 1, scoped.stdout + assert "page.md" in scoped.stderr + # other.py is just as stale, but this branch did not touch it -- failing on + # it would punish this author for someone else's drift. + assert "other.md" not in scoped.stderr + + sweep = _run(tmp_path) + assert sweep.returncode == 1 + assert "other.md" in sweep.stderr + + +def test_changed_vs_passes_when_the_page_is_reverified_in_the_same_branch( + tmp_path: Path, +) -> None: + _two_page_repo(tmp_path) + (tmp_path / "src" / "module.py").write_text("x = 2\n") + (tmp_path / "docs" / "page.md").write_text(PAGE_TEMPLATE.format(verified="2099-01-01")) + _commit(tmp_path, "touch module, re-verify its page") + + assert _run(tmp_path, "--changed-vs", "base").returncode == 0 + + +def test_changed_vs_unknown_ref_checks_everything(tmp_path: Path) -> None: + """A bad ref must not turn the gate into a no-op.""" + _two_page_repo(tmp_path) + (tmp_path / "src" / "module.py").write_text("x = 2\n") + _commit(tmp_path, "touch module only") + + result = _run(tmp_path, "--changed-vs", "no-such-ref") + assert result.returncode == 1 + assert "cannot diff" in result.stderr + assert "other.md" in result.stderr diff --git a/tests/test_sdk.py b/tests/test_sdk.py index 292f1cf6..37033f96 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -86,7 +86,12 @@ def test_all_contains_expected_names(self) -> None: "AssertSpec", "AssertedFact", } - assert expected == set(fathom.__all__) + # Subset, not equality: the exact contents of __all__ are bound to + # VERSIONING.md by tests/test_public_surface.py, which is where a new + # export gets reviewed. What this test guards is narrower and worth + # keeping separate -- these particular names have callers, and none of + # them may quietly leave the surface. + assert expected <= set(fathom.__all__) # ---------------------------------------------------------------------------