The built-in self test
http://<prism>/selftest
Prism runs where we cannot reach it. No logs, no shell, no query — so the box has to be able to answer, by itself, to someone with no context:
- What is running here, and where?
- Does it work?
- If not — is that Prism, or is it something upstream of Prism?
That third question is the one this page exists for. Press Run again to start a run; Download diagnostic bundle (.json) produces a file you can send us.
It is a verdict you run, not a meter. For what the ingest is doing right now — the rate Prism is calling your GitHub or Jira at, while it is calling — see What Prism is pulling, right now, which refreshes itself and is linked from the bottom of this page.
A run takes up to about two minutes on a cold install. That is deliberate: it waits out pods that are still starting rather than calling them down. The likeliest moment for anyone to press this button is minutes after an install, and a check with less patience than the product itself would report failures the product does not have.
How to read it
Checks are grouped by what they prove.
PRISM — the parts we ship: the database, the headroom left on its volume, the agent runner, each MCP server, and a real chat round trip.
AGENT ROUTER — the parts you and we share: the management API, the inference endpoint, and the model catalogue.
SOURCES — GitHub pull requests, GitHub issues, Jira and Router spend: which mode each runs in (synthetic or live), how many rows it holds, and how fresh they are. There is no line for a user directory: Prism keeps no list of people of its own, and the people in your organisation come from the source you designate as your organizational context stream, which appears on Admin → Context streams like any other source.
Three more lines appear once you designate an organizational context stream,
and they answer three different questions about it. designated route asks
whether the agent has a tool that can read the source you designated — a
designated stream is served through the semantic query MCP and through nothing
else, so an install whose MCP_PROFILES has no Semantic Query profile has
named a source of record for people that is not on the agent's tool list at all.
identity crosswalk asks the further question: whether the last compile FROM
that source left anything to resolve people through. It can fail while the
source itself is connected, verified and fresh, because a stream the compiler
will not read — a query-through view today — is emptied rather than left
resolving, on purpose: a directory nobody can compile from must not go on
answering about people. When it fails it carries the reason the ingest job
recorded, which names the fix. On an install that designates nothing both
lines are SKIPped rather than absent — they say so in as many words, and a
skipped line counts towards nothing.
The identity crosswalk line also carries how well the addresses Prism harvested from your forge join to the people on your staff list, and — where there are any — why the ones that do not, do not. Three questions are asked of every address that matches nobody:
- it carries a local part your staff list holds at a different domain — the same person spelled two ways, and the only one of the three anybody can act on. Change one spelling and the person resolves.
- it is on a domain your staff list never uses — usually a personal address someone put in their public profile.
- it is on a domain your list does use, under a local part it does not hold.
Expect the third to be large, and do not read it as a fault. A forge
organisation holds accounts that are not people on a staff list — contractors,
service accounts, leavers, machine users — so most of that number is people
your list was never going to name. It also cannot tell "not on the staff list"
apart from "on it under a different naming convention" (asmith against
alice.smith): separating those means matching names by their shape, which
Prism does not do, because a guess there attaches one person's work to another.
Beside those, the line reports members Prism asked about and kept nothing
for, by reason, counted over every member it has ever looked up. The reason
worth checking is domain_not_allowed: that is the address allowlist
(ingest.github.identityEmailDomains) doing what you set it to do, and a
number there means people on a second corporate domain are being left out of
the crosswalk. Whether that domain belongs in the allowlist is your call —
Prism reports the figure and changes nothing.
On the first bundles after upgrading, read that block with its companion number. Prism records the reason against each member as it looks them up, and the look-up is a rolling pass of a couple of hundred members at a time — so every member swept by an earlier version carries no reason until the pass comes round to them again, which on a large organisation takes weeks. The line says how many are in that state. While that number is large, an empty list of reasons is not a finding: it means almost nobody has been asked yet, not that nothing is being refused, and the line says so rather than leaving you to work it out.
None of these names a domain or an address. They are counts, because the self test's report is a file you may send back to us.
identity attribution asks the last question in that chain, and it is the one an install can fail while the other two pass: for each source whose rows name a person, does the crosswalk hold an identifier of a kind that source uses? A compile that linked four thousand email addresses and no GitHub logins has worked — and every question about who opened which pull request still resolves to nobody, because GitHub keys people by login and by nothing else. The line fails and names the sources and the kind each one needs:
FAIL identity attribution no activity on 2 enabled source(s) can be attributed
to a person — the crosswalk holds email 4589,
github_login 0 and github needs github_login;
gitlab needs gitlab_username. Every question about
who did what, on those sources, resolves to nobody
The kinds come from each source's own manifest — the identity: on a column —
so a source you register from a recipe is measured by what your document
declares, not by a list of source names we shipped. A source that names nobody
at all, such as a resource plan, is not asked to resolve anyone. A source
needing more than one kind passes on either: Jira can find a person by
address or by login, so an installation holding addresses answers its questions
even where the forges' go unresolved.
It stands down rather than adding a second red wherever the crosswalk line above already owns the fault — a refused or never-run compile, a compile that linked nothing, or one taken from a source that is no longer the designated one. And it says cannot tell rather than passing where a source's manifest will not parse in this build, or where the directory's own population could not be measured and the source rests on the anchor identifier: in both cases nothing has established that anybody resolves, and reporting that as green is what this line exists to stop.
ingest dispatch asks a question none of those does: is any stream switched on that Prism will never collect anything from? There are two such streams, and the line names both.
- A stream of your own with no credential stored on it. A mirror or snapshot carrying a document you wrote, pasted, or edited is walked under whatever name you gave it, but the ingest job never lends it a credential from its own configuration — the document chooses where its requests go. Every other signal about the stream can stay green meanwhile. Enter the credential on the stream's page on Admin → Context streams; the job walks it from its next run. See If you want the plan's history.
- A document naming a part this release does not implement. Nothing on your appliance fixes it, and the line under the stream names the part. Ask us.
What the line does not do is tell you to rename the stream, and that is deliberate: renaming it to a prebuilt name either collides with a stream your install already has, or quietly hands the walk to the prebuilt document and drops your edits.
Not every uncollected stream is a fault, and this line counts only the ones that
are. A query-through stream copies nothing by design — its rows are read from
your own system while a question is answered — and is not counted. Neither is a
stream nobody has switched on yet, nor a file stream such as a roster upload,
which is collected without a prebuilt stream at all. A stream whose stored
document Prism could not read is named as not judged rather than counted as
looked at and found well; it wears needs setup on Admin → Context streams
and that is where its reason is.
A clean line here says what it checked and no more: none of the switched-on streams is one this build would never walk. It is not a promise that every stream will run on its next tick — the job has further gates this report cannot see (the stream's schedule, whether its engine is deployed on this installation, and whether its stored document still matches the one this release ships).
source registry compares what the Context streams page holds against the
older per-source configuration the chart still renders, and it warns rather
than fails: those older jobs are still what collects GitHub and Jira on an
installation that has not moved them onto the ingest engine. One sentence on
that line is worth acting on when it appears, and it leads the line:
switching this context stream off did not stop collection: the legacy
prism-ingest <source> job is still walking it. It means the ingest engine is
set to take that source over (ingest.engine.mode: authoritative) and is not
walking it, because its context stream is switched off on Admin → Context
streams — and the job that is walking it is the older per-source job, which is
to be retired in a coming release. The data is arriving today; when that job
goes, it stops, and no line on this page turns red, because the source keeps
its rows and keeps counting them.
The sentence does not decide for you; it gives the three ways to settle it.
Move the stream onto the engine: a stream Prism enabled from your chart
values and somebody later switched off carries no test result of its own, so
enabling it is refused until you enter its credential on that page (Prism tests
it as it is stored), approve it if the page still offers Approve, and then
enable it — and afterwards check the ingest dispatch line and the bundle's
ingest.<source>.lane, because the ingest job can still turn a stream away for
a reason this page cannot see, such as a connection its own configuration does
not cover. Keep it on the older job deliberately: ingest.<source>.engineMode: "off" in your values, which is a choice the line respects and stops naming.
Stop collecting it: sources.<source> in your values. The bundle carries
the same facts under legacy_only on that line's evidence.
GitHub gets two lines because it fills two indexes. Pull requests and issues are
swept by the same prism-ingest github job on the same schedule and the same
credential — there is no sources.githubIssues, and the issue line follows
sources.github — but they are counted, dated and covered separately, because
each of the two searches has its own limits and its own history.
An empty issue index is a pass, once the sweep has run. An organisation that files no GitHub issues is a normal shape for this product, so the line reports what it knows rather than failing:
PASS github_issues 0 issues — this organisation files none; the issue sweep
reaches back to 2026-05-28
WARN github_issues 0 issues — the issue lane has not completed a sweep here yet
The two are different facts, and the leading edge is what separates them: until a run has swept issues and recorded how far back it reached, nobody can say whether there are none or whether nothing has looked. The synthetic seed writes no issues at all, so on a synthetic install the line stands down and says so.
A live source is also measured against its own ingest schedule. Once four of its scheduled intervals have gone by with no ingest completing — 24 hours on the six-hourly default — the line fails outright rather than reporting an age for you to judge:
FAIL github ingest stalled — last completed 2d ago, past the 24h budget
(schedule is every 6h) · 26,029 rows
The rows are still there, which is exactly why this is a verdict and not a row
count: a stalled index looks healthy until you know what its schedule was
supposed to be. ingest.stallAfterIntervals sets the multiple, and 0 turns
the verdict off and leaves the age on the line. The budget in force is in the
bundle as ingest_stall_budget_hours, and is absent where no schedule was
rendered — the verdict then stands down rather than guessing.
It also stands down until that source's first live ingest has completed. A source you have just flipped from synthetic to live has not been swept yet, so there is nothing to date the schedule from; the line reports what is there while the first backfill runs, and the verdict starts applying from the first run that finishes.
A source you are preparing carries an upstream verdict before you flip it.
Configure a source's target and credential and leave sources.<name> alone, and
each helm upgrade probes that upstream and reports whether it is ready:
PASS github synthetic · 26,029 rows · seeded 6d ago · upstream ready to go live (1m ago)
WARN github synthetic · 26,029 rows · seeded 6d ago · upstream NOT ready:
org_visible_but_no_prs_found (1m ago) — going live now would
evict the rows above and ingest nothing
The mode is still synthetic on both, and the source is still answering from
the seeded data — the clause is about the upstream, not about what is being
served. Amber is deliberately not red: nothing is broken, and the finding is
about a flip that has not happened. It is amber because there is a move to
make, and because the flip is the destructive step — the first live ingest
truncates the rows the same line is counting, so a credential that
authenticates cleanly and sees nothing would replace a working demonstration
with an empty source. See
Take a source live.
Freshness is stated in the units the source actually keeps. Most sources stamp
each row with the moment the thing happened, so the line reads data through 3h ago. Synthetic Router spend is aggregated per day and knows no finer, so its
line names the day itself — data through 2026-07-28. A spend line showing
yesterday's date is current: yesterday is the newest complete day. Compare it
against the timestamp in the page header rather than against the ages on the
other lines.
Live Router spend is the exception, and it has no row count or date at all.
Once sources.spend=live, Prism keeps no local spend index — the question goes
straight to the Agent Router's usage API each time it is asked — so there is
nothing on this box whose size or age would mean anything. That line reads
live · served by the Agent Router usage API (no local index), and it is
telling you the wiring is right, not that the data is missing. Whether the
Router is actually answering is the AGENT ROUTER lines' verdict, a few rows
up; the answer's own currency is the Router's, and the chat states it with the
figures. Do not read the absent date as stale spend.
sources.spend=indexed is the opposite case: it reads like the other indexes,
because it is one. Prism sweeps the Router's request logs into
router_spend_key_daily on a schedule, so that line carries a row count and a
data through date, and — like GitHub and Jira — it fails when the sweep stops
completing rather than reporting an age for you to interpret. A date of
yesterday is current here for the same reason as synthetic spend: the rows are
whole UTC days.
Four verdicts: pass, fail, warn, and skip. A skip is not a failure — it usually means the check stood down because something it depends on already failed and owns the verdict.
Three properties are worth knowing, because they change what the page is telling you:
Every line is exactly one hop, with one named exception. The indexed MCP
servers — Agent Router Usage, GitHub Org Stats, Jira Stats — do not call
GitHub or Jira. They read a local index that the ingest jobs populate. So "the
GitHub MCP is healthy" says nothing at all about whether GitHub is reachable;
those are four hops apart and each gets its own line. A green check at the wrong
layer is the specific failure this page is built to prevent.
The exception is the GitHub Live line, and it is one because that server is
not an index — it is a facade in front of GitHub's API, so there is no local
thing for it to be healthy about. Checking it the way the others are checked
would be a green check at the wrong layer, which is the fault this page exists
to prevent rather than commit. So this one line does leave your network: it
calls search_pull_requests with query="is:pr", perPage=1, one request
against GitHub's 30-per-minute search allowance, on the same egress path the
chat uses. It is the only line that does.
Two consequences worth having in advance:
- Read a red GitHub Live line as "the path to GitHub is broken", not "the MCP
is broken". The likely causes are in that order: no egress to
api.github.comfrom the namespace, aGITHUB_TOKENthat is missing, expired or too narrowly scoped, or thegithub-mcp-serverchild process failing to start. The evidence in the bundle names which. - It costs a GitHub API request each time you press the button. The self test is operator-driven and not on a timer, and the request is a read, so running it repeatedly cannot change anything — but unlike the SMTP check, which deliberately stops short of sending a message, this one really does talk to the far end.
The line exists at all only when mcps.githubLive.enabled is true; the chart
defaults it off, and with it off there is no GitHub Live line and nothing on
this page leaves your network.
CONTEXT STREAMS — one line for every source registered on Admin → Context streams, including any you registered yourself, which the SOURCES lines above do not list. It appears only with the source registry on. Each line carries the word the Context streams page shows for that source and the reason beside it, with any person's name removed, plus what the ingest bookkeeping has recorded for it:
- a failed run is recorded. An ingest run failed, and no run has completed since. A completed run clears it. The line carries the first 160 characters of the reason, on one line: a reason Prism composed is shown as written, and an error raised by a database driver is shown as its type alone, because a driver's message can quote a value. A longer reason is cut with no marker; the bundle keeps the first 300 characters. A switched-off source's line does not show it.
- the last completed run could not fetch some partitions. A partial run.
- its shadow run has a failure recorded. The new ingest engine, running alongside the current one, failed. The data you are served is unaffected.
- the ingest bookkeeping could not be read. Nothing is known either way.
What the line does not know: a run killed before it could write anything
(an out-of-memory kill, or a stop that outlasted the pod's grace period)
records nothing, and a source that has never run records nothing, so the absence of a
recorded failure is not proof that ingest is healthy. A source read live
where it lives (query-through) has no ingest, and its line says nothing
about one.
| Line | When |
|---|---|
| PASS | switched on, and none of the below. An unverified source passes: it is connected, and nobody has checked a figure yet |
| PASS, with its connection is checked on its own SOURCES line | a source your installation enabled, whose connection is checked by its SOURCES line above rather than by a test on the Context streams page |
| WARN | the word is not connected (a failed Test), wrong or stale; or a partial run or a shadow failure is recorded; or, on any source except the organizational one, a failed run is recorded or the word is not ingested or needs setup. That source's questions may be affected; nothing else is |
| FAIL | the source designated as your organizational context stream, switched on, with a failed run recorded, or reading not ingested or needs setup. Every person- and team-shaped answer on every source resolves through it, so the report's overall verdict fails. A failed Test on it is a WARN, not a FAIL: ingest does not read the Test result and keeps running |
| SKIP | switched off, including a synthetic source your installation has switched off. It is listed so the section names every registered source |
What to do about each word is the same as on the Context streams page.
Lines assert numbers, not health. "1,204 rows", not "OK". A credential can authenticate cleanly, return 200 to everything, and see nothing — so counts are the assertion.
A line reports what failed. Where Prism recorded a reason, the CONTEXT STREAMS line shows it, as the bullets above say. The raw evidence is in the bundle.
One root fault, one red line
If the database is unreachable, every source line reads from it and knows nothing — so those lines skip rather than each inventing a verdict. Same when the runner is down: the model catalogue line stands down, because the runner's own line already owns that failure.
So read the first failure in a group, not the count of failures.
The diagnostic bundle
The bundle is the same report as JSON, plus the deployment identity — chart version, release, namespace, revision, image digests — and the configuration this install is running with.
It carries counts, status codes, durations, versions and error classes.
It also carries an ingest block: one entry per source this installation has
ever swept, whether or not that source has a line on the page. Every source
you register from a recipe is in there — GitLab, an uploaded roster, a
warehouse view — with the watermark, how far back the sweep reached, what the
mirror actually holds (oldest and newest row), whether a resume point is
pending, and, where the last run failed, when it failed and why. A source
running in shadow keeps its own entry under its own <name>__shadow key,
rather than being folded into the lane it shadows.
Each entry also says which lane is feeding the source, under lane:
engine where the ingest tick walks it — its context stream is switched on,
the engine runs authoritative for it, and the tick has recorded a run of
it; legacy where only the per-source ingest job from before the engine is
collecting it; and shadow for a shadow row. Two facts ride beside the word,
engine_feeds and legacy_job, and lane_why is the sentence that explains
it.
The word is left out in two different situations, and lane_why says which.
One is that Prism could not establish it: the source registry could not be
read; the stream's stored document could not be judged; this installation gave
the app no schedule to read for that source; or the stream is switched on and
set to run on the engine but the ingest job has not yet recorded a run of it —
which usually means the next run is minutes away, and occasionally means the
job is turning it away for a reason this page cannot see, such as a connection
the ingest job's own configuration does not cover. The other is that nothing
is collecting the source: its stream is switched off and no older job is
rendered for it, or it is one the ingest job turns away. A query-through
stream reads that way too, and for it that is by design — nothing is copied.
legacy means the engine is not walking that source. For a source that has
a context stream on Admin → Context streams — GitHub and Jira — the job that
is walking it is the older per-source job, which is to be retired in a coming
release; on an installation running authoritative, such a source usually has
a stream somebody switched off, and the source registry line names it and
gives the choices. For a source with no context stream at all, such as Router spend,
legacy is simply the lane it runs on, and the sentence says so.
That block is why sending the bundle is worth doing even when every line on the page is green: the page's verdicts cover the sources it ships lines for, and the block covers every source you have.
It also carries what the agent can see, and what each source publishes —
the sources the semantic query server lists, which of them is your
organizational context stream, and the column names each dataset declares.
Names only: never a value, never a row, never a person. That is there for one
question in particular. When an answer tells you it cannot do something because
a field does not exist — "there is no github_login field" — the bundle is what
lets us confirm that against your manifest instead of asking you to run a query
and read it back to us.
Beside the identity crosswalk's own figures the bundle carries how many people your organizational context stream holds, counted at the moment you pressed the button. That is the denominator: "1,204 identifiers" means one thing in a team of forty and another in an organisation of four thousand, and until now the only way to find out which was to ask you to run SQL. It is stated as a measurement with its own timestamp rather than folded into a percentage, because the compile that produced the identifiers may have run before your last roster upload — a ratio across the two would look precise and be wrong.
For each source that walks one, the bundle also carries
credential_reach_count — how much that source's credential could see when
the last run looked, recorded on every run whether or not anything changed.
It is a count and never the credential: comparing two bundles is how a grant
that widened in steps too small for any single run to report becomes visible.
Widen a source's scope is what to do about one.
It does not carry rows, issue titles, logins, names, chat questions or answers, model ids from your catalogue, or secrets. Secrets appear as present or absent, never by value. Our test suite pins this: one test runs every check against a configuration stuffed with recognisable secrets and fails if any of them appears anywhere in the report, at any nesting depth, including inside error text.
It is deliberately readable rather than packed: someone in your compliance team should be able to open it and confirm for themselves that nothing they care about left the building. That is the point of the format — please do check it.
A screenshot of this page is the single most useful thing to send us if something looks wrong. The bundle is the follow-up when the screenshot isn't enough.
Who can open it
Under an identity front door (app.identity.mode of easyauth, iap or
local) the page and its endpoint require the super admin role — the
report names image refs, internal URLs and configuration, which a signed-in
user of the chat has no business reading. See "Identity and roles" in
install.md for how the role is granted. Under static mode
there is no front door and the page is open, as before.
The datapond volume line
Everything Prism keeps lives on one volume — the ingested index, the agent runtime's state, and a row per question asked and answer given — and nothing prunes any of it. A full volume is a database that stops accepting writes, and it announces itself as whatever the next write happens to be rather than as a disk fault. So this line exists to be read weeks early.
On a bundled install it sums pg_database_size() across every database on the
instance — the datapond and the runtime's own database both sit on the same
volume, and on a chatty install the runtime's is the larger half — and compares
that to the usable space under postgres.storage:
datapond volume WARN 6.2 GiB of 10.0 GiB · 77% of usable space · nothing prunes it
Under 75% it passes; at 75% it warns; at 90% it fails. The percentage printed on the line is the one those thresholds are read against, so a screenshot of the line and the verdict on it can never disagree.
Usable is postgres.storage minus 2 GiB. pg_database_size() counts
database files, and the volume also carries the write-ahead log (1–1.5 GiB after
a checkpoint on stock settings), temporary files and the filesystem's own
metadata and reserve — none of which any query can see. On the 10 GiB default
that leaves about 8 GiB of room, so a percentage of the raw size could never
reach 90% and the failure verdict would never fire on the install we ship. The
two raw figures stay on the line so they still match kubectl get pvc.
One more thing about the capacity: it comes from postgres.storage because
nothing inside the appliance can measure the volume — it is mounted in the
Postgres pod, Postgres exposes no free-space function to SQL, and Prism has no
Kubernetes API access by design. Expand the PVC and you must raise
postgres.storage to match, or the warning keeps firing against the old
number. The evidence in the bundle names the source of the capacity for exactly
this reason.
With an external database (postgres.bundled=false) there is no capacity to
compare against, and the line says so — 4.2 GiB in this database · volume size not known here — rather than implying headroom nobody has established. It reads
only Prism's own database there, not the instance: a shared managed server
grants every role CONNECT on every database by default, and summing that would
report your other databases' bytes as Prism's usage and carry their names and
sizes into the bundle you download. If the datapond line has already failed,
this one skips: that failure owns the fault.
limits.md has the sizing formula and the measured per-row costs behind it, including what one question costs.
The SMTP check (local sign-in only)
With app.identity.mode: local the report gains an smtp relay line: it
connects to the configured relay, negotiates TLS per smtp.tls, authenticates
if a username is set, and then asks — address by address — whether the relay
would deliver to each bootstrap super admin (MAIL FROM/RCPT TO, then
RSET/QUIT, never sending a message, so running the self test
repeatedly cannot spam anyone). Per-address verdicts are in the evidence.
That per-address detail is the point: every other line can be green and the
install is still dead on arrival if the relay refuses exactly the address of
the one person who can let anyone else in. The report carries the relay host,
port, TLS mode and sender — never the SMTP password. Under every other
identity mode the line reads skip.
The CLI — the same report, from inside the box
kubectl exec -n <namespace> deploy/<release>-app -- python -m prism_app.selftest
Runs the identical check list and prints the identical JSON report to stdout.
This is the escape hatch for the moment the role gate cannot help with:
mid-install, when something is broken and no super admin can sign in yet.
Anyone who can exec into the pod already holds a stronger credential than
any app role, so the report is theirs to read.
The exit code is always 0 — a failed check list is a successful diagnosis, the same rule as the endpoint answering 200 with a body. Read the report, not the exit code.
That holds even if the self test hits a bug in itself. A check that cannot finish becomes a failed line and the rest of the report still prints; if the run cannot be completed at all, you get a one-line report instead of a traceback:
[PRISM]
FAIL self test the self test could not run (TypeError)
That line is a fault in Prism, not in your environment, and nothing else on the page can be trusted while it is showing. Download the bundle and send it to us — it carries the version, the error class and the message.
The endpoints
| Path | What it does |
|---|---|
/selftest | the page (super admin under easyauth/iap/local) |
/selftest/run | runs the checks, returns the report as JSON (same rule) |
/selftest/run is the only thing the button does, and one run at a time: it
takes a lock, so a second caller waits rather than doubling the load on a box
that may already be struggling. It takes no parameters — there is nothing a
caller can make it do that the button cannot.