Tracker record contract
The tracker.* ops, and the tracker legs of code.* and loom_search, read a team's
issues, pull requests and incidents live from the team's own ClickHouse with a read-only
credential. Loom does not copy the record. Every statement is written against one logical
model, the contract: the tables and columns below.
A team connects its schema in one of two ways:
| Mode | How |
|---|---|
| Views | The team creates views with the contract's table and column names. Loom reads them as they are. |
| Mapping | Loom lists the tables the credential can read and proposes a mapping. The confirmed mapping is saved as data; nothing is created remotely. |
A mapping is only a projection in front of the contract, so the same statements, tests and
privacy predicates run for every source. The contract as code is
server/tracker/domain/contract.py (REQUIRED, OPTIONAL, FEATURES, ALIASES), the
mapping is server/tracker/domain/mapping.py, and the columns read per table are
COLUMNS_READ in server/tracker/domain/columns.py.
Adding a tracker source
-
Create a read-only ClickHouse user for Loom with
readonly = 2.readonly = 1refuses the per-query settings Loom sends with every read, so no read would run (CREDENTIAL_READONLY_MODE).CREATE USER loom_ro IDENTIFIED BY '…' SETTINGS readonly = 2; GRANT SELECT ON system.columns TO loom_ro; -- views mode: the views only GRANT SELECT ON loom_tracker.* TO loom_ro; -- mapping mode instead: each mapped table GRANT SELECT ON tracker_db.tickets TO loom_ro; -
Views mode only: create the views. A database (say
loom_tracker) withgithub_issues,github_prsandedges, plus any optional tables you have data for. Create themDEFINER = CURRENT_USER SQL SECURITY DEFINER, soloom_roneedsSELECTon the views and nothing on the tables behind them. A plain view reads with the reader's own rights, and every read would fail withACCESS_DENIED; the registration check catches this and names the fix. A source outside ClickHouse can sit behind aPostgreSQL,MySQLorS3engine table.tracker.contractreturns every table and column with what it holds and a blank view template per table, including aloom_uuid5(name)SQL function for the itemid. -
Register the source. Any signed-in member of the org can, from the dashboard's Add a tracker flow (tracker overview) or with the ops below; the registrant becomes the first
adminof the tracker namespaces. Before anything is written Loom checks that the host resolves to a public address (HOST_REFUSED), that the credential is read-only (CREDENTIAL_NOT_READONLY,CREDENTIAL_READONLY_MODE), that it can connect (SOURCE_UNREACHABLE) and that the schema or mapping meets the contract (CONTRACT_FAILED). Read-only means every access typeSHOW GRANTSandSHOW GRANTS FINALlist, the granted roles' types included, isSELECT, aSHOWof databases, tables, columns or dictionaries,dictGetorCREATE TEMPORARY TABLE, and nothing is grantedWITH GRANT OPTIONorWITH ADMIN OPTION, on a nested role either; any other type is refused. Every role granted to the user must be a default role, so the check sees its whole chain. A server that does not acceptSHOW GRANTS FINALis refused (SOURCE_UNREACHABLE). Withdry_run: truea failing schema comes back as the report instead, with aCREATE OR REPLACE VIEWdraft per failing table.Op Does tracker.contractThe contract and view templates, before any connection. tracker.source.inspectConnects and lists every readable table with its columns, plus a proposed mapping. Writes nothing. tracker.source.addname,host,port(8443),secure(true),database,user,password,namespaces, optionalmapping;dry_run: trueruns every check and writes nothing.tracker.source.set_mappingReplace a registered source's mapping ( dry_runtoo);mapping: nullreturns it to views mode.tracker.source.checkRe-run the contract check now. tracker.source.rotateReplace the credential. tracker.source.removeRetire it and its namespaces ( confirm).tracker.source.listEvery source of the org: contract verdict, breaker state, namespaces and the caller's standing in each. No credential is ever returned. Each entry of
namespacesis{namespace, repos, restricted, join_policy}:reposare theOrg/repoprefixes ofsource_idthe namespace may read (a repository not listed is never read);restrictedmakes it a private record;join_policyisopen(default) orapprovalfor a standard namespace. Self-service registration needsLOOM_TRACKER_ORGand is on by default (LOOM_TRACKER_SELF_SERVICE_ENABLED, at mostLOOM_TRACKER_SELF_SERVICE_MAX_SOURCESper org, default 10;LOOM_TRACKER_SELF_SERVICE_ALLOW_PRIVATE_HOSTS=truefor a local development ClickHouse). The password is stored encrypted and never returned.inspectby name needsadminon one of the source's namespaces;set_mapping,rotateandremoveneed it on every one, since each changes what every namespace of the source reads. A standard namespace is decided by the gate atadmin, and a restricted one by the restricted gate's rules except that the token need not name it; registering the source is not enough once the registrant's grant is revoked (restricted namespaces). A dashboard session of anyone but a platform admin is awritertoken, so it cannot change or retire a source with a private (restricted) namespace: that takes a token with theadminrole, which an operator mints withloom mint-token --roles admin. The page shows those buttons disabled with the reason; the server decides.An operator can configure the same source instead (configuration); a configured entry wins over a registered one of the same name and is changed only in configuration (
SOURCE_FROM_CONFIG). -
Loom creates the namespaces' directory rows:
kind = tracker, private,classification = restrictedfor a restricted namespace,join_policyopen(orapproval) for a standard one andapprovalfor a restricted one. -
Readers join.
namespace.joinwithrole: "viewer"on an open tracker namespace admits at once, service accounts included. A restricted one waits for its admin, and the reader's token must name the namespace (identities and namespaces). -
Link the tracker to a repository so agents working in that checkout read it:
code.repo.set_sourcewithtracker_namespaces(repositoryadmin), the repository card in the dashboard, or, for the operator's own repositories only,LOOM_TRACKER_CODE_NAMESPACE_BINDINGS_JSON. A repository may link several trackers in order; the first is the primary, whose numbers are the repository's issue and PR numbers (linking a tracker).tracker.bindingreports what a code namespace resolves to for the caller.
Configuration
The operator's form of the same entry:
LOOM_TRACKER_ORG=acme
LOOM_TRACKER_SOURCES_JSON='{
"sources": {
"widgets": {"host": "abc.clickhouse.cloud", "port": 8443, "secure": true, "database": "loom_tracker"}
},
"namespaces": {
"tracker-widgets": {"source": "widgets", "repos": ["acme/widgets"]},
"tracker-widgets-private": {"source": "widgets", "repos": ["acme/widgets-private"], "restricted": true}
}
}'
LOOM_TRACKER_CREDENTIALS_JSON='{"widgets": {"user": "loom_ro", "password": "…"}}'
LOOM_TRACKER_CODE_NAMESPACE_BINDINGS_JSON='{"code-widgets*": ["tracker-widgets", "tracker-helpdesk"]}'
Credentials sit in their own variable, apart from the map, so the map can be shown and
diffed without the secret. A binding value is one tracker namespace or an ordered list,
primary first; keys are globs over the code namespace names of the operator's
repositories (a self-service repository is linked by its maintainer). A maintainer's
code.repo.set_source links replace the map for that repository. Tracker namespaces
belong to LOOM_TRACKER_ORG; with it empty no tracker namespace serves.
Source entry options
A source entry may also carry:
"mapping": the saved mapping (mapping mode); absent means views mode."checks": {"from": "<table or table function>", "repo": "Org/repo"}: where the tracker's CI results live, read bytracker.flakyandtracker.test_signal. Resolution: this entry, else the source's owncheckstable, elseLOOM_TRACKER_CHECKS_FROM/LOOM_TRACKER_CHECKS_REPO(both empty by default: no CI checks). Name a table function rather than a view over it: a view's filter runs on the remote side after the scan. With norepo, a namespace with a single repository uses that one."bisect": {"code_prefixes": ["src/"]}: which paths count as source code for bisect; absent means any path not under docs, tests, CI, examples or benchmarks. A repository with no tracker still bisects over Loom's own PR history (the answer then sayscandidates_from: "code_history")."columns": {"<table>": {"<contract column>": "<your column>"}}: contract columns your tables hold under another name, for example{"github_issues": {"triage_bisect_outcome": "bisect_result"}}. The live column is checked like the one it serves; only plain identifiers are accepted.
Rules:
- A namespace naming an unknown source does not serve (one error line at boot, never a guess).
- Each source has its own connection, circuit breaker and time budget, so one team's slow service never trips another's breaker.
- Registered sources (
loom.qa_sources) are re-read everyLOOM_TRACKER_SOURCES_REFRESH_SECONDS(60); a write in the same process refreshes at once.
Every tracker setting is in the configuration reference.
Public and private
A tracker namespace is either standard or restricted, enforced by Loom statement by statement:
- A standard namespace's every statement carries
is_private = 0, and the tables that hold only private data (core_incidents,support_cases,slack_threads) are refused before any read. - A restricted namespace lifts that predicate, reads only its own
repos, and is gated by the restricted-namespace rules: a token that names it and a direct grant.
Three layouts work:
- one view set with an
is_privatecolumn, read by both namespaces (a row policy onis_privatefor the read-only user adds a check on the ClickHouse side); - two view sets with two users, one holding only public rows, registered as two sources;
- a restricted namespace alone.
The contract
Types are compared by family: String, LowCardinality(String), Nullable(String),
FixedString and Enum are one; DateTime, DateTime64 and Date are one; the UInt*
and Int* types and Bool are one; Array(x) compares on x. A view over another store
may differ in codec and nullability, never in shape. The view templates write each family
as its widest type (UInt64, DateTime64(3), …).
Required tables
A source without these does not serve.
github_issues: one row per issue.
| Column | Family | Meaning |
|---|---|---|
id |
UUID | uuid5(NAMESPACE_DNS, 'github_issue:' \|\| source_id) (github_issue_private: for private rows); the key edges uses |
source_id |
String | Org/repo#number; the repository must be one the tracker namespace lists |
number |
UInt | the issue number |
url, title, body, author |
String | a link a person can open; the title every search reads; the text the duplicate check reads; who opened it |
labels, assignees |
Array(String) | |
primary_component, severity_level |
String | the team's taxonomy; '' when none |
state_reason |
String | completed, not_planned, duplicate or '' |
affected_versions, fix_versions |
Array(String) | versions the issue affects; versions that carry the fix |
is_flaky_test, is_crash_ci, is_performance |
UInt | 0/1 flags; 0 when unknown |
has_test |
UInt | a linked test exists; NULL when unknown |
error_code |
UInt | the server error code the report names; 0 when none |
is_private |
UInt | 1 for a private-repository row |
created_at, updated_at, closed_at |
DateTime | closed_at is epoch 0 while open |
triage_bisect_outcome, triage_introduced_commit, triage_component, triage_confidence |
String | a triage bot's verdict; served as '' when the tracker has none |
triage_reproduces_on, triage_clean_on |
Array(String) | likewise, served as [] |
github_prs: one row per pull request.
| Column | Family | Meaning |
|---|---|---|
id |
UUID | uuid5(NAMESPACE_DNS, 'github_pr:' \|\| source_id) (github_pr_private:) |
source_id, number, url, title, body, author |
as above | |
pr_number |
UInt | equal to number |
primary_component, severity_level, is_private, created_at, updated_at, closed_at |
as above | |
state |
String | open, closed or merged |
is_merged, has_test, review_requests_test |
UInt | |
test_types, reviewers, src_components |
Array(String) | |
ci_result, changelog_category, minor_version, base_branch |
String | |
is_backport_pr, is_cherrypick_pr, is_sync_pr |
UInt | derivative PRs, left out of activity reads |
upstream_pr_number |
UInt | the PR a backport repeats; 0 when none |
additions, deletions, time_to_merge_days |
UInt |
edges: links between items.
| Column | Family | Meaning |
|---|---|---|
from_id, to_id |
UUID | item ids as above |
from_type, to_type |
String | github_issue, github_pr, github_issue_private, github_pr_private, core_incident, support_case |
rel_type |
String | fixes, related_to, duplicate_of, caused_by, backport_of, reverts, … |
created_at |
DateTime |
Loom derives an item's id locally and joins edges on it, so a view must produce exactly
that value. ClickHouse has no uuid5(); the loom_uuid5 function tracker.contract
returns is the RFC 4122 version-5 construction in SQL, equal to Python's uuid.uuid5.
Optional tables
An absent table makes the ops that need it answer not_provided; a present table missing
one of its minimum columns (OPTIONAL in contract.py) is reported incomplete and
treated as absent.
| Table | Minimum columns |
|---|---|
core_incidents |
id, source_id, title, body, primary_component, severity_level, stack_functions Array(String) (stack-frame matching), error_code, created_at |
support_cases |
id, source_id, title |
slack_threads |
source_id, title, body |
component_risk_snapshot |
computed_at, component |
release_items |
release_version, issue_number |
release_health_snapshot |
computed_at, release_version |
version_history, version_history_private |
commit_sha, version |
checks |
check_name, test_name |
cloud_instances |
instance_id |
rel_instance_mentions |
from_id, instance_id |
slack_incidents |
incident_id |
core_incidents, support_cases and slack_threads are read only through a restricted
namespace.
Feature columns
Optional columns on required tables, reported present or absent. A feature column the source lacks reads its alias, or else a default (see Aliases): the statements that read it answer as for a row without it, and the feature stays absent.
| Feature | Columns | Unlocks |
|---|---|---|
bisect |
github_prs.diff, github_prs.affected_file_paths |
bisect candidates and culprit scans |
merge_time |
github_prs.merged_at |
merge-time facts |
vector |
github_issues.embedding, github_prs.embedding |
reported only; tracker.similar uses the embeddings Loom computes itself |
Aliases
A source that lacks a contract column still meets the contract when ALIASES in
contract.py names an accepted expression for it over columns the source does have. The
check verifies the alias's own columns by family and reports it as aliased; statements
keep reading the contract's name, because the table reference is replaced by a projection
that adds the alias (the query analyzer prunes it to the columns a statement reads). An
alias with no columns of its own is a default (defaulted): every row reads '', 0,
[] or NULL, and the report says so, so a default is never mistaken for data.
| Table | Column | Accepted forms, in order |
|---|---|---|
github_issues |
severity_level |
severity |
github_issues |
has_test |
toUInt8(metadata.has_test.:Bool) (a JSON metadata column), else NULL |
github_issues |
triage_bisect_outcome, triage_introduced_commit, triage_component, triage_confidence |
'' |
github_issues |
triage_reproduces_on, triage_clean_on |
[] |
github_prs |
has_test |
toUInt8(metadata.has_test.:Bool), else toUInt8(tests_added > 0) |
github_prs |
upstream_pr_number |
ifNull(toUInt32(metadata.original_pr_number.:Int64), 0), else 0 |
github_prs |
review_requests_test |
NULL |
github_prs |
test_types, src_components |
[] |
github_prs |
merged_at |
if(is_merged = 1, closed_at, toDateTime64(0, 3)) |
github_prs, core_incidents |
severity_level |
'' |
qa_bugs (the answer route's cross-source view) |
severity_level, has_test |
severity; toUInt8(metadata.has_test.:Bool), else NULL |
A rename the contract does not list is still a missing column. Declare it in the source
entry's columns map (source entry options), or add an Alias
in contract.py to accept another spelling for every source.
Mapping mode
A mapping says, for each logical table, which of the team's tables it reads and, for each logical column, an expression over that table's columns:
{
"version": 1,
"tables": {
"github_issues": {
"source": "tracker_db.tickets",
"final": true,
"columns": {
"source_id": { "concat": [{ "lit": "acme/widgets#" }, "ticket_no"] },
"number": "ticket_no",
"title": "summary",
"body": "description",
"created_at": "opened_at",
"state_reason": { "lower": "resolution" }
}
}
}
}
- Expressions are data, never SQL: a column name the live table has (or
{"col": …}),{"lit": …}, or one ofconcat,coalesce,eq,in,has,cast([expr, family]),lower,digits(the trailing number of a key:PROJ-1234→1234). Loom renders them with identifiers back-quoted and literals quoted, converts each to the family the statements read, and runs them with the read-only credential. sourceisdb.table, ortablein the tracker's database.final: truereads the table withFINAL(proposed for Replacing, Collapsing, Aggregating and Summing engines).- Loom derives each item's
idfrom the mappedsource_id, and an edge'sfrom_id/to_idfrom the mappedfrom_ref/to_ref(Org/repo#number). They are never mapped. - Each mapped table needs its core columns:
github_issuessource_id,number,title,created_at;github_prssource_id,pr_number,title,created_at;edgesfrom_ref,to_ref,from_type,to_type,rel_type;core_incidentssource_id,title,created_at. A mapping must mapgithub_issues. Unmapped columns take a default or are derived (a PR'sstatefrom its merged and closed fields), and the report lists them; a logical table the mapping does not name reads as an empty table. - The report's capabilities (
items,duplicate_check,duplicate_calibration,pull_requests,links,severity_board,bisect,incidents,ci_checks) say what the mapped data lets Loom answer.
At read time each logical table reference is replaced by the mapped projection, so the same statements, tests and privacy predicates run for every source.
Checking a source
loom tracker-contract-check # every configured source
loom tracker-contract-check --source widgets # one source
loom tracker-contract-check --namespace tracker-widgets
loom tracker-contract-check --json
The server runs the same check on the first tracker read of each process: it reads the
schema through the source's own credential (system.columns, or DESCRIBE per table when
that is hidden) and caches the verdict for LOOM_TRACKER_CONTRACT_TTL_S (3,600 s), a
failed one for 60 s so a fixed view is picked up within a minute. A source that fails
answers every read tracker_unavailable naming what is missing, never a partial answer.
tracker.source.check runs the check on demand.
Related
- Tracker overview: what the ops answer, linking, the dashboard flow
- History and PRs: the code-plane ops that read a tracker
- Configuration reference
- Operations reference