Evidence sources: Git, Jira, docs, deployment
Horologium's Product Truth is only as complete as the evidence feeding it. In
the target architecture, four kinds of source feed evidence into the Rust
core through the same provider contract (docs/adr/0011-evidence-provider-contract-and-provenance.md):
| Source class | What it's authoritative for | Trust tier |
|---|---|---|
Git (vcs) | Commit ancestry, tag containment, file contents at a commit | system_of_record |
Jira (tracker) | Issue status, fields, and explicit ticket-key references | system_of_record for structured fields |
Documentation (documentation) | Prose describing product behavior | asserted — a docs page states something without being the authority on whether it's still true |
Deployment records (deployment) | What release is running on what target, over what interval | system_of_record |
Every provider implements one shared trait (EvidenceProvider) and can only
ever emit evidence — never a claim or a finding directly. That boundary is
enforced by the type system, not by convention: the batch type a provider
returns has no variant for anything except an immutable evidence item with a
re-fetchable citation. See Integrity states and the
evidence model for what happens to evidence
once it's collected.
Egress control is live, not just designed: every outbound network
request any provider makes goes through one shared, egress-controlled HTTP
client (horologium-providers::egress::EgressClient, HORO-1397) — HTTPS-only
by default, an explicit host allowlist with no default wildcard, DNS-pinned
resolution (every address a lookup returns is checked against the
private/link-local/reserved deny list before anything connects to it), the
same check re-run in full on every redirect hop (capped at 3), a credential
refused outright rather than followed across a redirect, and a response-size
cap enforced by streaming rather than trusting Content-Length.
Registration-time validation (see the next section) narrows what a
collection run can even be pointed at; this client is what actually enforces
it at fetch time. For the local-filesystem-backed Docs, Deployment, Git and
Jira sources (HORO-1397 increments 1-2 and 4), an analogous confinement
check applies at open time instead — a canonicalized root directory
(HOROLOGIUM_SOURCE_ROOT) that every file/repository open is checked
against, refusing traversal and symlink escape, since no network request
happens for those sources at all. An unconfigured provider fetches or opens
nothing — see Security, privacy and data boundaries
for the full egress and credential model.
What's actually wired up for operators today
Being direct about the current state matters more here than anywhere else in
this documentation set. Git, Jira, docs, and deployment providers exist as
Rust implementations and are exercised end-to-end in this repository's test
suite (crates/horologium-providers, crates/horologium-providers-conformance)
against the same golden fixture the hosted demo runs.
As of HORO-1397, an operator-facing collection entrypoint also exists for
real: POST /v1/jobs {"job_kind": "collect"} (or collect_and_reconcile,
run by the horologium-runner binary) drives real collection over every
registered, enabled source — local Git/docs/deployment/Jira sources by
reading real files under HOROLOGIUM_SOURCE_ROOT, and a live Jira project
over the egress-controlled HTTP client described above. What does not
exist yet in this Public Alpha slice is a live/network Git, docs, or
deployment source (only Jira has a live/network implementation so far), or a
.env/config-file surface simpler than registering a source through the API.
In concrete terms, as a self-host operator today you can:
- Register a real local Git/docs/deployment/Jira source, or a live Jira
project, and run a real
collect/collect_and_reconcilejob against it — or seed the golden fixture (seed_golden_fixture, see the self-host quickstart) for a populated instance with no source of your own. - Read the full evidence/claim/finding model this repository implements, and
verify it against
docs/adr/0011-evidence-provider-contract-and-provenance.mdand the ontology document, both in the repository.
Registering a source (POST /v1/sources, ADR-0018) is real, live
operator-facing functionality today: it validates the source's config/
declared_scope against a typed per-provider schema, checks every URL
field against this deployment's HOROLOGIUM_EGRESS_ALLOWED_HOSTS
allowlist and refuses private/loopback/link-local IP literals, refuses
path traversal in declared_scope, and stores credential_ref (the
name of an env var or file path, never the value) without ever
resolving it. A Git, Docs, Deployment or Jira source may instead set
local_path (HORO-1397): a root-relative filesystem path under this
deployment's HOROLOGIUM_SOURCE_ROOT, used at collection time instead of
the URL field. When local_path is set, the URL field is still
shape-checked (absolute, https) but is not checked against the egress
allowlist or refused for naming a private IP — nothing is ever fetched
over the network for a local-path source, so it keeps its meaning purely
as citation identity. local_path itself is checked for traversal and
rejected if it is not root-relative. For Jira specifically, local_path
names a directory of Jira-issue-shaped JSON exports (the same trimmed
Jira Cloud REST API v3 shape the golden fixture uses) — this exists so a
tracker-class source can be exercised end-to-end without a live Jira
instance. A Jira source with no local_path is a real, live/network
source (HORO-1397 increment 5): credential_ref must be set, and
collection calls the configured Jira site over the shared
egress-controlled HTTP client. A live/network Git, Docs, or Deployment
source (one with no local_path) is still scoped ahead — only Jira has a
live implementation so far. GET /v1/sources reports each registered
source's origin ("local" or "live") so imported and live evidence
are never conflated.
A real collect/collect_and_reconcile job (POST /v1/jobs, run by the
horologium-runner binary) now does more than insert evidence rows: after
collecting, it normalizes each successfully-collected source's evidence
into canonical entities, and — for Jira's tracker.status field — an
observed claim, inside the same transaction that runs reconciliation and
publishes the snapshot (HORO-1397). Editing a registered local source's
underlying file(s) and re-running the job produces new evidence, updated
entities/relationships, and (for Jira) a changed claim — visible through
GET /v1/entities, GET /v1/entities/{id}/claims and GET /v1/findings.
A source that fails to collect on a given run never has its prior
evidence or claims deleted; only claims from a source that did collect
successfully this run are ever superseded.
Select Markdown sections for local Docs collection
A local Docs source can declare the exact level-two headings it watches:
{
"source_key": "product-handbook",
"provider_id": "docs",
"display_name": "Product handbook",
"config": {
"base_url": "https://example.invalid/handbook",
"local_path": "handbook"
},
"declared_scope": {
"paths": ["/product.md"],
"watched_sections": [{"path": "/product.md", "heading": "Product Profile"}]
},
"reality_layers": ["documented"]
}
Submit this body through the existing authenticated POST /v1/sources route,
then run an ordinary collection job. The selected file is beneath
HOROLOGIUM_SOURCE_ROOT/handbook; the URL remains its citation identity.
The selector path must exactly match a declared path. Heading matching is
case-sensitive and requires one complete ## Product Profile line. The page also
needs parseable YAML front matter with last_updated (for example,
last_updated: 2026-10-06T00:00:00Z) to retain its evidence timestamp; repeated
headings are ambiguous and produce no section evidence. The collector extracts
the opening sentence mechanically, retaining its original byte range and file
revision. It does not infer a capability or validate what the sentence asserts.
Multiple headings on one file are allowed; use a separate registered source for
another file so each citation retains its page identity. Omitted or empty
watched_sections preserves the existing behavior and emits no section evidence.
Duplicate selectors, paths outside the declared scope, malformed headings and
more than 100 selectors are rejected. Existing 2,048-byte string and 8 KiB scope
limits still apply. Empty sections remain empty; collection rechecks confinement
and refuses a symlink escape even after registration. If every source fails, the
job records the failures and publishes no snapshot.
Credential handling, as designed
Whenever provider configuration does land, the documented shape is already fixed by ADR-0015 and worth knowing in advance:
- Configuration stores a
credential_ref— the name of an environment variable or a file path — never a credential value. The core resolves it at process start. - Credentials are never logged, never included in an error message, never returned by any endpoint, and never written to evidence.
- Least privilege is the documented requirement: read-only tokens, scoped to the specific repository, project, or docs space you're connecting — never a broad admin token.
See Security, privacy and data boundaries for the complete model.