Release notes
What changed between releases, and what you should read before upgrading. This page covers the changes a reader or an installer would notice. It is not a list of every merged change.
How often these arrive, and how fast a vulnerability gets fixed in one, is in Release and CVE policy.
Security acceptances
Every image is scanned before a release is pushed and a fixable CRITICAL or HIGH stops the release — Release and CVE policy is the whole statement. Where the only fix is a rebuild no upstream has published, we accept the finding in writing instead of blocking indefinitely, and this is where those acceptances are published rather than left for your scanner to find.
This section is current rather than historical: it lists the acceptances in force, and an entry leaves it when the finding does. Every one below has the same shape — a third-party component we mirror or vendor rather than build, where the fix either does not exist or exists only in a rebuild nobody upstream has published — and each carries an owner, an expiry, and a re-review at that expiry and at every change to the image it sits in.
The bundled Postgres — 22 findings in gosu, 3 in libpcre2
Everything in this section applies only if you run the bundled Postgres.
pgvector runs only when postgres.bundled=true, which is the chart's
default. If you have set it to false and pointed Prism at a Postgres of your
own, the image never starts and you can skip mirroring it entirely.
Mirror the images and the chart into your
registry says how, and
what to do if you ever turn it on. On such an install none of the findings
below is in your registry or your cluster, and none of these acceptances is
yours.
What your own scanner will show if you mirror this image, so it is not a
surprise: about 118 CRITICAL and HIGH findings, not 25. Twenty-five of those
are the ones these acceptances cover (22 in gosu, 3 in libpcre2), and they are the ones our
release gate can act on, because a fixed version exists. The other ~93 are
Debian packages with no fixed version at any severity. Our gate does not
stop a release for those and cannot, because there is nothing to upgrade to.
Fifteen of them are CRITICAL, in perl, libsqlite3-0, libxml2 and
zlib1g; Debian marks the zlib1g one will_not_fix. They are the base
image, they are the same on every recent pgvector tag, and they are why a
scan of this image and our release gate report different numbers. Measured 18
September 2026 on the pinned image with Trivy 0.74.0. The image has not changed
since 0.13.2 shipped it; the count rose from 112 on 13 September because the
scanner's database learned of more findings, not because the image did.
The 22 this acceptance covers: pgvector ships /usr/local/bin/gosu, a
prebuilt Go binary that the official Postgres entrypoint uses to drop privileges
at container start. It was compiled against Go 1.24.6, and the Go standard
library inside it carries 1 CRITICAL and 21 HIGH advisories.
CVE-2025-68121 (CRITICAL, crypto/tls), and the HIGH findings CVE-2025-61726,
CVE-2025-61729, CVE-2026-25679, CVE-2026-27145, CVE-2026-32280, CVE-2026-32281,
CVE-2026-32283, CVE-2026-33811, CVE-2026-33814, CVE-2026-33818, CVE-2026-39820,
CVE-2026-39821, CVE-2026-39822, CVE-2026-39836, CVE-2026-42499, CVE-2026-42504,
CVE-2026-56853, CVE-2026-56858, CVE-2026-56859, CVE-2026-56860 and
CVE-2026-56862. All are accepted until 28 October 2026.
Why they are not fixed. There is no fixed artifact to take. A scanner calls
these fixable because the Go standard library fixed them — but gosu upstream
has not rebuilt, its security policy explicitly declines releases for
standard-library advisories that do not affect it, and no pgvector image
carrying a rebuilt gosu has shipped. We checked the current pg16,
pg16-bookworm and pg16-trixie images before deciding: the newest carries the
same findings and trixie carries more, so moving the pin buys nothing.
Why the residual risk is low. gosu runs exactly once, at container start,
as gosu postgres postgres, and then replaces itself with the Postgres process.
It opens no network connections and parses no input you or anyone else supplies,
so the vulnerable paths — TLS session resumption, X.509 chain building, URL and
mail parsing, HTTP/2, DNS responses — are linked into the binary but never
executed. The one finding that is not a network parser needs code that calls
os.Root, which gosu does not.
What would end it. A new upstream pgvector digest carrying a rebuilt
gosu. Moving that pin re-runs the scan, and anything still listed here has to
be true of the new image.
Re-checked 8 September 2026, and moving the pin would buy nothing. Upstream
has published a newer pg16 image since we pinned, and it scans identically
— the same 108 findings, the same 22 fixable, the same 16 CRITICAL, and gosu
still compiled against Go 1.24.6. Zero findings gained, zero lost. That is the
tag we track; pg16-bookworm and pg16-trixie are a base change rather than a
pin move and were not re-scanned, and this section already records that trixie
carried more when it was.
The 3 in libpcre2. CVE-2026-86145 (HIGH, an out-of-bounds write in
pcre2_dfa_match) and CVE-2026-89161 (HIGH, an incorrect free in
pcre2_jit_match), accepted 13 September 2026, and CVE-2026-89157 (HIGH, an
out-of-bounds write in pcre2_pattern_convert from a large pattern, on 32-bit
platforms only), accepted 18 September 2026. All three are in libpcre2-8-0
10.42-1 and are accepted until 28 October 2026.
These are a different shape from gosu, and the difference is worth stating:
Debian has shipped the fix, as 10.42-1+deb12u1. The three Prism images built
on Debian take it at build time. This image is mirrored exactly as upstream built
it, so we cannot take it here without rebuilding the image ourselves. No
pgvector image carries it yet: the current pg16 and pg16-bookworm images
(13 August 2026) have the same 10.42-1, and pg16-trixie has 63 other fixable
findings.
Accepting them is not fixing them. The acceptance lets our release gate
pass; it changes nothing in the image. If you mirror it, your own scanner will
still report all three as HIGH with a fixed version available, and it already
reports them on any pgvector image an earlier release shipped. The pin has
not moved since we began mirroring the image, so every release that ships it
ships this same image. For 0.13.2 we
checked the digests: its configuration and every layer are identical to the
pinned upstream image. The findings are new in the scanner's database, not in
the image.
Why the residual risk is low. Nothing Postgres runs loads the library. In
the image's Postgres tree it is linked only by sepgsql, the SELinux label
provider, which loads only if you add it to shared_preload_libraries; the chart
does not. Neither the postgres server nor the vector extension links it. Its
other user is libselinux, which the image's shell utilities link. It compiles
regular expressions only from an SELinux policy's file contexts, and the image
contains no policy; its /etc/selinux holds only a configuration file for a
different library.
The first finding needs an attacker-supplied regular expression, the second a
specific way of calling the matching API, and the third an attacker-supplied
pattern passed to the library's pattern converter on a 32-bit system; this
image is published for 64-bit amd64 and arm64 only. Nothing in the
container gives anyone any of them.
What would end it. A pgvector image carrying libpcre2 10.42-1+deb12u1 or
later; we move the pin when one exists. If none has shipped by the expiry, the
review decides between renewing and building a thin image of our own over the
pin that takes Debian's update.
The agent runner — 4 findings in vendored dependencies
These are in the agent-runner image the appliance runs:
-
CVE-2026-54328 (HIGH, accepted until 14 October 2026) — predictable temporary paths in the coding-agent harness the runner is built on, which a second local user on a shared host could race. There is no fixed release: the advisory covers every version up to the current one. The preconditions do not exist here. The chart runs the runner as the only container in its pod, as a non-root user, with no privilege escalation and all Linux capabilities dropped; nothing is mounted over its
/tmp, so that directory is the container's own writable layer rather than one shared with a second tenant, and there is no other local user to escalate from. -
CVE-2026-39822 (HIGH, accepted until 21 October 2026) — symlink following in the Go standard library compiled into
esbuild, reached throughtsx.tsxis at its latest release and no rebuiltesbuildhas shipped, so there is nothing to upgrade to. In this imageesbuildonly transpiles our own first-party TypeScript at start-up, never a path you supply. It is the same standard-library advisory as theos.Rootone ingosuabove, in a different binary.Your own scanner will not show this one, and that is the opposite surprise to the Postgres section above.
esbuildships stripped — the binary carries noGo buildinf:header — so Trivy does not recognise it as a Go binary and skips the file. The finding is never raised for this image, with or without our acceptance file; we keep the entry so that it blocks again if a future scanner learns to read the binary. This is not a scanner-database gap: in the same run Trivy does findgosuin thepgvectorimage and reports all 22 of its findings. So do not read a cleanagent-runnerscan as a statement about that image's Go dependencies — and the finding is live rather than historical: the binary is built against Go 1.26.4, below the 1.26.5 that fixes it. -
CVE-2026-56876 (HIGH, accepted until 18 November 2026) — unvalidated symlink paths in
extract-zip, which is unmaintained and has no fixed version and never has had one. Its only call site extracts a.zipasset on Windows; every image here is Linux and takes the.tar.gzbranch instead, so the vulnerable code is unreachable. -
CVE-2026-19693 (GHSA-7pqw-9j4j-h8q3, HIGH, accepted until 18 November 2026) is a second symlink advisory against the same
extract-zip, published 17 August 2026. It covers every version, so there is still nothing to upgrade to, and it is unreachable for the same reason: the single call site handles a.zipasset only on Windows. A scanner that includes unfixed findings reports it for this image, as it reports CVE-2026-56876, so we list it here.
Four further findings are recorded in the same acceptance file but are not in
any image Prism ships: CVE-2026-14257 and CVE-2026-69152 (brace-expansion,
until 27 October and 6 November 2026), CVE-2026-69192 (ip-address, until 6
November 2026) and CVE-2026-73566 (tar, until 6 November 2026). All four sit
in the copy of those packages that the npm CLI vendors inside its own tree,
which no lockfile reaches and which no released npm has yet fixed — the
tar there is 7.5.19 while this project's own lockfile already resolves tar
to 7.5.22, which is a different copy. They affect the agent runner project's
coding-sandbox images, which the Prism appliance does not ship — the runner
image it does ship removes npm, so a scan of it does not report them. They are
listed here because the acceptance file is shared with that project and the
release gate honours all seven entries when it scans the runner.
0.16.0 (unreleased)
A Snowflake table or view can be copied once a day, and a copied directory can be the organizational context stream
A new recipe, snowflake_snapshot, copies one Snowflake table or view on a
schedule (kind: snapshot). Prism runs one statement per copy over Snowflake's
SQL API, signing in with the same key pair a live Snowflake source uses, and
answers every question from the copy, so your warehouse runs one statement per copy (and again only when a copy is retried). The
worked example is a directory of people. A copied directory can be designated
as the organizational context stream, which a live Snowflake directory cannot
(issue 938). Copy a Snowflake table or view once a day
walks through it, including replacing an uploaded roster.
Prism was not tested against a real Snowflake account. It was tested
against a model of Snowflake's documented API. Key-pair sign-in, warehouse
resume time, the server-side statement timeout and partition sizes were not
observed; the page lists them. The ingest pod now needs egress to
<account>.snowflakecomputing.com on 443 when you add one of these sources.
A live (query-through) Snowflake directory still does not resolve people.
It can still be designated, but the next crosswalk compile refuses it, with a
sentence that points at this recipe, and nobody resolves. Move to onto one,
away from a directory that is working, is now refused for that reason.
Moving the organizational context stream to a directory with no rows yet is refused
Move to used to accept a source whose first copy had not landed, taking the stream away from a directory that was working. The next crosswalk compile then emptied the crosswalk, and nobody resolved until that copy arrived, or at all if its first run failed. The move is now refused with nothing changed, and the refusal says to let the source land first. The compile also names this case, instead of failing with a database error. It still empties the crosswalk, as any compile it cannot complete does.
The self test's datapond line counts every context stream's table
The datapond postgres line said how many tables the datapond holds and how
many rows are in them, but it counted only the built-in GitHub, Jira and spend
tables. A stream registered on Admin → Context streams, GitLab for example,
was left out of both numbers. It now counts every switched-on stream's table as
well, from the same list the context streams section of the self test is built
from. A stream that has not been walked yet has no table; that is listed in the
bundle and does not fail the line. One case is unchanged: if the built-in tables
hold no rows at all, the line still reads that every table is empty and fails,
whatever the streams hold.
Stream setup shows sample rows from a SQL_ASCII Postgres server
On a Postgres server whose encoding is SQL_ASCII, Admin → Stream setup
could list a table and describe its columns, but its sample rows could fail
with 22021 when a value was not valid UTF-8. The sample now decodes such values
with the same code the ingest walk uses: an emoji a Java or ODBC writer stored as
CESU-8 shows as the emoji, and any other invalid sequence shows as � (U+FFFD).
The sample says how many values needed either, and never shows the bytes
themselves. It shows every column it samples, including ones the walk still
refuses: such bytes in an array, enum or composite column or in a key at a page
boundary, and a replaced byte in an identity: column. See Copy a Postgres table or
view. A table or
column name that is not valid UTF-8 still stops the listing or the description,
as before. The source is never changed, and servers in any other encoding are
sampled exactly as before.
A stream that differs from its recipe says whether the recipe moved or the copy was edited
When a stream's stored copy differed from the recipe it was registered from, Admin → Context streams and the self test could only say that one of two things had happened: a release corrected the recipe, or somebody edited the copy. The two need opposite actions, and an operator's own change, such as a disclosure floor raised on one stream, was reported as a correction to re-apply.
Prism now records the recipe's model whenever a stream holds it: at registration from a recipe, when an administrator applies a recipe's current model, and at every start-up for each stream that then holds its recipe's current model. For a stream whose own stored copy is the one in use, it then says which:
- the recipe was corrected and this copy's model was not edited: re-applying the recipe replaces no edit to the model, and the self test warns. Like any re-apply, it also resets the stream's label and presentation to the recipe's, sets verification back to pending, and switches a connected source off until it is approved and enabled again (and probed again first, if the re-apply changed how it connects). On the organizational directory stream, a re-apply that would switch it off is refused unless you choose Save anyway, which then switches it off;
- this copy was edited and the recipe has not changed: nothing is waiting, and the self test does not warn;
- both: re-applying would pick up the correction and replace the edit, so the self test warns and says to compare the two first.
A stream that differed from its recipe before this upgrade has no recorded model yet, so it keeps the earlier wording, which names both possible causes, until it holds its recipe's current model again. An unedited stream that records the recipe it came from records the model at the first start-up after the upgrade, unless this release changed that recipe; then it too keeps the earlier wording until it holds the current recipe. Every start-up checks each stream registered from a recipe while it holds the stream list, so every read of that list, the ingest job's included, waits until it finishes. Measured locally, the registry's start-up step took about a second with 1,000 such streams and about five seconds with 5,000 on the first start-up after the upgrade, and about a quarter of that on later ones.
A stream can no longer take a prebuilt stream's name with a different model
Prism answers a prebuilt name (github, gitlab, jira) with its own
built-in model, never the stored one's. On an installation that has not
connected GitLab, nothing stopped another stream from being registered as
gitlab or renamed to it. Nothing stopped an edited copy of a prebuilt recipe
from keeping its prebuilt name either. Such a stream's own model was never the
one answered with. A staff list designated as the directory under that name
also left the installation with no directory, so every question that needs one
was refused.
Registering, renaming or designating a stream like that is now refused, with
the reason. The unmodified recipe, with a label of your own, is still
accepted. A stream of a different kind already under a prebuilt name, such as
a staff list named gitlab, is named by the self test and on Admin →
Context streams, which say to rename it. A stream of the same kind already
under a prebuilt name is not told to rename, whether it is an edited copy of
that recipe or another source, because it cannot yet be told apart from a copy
that is only older than this release's recipe.
A button you cannot use yet looks different on every page, not just on Definitions
0.15.0 changed how an unavailable button looks on Admin → Definitions. It used to be a dimmed copy of the live button. It became an outline with no fill and a dashed border. Every other page still dimmed its buttons, so on this dark palette an unavailable blue button could still look ready to press. They now all use the outline. That covers asking a question (including the Send button in the form for giving feedback on an answer), a saved answer, the list of your answers, sign-in, Admin → Context streams, Admin → Stream setup, Admin → Answer feedback and the built-in self test.
A recipe that covers two editions says so beside its manifest
The Jira recipe covers Jira Cloud and Jira Data Center in one manifest, and
the editor used to show its connection.editions block with nothing to say
what it is. The register form and a stream's Manifest tab now name the
editions the manifest covers and the parameter that chooses between them.
The ingest job's "stored copy is stale" line agrees with Context streams
For a stream under a prebuilt name (github, gitlab, jira), the ingest
job's log line reports whether the copy of its document the installation holds
is "stored_document": "current" or "stale". It judged that differently from
the recipe-drift report on Admin → Context streams, and in two cases it was
wrong:
- A stream you registered with a label of your own read as
stale, for ever, because the label was compared as though a release had changed it. It now readscurrent: the label is yours. - A release that corrected one of a stream's verification questions read as
current, because the whole verification block was ignored. It now readsstale, as it already did on Context streams. Your own verdicts on those questions are still set aside.
Both now use one rule, so the log line and the page cannot disagree again.
An area whose table names an owner Prism could not place now says so
When an ownership table names two owners for an area and the directory could not place one of them (nobody of that name, or several with none chosen), the area's group is everyone under the owner who was placed. The answer used to report that group with nothing to say a second owner had been named. It now says how many owners the table names for that area or product that could not be placed, and that they add nobody to the group. It counts owners, not rows, and leaves out an owner the directory matched to several people when one of them is already one of the group's owners, or to more people than it lists.
For a group created before this release, re-upload the ownership table and activate it. Prism never changes a stored definition, so a group compiled by an earlier release does not carry the count, and nothing is said about it until the table is activated again. The counts and the figures do not change.
A prebuilt stream you gave a label of your own is cited under that label
When you register a stream under a prebuilt name (github, gitlab, jira)
and give it a label of your own, Admin → Context streams showed your label,
but answers still said the figure came from the prebuilt label (GitHub,
GitLab, Jira). Answers now cite your label. A stream you did not label
still reads as before.
On any stream, a label holding a quotation mark, or running past 64 characters, could make the sources footer under an answer drop the stream's last-load time when a long result was cut short, or keep one such stream's load time under another's. Both are fixed, with two limits. Two streams whose labels run past 300 characters and begin the same way can still be taken for one stream. And when the result is cut before its label ends (the chart keeps about the first 100,000 characters of a result), the load time is still dropped, though never shown with another stream's.
The semantic MCP also logged a warning for such a stream, saying its registry row carried a different model. A label is not a different model, and the warning is no longer logged for it. It is still logged when the row's document differs from the prebuilt one in anything other than its label, designation, catalog tile or verification record, and it now says exactly what is used: the image's model is served, and the row's connection, enabled state, verification record and label still apply.
Zeros withheld because of work nobody is named on are no longer explained as a doubt about who people are
When a pull request or issue in the window has no author, Prism already
withholds the observed zeros for the group, because that work could be anybody's
in it, and counts those people as unknown identity. The answer could
describe that as an identity match it could not confirm. The explanation now
says how many people this moved into unknown identity, that each of them holds
an identifier that resolves to them, and that the doubt about them is whose work
those rows are, not who they are. Anybody else in the same count is given a
number, and the answer does not claim this reason for them. The same applies
when the column a group is matched on mostly holds another kind of identifier,
where the doubt is what the column holds. Where anybody was moved,
moved_to_identity on the measure gives the number. The counts are unchanged.
A Postgres source whose text is not valid UTF-8 now copies
A SQL_ASCII Postgres server stores bytes without checking them. Prism asks
for UTF-8, so a single value that was not valid UTF-8 made the server refuse
the whole statement with 22021. The stream copied nothing and the run
failed. The usual cause is an emoji that a Java or ODBC writer stored as
CESU-8.
On a SQL_ASCII server, Prism now reads text columns as bytes and decodes
them itself. An emoji stored as CESU-8 lands as the emoji. Any other invalid
byte lands as � (U+FFFD). Valid text is unchanged. When any value in a
stream needed either fix, the run log has one line saying how many values
were repaired and how many were replaced. The values themselves are never
logged, and the source is never changed.
If you removed a stream or a column to get past 22021, you can put it
back. Three cases are refused rather than guessed at:
- A
keyset-paged stream whose page ends on such a key. The message says to usepaginate: none, the recipe's default. Starting the next page from a repaired key could skip rows. - A column declared with
identity:, or read by the directory crosswalk, holding a value that could be read only by replacing bytes. Two different people would otherwise become one. - A key that becomes the same text as another key after repair, for example one emoji stored as CESU-8 and the same emoji as valid UTF-8. The key would no longer be unique.
The note that shipped with 0.15.1 listed only the first two. The third was refused in 0.15.1 as well.
Each refusal stops only its own stream, and the source's other streams still
copy. When a run refuses more than one stream for a keyset key or an identity:
value, its summary says to repair the value at the source. It does not suggest
paginate: none, which helps only with a keyset key; the run log's line for that
stream says so. Two keys that became equal are refused as a key that is not
unique, with that refusal's own advice. Servers in any other encoding read
exactly as they did before. What this does not cover is in Copy a Postgres
table or view.
If you are on 0.15.1, all of this already applied. What changed since is the summary's advice above, and the wording of two lines in the run log: the line counting repaired values no longer says the rows were copied, and a refused directory pull that falls back to the previous pull now logs its reason.
The ingest walk sends a source's credential only to hosts the source names
Every request a scheduled walk makes is now checked, just before it is sent, against the hosts the source's own document names: its base_url, any address a request in the document is sent to, and any address an administrator set in a connection parameter a request is sent to (for example a GitHub Enterprise graphql_url). A request to anywhere else is refused with the host named, and nothing is sent there. The shipped GitHub, GitLab, Jira and Postgres sources are unaffected. If a source you registered follows pagination links onto a different host from its base_url, the walk now stops at that link; put that host in a connection parameter or as the address of a request in the document. An address that appears only in a description, or as a query, body or header value, does not count.
Stream setup (experimental): an assistant that sets up a context stream with you
Experimental, and on by default. Admin → Stream setup (experimental) opens a session on a context stream you have registered. Only a super administrator can reach it. Pick the stream and a mode:
- customise a recipe — for a stream registered from the GitHub, GitLab or Jira recipe. The assistant checks the recipe's settings against your system — which organisations, groups or projects exist, which Jira fields hold story points — asks you what it cannot tell, and proposes values for the settings the recipe leaves open. It cannot propose a change to where the stream is or who signs in: those stay yours.
- describe from scratch — for a query-through stream (Snowflake, Oracle) registered with a starting document. The assistant reads what the system holds (its tables and views, a few sample rows, and which date columns actually move), asks you about look-alike tables rather than guessing, and proposes a complete document.
A document proposal is checked by the manifest validator and, where the stream can be reached, tested against it before you see it, and the Manifest tab's own apply runs its remaining checks when you approve; a settings proposal is checked against the settings its recipe leaves open. The card says whether a proposal was tested, and nothing changes until you press Approve. A customise approval changes those settings and nothing else — the stream keeps its credential and its state. An approved document goes through the Manifest tab's own apply and gets its outcome: a change to the model needs probing, approving and enabling again, like any edited manifest, while a change only to how the stream is labelled keeps its state. Ask its verification questions once it has run.
Not available yet: describing a mirror or snapshot stream from scratch. The page says so and refuses that session. A mirror stream registered from the GitHub, GitLab or Jira recipe can still be customised; the database recipes leave nothing open to a session, so a query-through stream registered from one opens in describe-from-scratch, and the page marks a snapshot one "nothing to customise".
A proposal changes nothing until an administrator approves it. The
assistant runs as a separate agent that only a super administrator reaches;
the chat everyone else uses never gets its tools. To switch it off, set
mcps.discoveryMcp.enabled: false. See
Set up a stream with the assistant.
A new image to mirror: discovery-mcp — experimental, and on by default
This release adds an eleventh image, discovery-mcp. Mirror it with the
others before you upgrade — the loops in Installing Prism name
it. It runs by default, so an upgrade that cannot pull it leaves one pod in
ImagePullBackOff.
What it is: the read-only tools behind Admin → Stream setup, an experimental assistant that sets up a context stream with you. It reads a published API description on the stream's own host, makes read-only requests there, lists and samples the tables of a Postgres, Oracle or Snowflake source, asks you questions, and proposes a manifest. A proposal changes nothing until an administrator approves it. Every tool works on a source you have already registered, with the credential stored on it: the session names the source, never a host, a login or a password, and it cannot reach any host but the one you registered. It is attached only to the session agent a super administrator starts, never to the chat everyone else uses.
It is one more steady pod (the query servers' mcps.resources block sizes it,
so the steady total in Limits goes from 7 pods to 8), and every
call it makes to one of your systems writes an external_call line like the
rest of the appliance. It renders
only when sources.registry.enabled is on, which is the default.
To switch it off, set mcps.discoveryMcp.enabled: false. The pod and the
Stream setup page both go, and nothing else changes.
Somebody who has never appeared in a source is no longer counted as having done nothing
An observed zero now requires the person to have appeared before in the column
the group is matched on, for example author_login for "who has merged no
pull requests", as far back as your installation has read the source. Somebody
whose identifier has never appeared there is counted as unknown
observation, not as an observed zero. The answer says how many such people
there are, which column, and how far back the installation has read that
source. Somebody whose work sits only in repositories the credential never reached used to read as
having done nothing.
What you will see: on a group that includes people who never use a source, the observed-zero count goes down and the unknown-observation count goes up by the same number. It applies to the column the question is matched on, not to the whole source. Somebody who reviews pull requests but has never authored one is not an observed zero for "authored no pull requests". Somebody who appeared only in repositories that left the credential's reach before the window is still an observed zero; the limits page says so. The check runs only when somebody would otherwise be an observed zero. Its cost is on the limits page. For it, Prism adds an index on the person column of pull request reviews, GitHub issues, Jira issues (the assignee and the assignee's address) and GitLab merge requests, and creates them for you after the upgrade. Each took about five seconds to build on a table of ten million rows.
A refused copy says how to read your server's own message
When a database refused a copy with a code Prism had no entry for, the message
said only that the code was unfamiliar and why Prism had not retried. It did
not say what to do next. It now tells you to run the stream's SELECT as the
service account in your own client and read your server's own message, which
Prism never prints. The Test button's verdict carries the same
instruction. The Postgres and Oracle recipe pages list the session settings
the copy uses, under "When the code is not in this list".
Postgres 22021 and 22P05 now have their own message, in the stream's own
refusal. When several streams were refused in one run, the stored summary
names each stream with a short remedy instead. The message says a text value
will not convert to UTF-8. Prism always asks the server for UTF-8. Your own client may
ask for something else, and then it shows the same row without complaint. The
message tells you how to see the offending bytes:
set client_encoding to 'UTF8', then run the SELECT.
A rejected login (28P01 or 28000) and a database that does not exist for
the login (3D000) are also named now. They used to fall to the same "not a
code we have seen" message.
A database copy that stops getting answers now fails instead of waiting all day
A context stream that copies your Oracle or Postgres, with no
connection.database.statement_timeout in its manifest, had no time limit.
If the source stopped answering, for example because a firewall or load
balancer dropped the connection mid-query, the copy waited silently until the
job's own deadline (24 hours by default). Nothing was written to the log, and
the sources scheduled after it did not run either.
Now:
- On Postgres, each setup step has a 60-second limit: connecting, the
session settings and starting the transaction. Preparing the statement and opening
the cursor lock and plan your view, so they count as part of the statement
and wait as long as it may: a reload holding a lock on the view's tables is
waited out, not reported as a network stall. A setup step that gets no
answer is retried like any network fault. The log names the step, for
example
database error (no answer to connect within 60s), and so does the final "giving up" line. - Each fetch has a limit when the manifest sets no
statement_timeout: one hour by default, set by the new chart valueingest.engine.databaseFetchTimeoutSeconds. It limits one fetch, not the copy. Every chunk that arrives restarts it, so a long first copy that keeps delivering is never stopped by it. A manifest that setsstatement_timeoutkeeps using that value, exactly as before. No setting is sent to your server for this bound, so astatement_timeoutyour DBA set on the login or the cluster still applies unchanged. - Postgres connections use TCP keepalive, so a connection dropped in the network usually fails within about two minutes.
- Oracle copies with no
statement_timeoutnow have a limit on every round trip after connecting: the driver'scall_timeout, set to the fetch bound. Connecting to Oracle is not bounded by it; the session settings made after connecting are. A statement is stopped at that limit. A fetch that stalls can take up to about twice as long to be stopped, because that is how the driver behaves (measured). - An Oracle connection that closes early is no longer reported as your
statement_timeout. Oracle'sDPY-4011means either the call timeout fired or the connection closed. With astatement_timeoutin the manifest, everyDPY-4011used to read "the statement did not finish inside the configured statement_timeout", even one that came seconds in. Prism arms half of the tighteststatement_timeoutthe manifest sets (on the connection or any stream) as the call timeout. On an open session, aDPY-4011before about 90% of that armed value is now treated as a closed connection (see the next bullet). A later one still reads as the configured timeout. Before the session is set up, aDPY-4011ends the run at once and its message names both possibilities; aDPY-4024there names the call timeout. - A dropped connection no longer stops the streams after it. A database
source's streams share one connection. Before, once any stream of the run
had read a page, a dropped connection ended the run: a drop at stream 5 of
41 stopped streams 5 to 41. (On Oracle a
DPY-4011there read as the statement timeout instead, and lost only that stream.) Now a stream that has read nothing is retried on a new connection, and if a retry succeeds the streams after it copy too; if every attempt fails, the run stops there, as before. A stream that had read a page is refused for this run and keeps its previous copy, and the streams after it copy on a new connection. The log says the streams copied after a reconnect are read at a later instant under the sameas_of, as it already does when a refused stream closes the connection.
What could change for you: a copy whose manifest sets no
statement_timeout, and whose view takes longer than an hour to produce its
first chunk, now fails where it used to finish. This happens when a view
sorts or aggregates everything before returning a row. Set
statement_timeout in that manifest to what the copy needs, or raise the
chart value.
A stream whose connection drops before it has handed over any rows is tried up to six times in all, whichever stream it is, and each attempt runs its statement on your database again.
One slow context stream no longer holds up the others
The ingest runs your context streams one at a time. A stream that kept answering, but slowly, held every stream behind it for as long as it ran, up to the job's whole deadline. Examples are a view that returns rows at a trickle, or an API that makes the walk wait out its rate limits. The streams behind it did not refresh, and could miss the next run too.
Now each stream has a time limit inside a run, set by the new chart value
ingest.engine.sourceDeadlineSeconds (two hours by
default). A stream that reaches it is stopped and the next stream starts. The
stopped stream records why, which the self test shows on its CONTEXT
STREAMS line: prism-ingest tick: abandoned after …s (ingest.engine.sourceDeadlineSeconds) so the sources behind it could run. Its
next run goes last, after the others.
- A mirror (GitHub, GitLab, Jira) keeps everything it had landed and resumes where it stopped.
- A snapshot or a file copies each of its streams in one pass. The stream it was copying when it was stopped keeps its previous copy exactly as it was; a stream it had already finished keeps its new one.
A stream stopped without moving forward gets one run with the rest of the
day. That is any snapshot or file, and a mirror whose resume point did not
move, for example when one window of its history takes longer than the limit.
Its next run is given what is left of ingest.runDeadlineSeconds instead of
the limit, so a copy that fits in the time left can finish. That happens once,
and only a run given at least the limit counts: when the day is nearly spent
and less than that is left, the stream keeps its turn for next time.
If it is stopped even then without moving forward, the reason says so (stopped … with the day's whole remaining budget … does not fit in ingest.runDeadlineSeconds) and names what
the stream reads from, and from then on it is held to the limit again, so it
cannot take the whole day on every run. That is a matter for its statement or
its database, not for this limit. Another failure or a restart does not end
the hold. It ends when a run completes, or when
you change what could make the copy fit: the source's manifest, its parameters (set on
the source or in the chart), its uploaded file, either deadline, oracle.mode, or the Prism version. Its next
run then gets the rest of the day again, once.
Streams that were stopped go after the others, the one that has waited
longest first, so two stopped streams take turns. Each stream is also given no
more than what is left of ingest.runDeadlineSeconds, less ten minutes kept
back for the work after the streams. A stream the run has no time left for is
not started; it runs next time, and nothing is recorded against it.
If a stopped stream's clean-up itself stops answering (a database that has gone quiet), it is interrupted again every two minutes until it lets go, so the run can go on.
What could change for you: a mirror whose honest walk takes longer than
two hours now does it over several runs rather than one, and the streams
behind it refresh in between. Raise ingest.engine.sourceDeadlineSeconds if
you would rather it ran in one.
A Postgres DSN whose certificate file cannot be used now says so
A Postgres source whose DSN named a certificate, key or revocation file
(sslrootcert, sslcert, sslkey, sslcrl) that is missing from the
appliance, is a directory, or is not what that setting takes, failed Test
without explaining why. Its record also said the password had been sent,
when nothing left the appliance. 0.15.0 listed this as not yet covered.
Prism now loads the named file before it dials, exactly where its Postgres client would read it, and Test reports that it could not ask rather than recording a password as sent. It says which setting names the file and that the file is missing, a directory or of the wrong kind. The path is never quoted.
Files that load keep working, and so do files your sslmode never opens.
The client reads sslcert (with sslkey beside it) from sslmode: allow
upwards, and sslrootcert and sslcrl from require upwards; below those it
opens nothing. So a source naming a file at an sslmode that does not use it
connects exactly as before, and a source whose files are present and readable
in both the app and the ingest images is unaffected. Naming the system CA
bundle (sslrootcert=/etc/ssl/certs/ca-certificates.crt) with verify-full
is still how to verify a server whose certificate chains to a public CA.
The appliance does not supply certificate or key files for a source's database, so if you name one it has to be a file present in the images.
GitHub, Jira and GitLab can no longer be designated as your directory
Your organizational context stream is a list of your people — one row per person, keyed by an employee id or an email. Designate and Move to… now refuse to hand the directory to a source whose primary key has no employee id or email field (including every GitHub, Jira and GitLab source, whose rows are pull requests and issues), and the page offers only the sources that qualify: a staff list, or a directory view. If a source that is not a list of people holds your directory today, it keeps it: it can still be disabled, enabled and edited, and its pane and the self test warn that it should be moved to a source that lists people.
A generic Snowflake recipe, and the directory recipe renamed
New: snowflake_table, "Snowflake table". A recipe for any table or view
in Snowflake — a record id, a timestamp and a few attributes you rename —
answered in place like the directory. It makes no assumption that the rows are
people. Connect a Snowflake table walks through it.
Renamed: the Snowflake directory recipe is now snowflake_directory, its
billing pack is Identity (the pack the people-file recipe uses), and it no
longer carries a role: organizational line: which source is your directory
is your designation, never the recipe's (see the next entry). The recipe's
columns, questions and connection are otherwise unchanged.
On an installation you upgrade, a source registered from the directory
recipe under its earlier name is not touched: its name, its stored document,
its credential, its parameters and everything keyed by its name stay exactly as
they are, and it keeps answering. The one visible difference is on its page:
the recipe it records having been registered from is no longer offered, so the
source is shown as not compared against any recipe. Registering a new
directory source uses snowflake_directory; an automation that registers by
the earlier recipe name is refused and must use the new one.
Registering a source no longer designates it as your directory
A manifest's role: organizational line is no longer stored when you register
a source, whether it came from a recipe or a document you pasted. Which source
is your organizational context stream is only ever the designation you make on
a registered source (Designate on Admin → Context streams, or
POST /api/admin/sources/<id>/organizational), and the register response says
so when it drops the line.
What this fixes: with a roster (or any other source) designated as your directory, a source whose recipe carried the line could not be enabled at all — the enable was refused as a second directory, although nobody had designated it. It now enables as an ordinary source beside your directory.
On an installation you upgrade, a source registered by an earlier release
keeps the line it stored, and still counts as designated. If it is the source
that holds your directory, nothing changes. If enabling one is refused because
another source holds the directory, the refusal now says so and names both
remedies. To run it beside your directory, press Withdraw designation on
that source's row in Admin → Context streams (it is offered on any source
that carries the designation and is switched off; the API is
DELETE /api/admin/sources/<id>/organizational), then enable it — its stored
credential is kept. To make it your directory instead, use Move to… on the
current directory's row, which now offers it — marked (switched off),
because until you enable it your installation has no directory.
Enabling a source that carries the designation while no source holds your directory makes it your directory: the page asks before it does so, and the note says so.
Enabling a source that carries the designation now also checks it declares an identity kind, as designating one always has: a source with nothing for other sources' people to be resolved through is refused rather than enabled as a directory that cannot resolve anyone.
When registration drops a role: line, the page now says so under the new
source as well as the API.
Designating an enabled source while another holds the directory is still refused, and that refusal now offers only the move — it no longer tells you to withdraw the designation you had just asked for.
The Oracle recipe is a generic worked example, and its copied form ships
The Oracle recipe in Admin → Context streams is now the Resource plan
tile: a worked example of a plan view — who is planned on which project, in
which month, for how many hours — with every line marked as the engine's
(STRUCTURAL) or yours (YOURS), the way the Snowflake recipe is. It used to be
one customer's view, under that customer's product name, and a reader who was
not that customer reasonably concluded Oracle was not for them. The recipe's
key is still clarity, so rows registered from it keep their provenance.
A source you registered from the old recipe keeps its own model — do not re-apply the recipe to it. Its stored document is what is read and answered from, and the upgrade does not touch it. It now differs from the recipe in every field, on purpose, so Admin → Context streams says the recipe's example was replaced and the source keeps its own model, and the self test's recipe drift line names it without warning. Re-applying the recipe from the source's Manifest tab would replace your model and your verification questions with the generic example. Applying the recipe's current example on purpose clears that mark, and from then on the source is compared with the recipe like any other.
The mark is written once, on the first start of 0.16.0, on the sources registered from this recipe at that moment. If you downgrade to 0.15 and register a source from its recipe there, that source is not marked when you upgrade again, and it will read as differing from the recipe; do not re-apply the recipe to it either.
Read an Oracle view as a context stream is new: what to ask
your DBA for, the four connection values, what the document needs your view to
have, a column check to run as the service account, and how to verify the
figures. It also carries the copied form of the recipe — kind: snapshot,
for a stream that keeps dated copies so you can ask how the plan changed — as a
complete document. That document used to exist only as a test fixture, which
reached no tarball. It ships now, and the test suite executes the copy of it on
that page against a real Oracle, so it is kept working rather than kept in step
by hand.
Importing a classification shows your file's columns before asking what they are
On Admin → Definitions, Import a classification from a file used to
ask you to type both halves of the column mapping — each heading out of your
spreadsheet and each directory field off another admin page — before it would
read the file at all. Now choosing the file reads it straight away, stores
nothing, and lists your file's own columns, each with a few of its values; for
each one you choose the directory field it holds from the fields your
organizational context stream declares, or leave it as not used. A file
Prism cannot read is refused at that first step, in the same words as before.
The counts, the refusals and what is stored are unchanged. For scripting,
inspect without a mapping returns the headings, and
GET /api/admin/definitions/fields returns the declared fields — see
Upload a classification of your people.
Uploading who owns what has a page
The ownership import shipped in 0.14.0 with only an API, so the one way to make an ownership table on Admin → Definitions was typing it as JSON, which skipped the step that connects each owner to a person. The Who owns what card now opens Upload a CSV: choose the file and see its columns, say which is the owner, the area and the products and what separates products, read every count at its own grain, choose between the people an ambiguous name matched (or leave the owner unplaced), see the groups it would create and any word already in use, and import it as a draft. The JSON editor is still one link down on the same card. An ambiguous owner's candidates now carry the name fields your directory discloses for each of them beside the key that tells them apart — see Upload who owns what.
Verification › Run all questions works behind a proxy with a short timeout
On an install behind a proxy with a short timeout (OpenShift's Route defaults to 30 seconds), Run all questions on a context stream's Verification tab could never show a result. It sent one request that ran every question before answering, the proxy cut it off, and every question still read "not run".
It now asks one question per request, in order. Each question says it is running while it is, and its figure appears as soon as it answers. A failure is shown on that question, in words. When the proxy is what gave up, the question says so and names the timeout to raise; it is not presented as an answer about the source. A proxy that could not reach Prism at all — a pod restarting, a service with no ready endpoint — says that instead, because no timeout would fix it. Where a re-run fails and the previous figure is still on screen, the question says which run the figure is from and its verdict buttons go dead until it answers again. A single question that takes longer than your proxy allows still needs the timeout raised: Installing Prism, step 1e, now says how for an OpenShift Route and for ingress-nginx.
A re-walk releases a widening's zeros only once it has finished
Starting a re-walk used to be enough to count a widening's days as re-walked. If that refresh died part way, the next ordinary refresh still treated the days as re-walked, and a window over them could publish observed zeros for days only the narrower credential had walked. Now the days count as re-walked only when the refresh that carried the re-walk completes. A re-walk that dies, or that finishes with part of the source failed, leaves those zeros withheld until a re-walk from a new date completes.
Nothing to set. A re-walk that died on an earlier release may already have released its widening; if you know of one, re-walk again from a day earlier and let it finish. See What does NOT withhold an observed zero in the limits page.
An observed zero from GitHub or GitLab says what bounds it
An observed zero means nothing turned up in what the credential could see,
and the answer never said so. Somebody whose work is only in repositories
the credential cannot reach is an observed zero. Now every answer with an
observed zero from GitHub states the credential's reach now and the date it
was measured (zero_reach on the measure, and a sentence in the answer),
noting that the window may have been walked at a different reach. A zero
matched through reviews gets no number, because reviews are read only from
repositories the credential can open. A GitLab answer says the reach is not
measured. Nothing is withheld because of it.
Nothing to set. If the number is smaller than the set of repositories your developers work in, read the zeros with that in mind, or widen the credential (see Widen a source's scope).
A failed source no longer waits out the hour after a values-only fix
A source whose last run failed is held off for an hour. Since 0.13.0 an
upgrade ends that wait, but only when the chart version moves, so correcting a
value such as oracle.mode and redeploying the same version left the source
waiting anyway. Each attempt is now also stamped with a digest of the values
that decide how a source connects (today, oracle.mode), and a change ends the
wait at the next tick. For anything else, the tick's not-due line now names
the run-now request, which has always overridden the wait. That request is an
admin API call, POST /api/admin/sources/<id>/ingest/run-now: earlier notes
and the troubleshooting page called it a Run now control on the source's
page, and no page has one.
A run that was killed says so
When a tick's run was killed rather than failing, nothing recorded why, and the tick's line said a run had failed without a reason. Now:
- a SIGTERM (the run passing
ingest.runDeadlineSeconds, an eviction or a node drain) is caught from the moment the tick starts. A source that was running is recorded as "the run was stopped by SIGTERM before it finished", and no further source is started. The tick exits 143 either way. Before this, the tick process ignored the signal and ran on until it was force-killed; - an attempt that left no reason at all (an out-of-memory kill, a crash in a native library) is reported as such, with the pointer to the tick pod's exit reason. Troubleshooting has the commands.
A copy that stalls without failing now has a page to look at
Troubleshooting
has a new entry for a context stream copying your Oracle or Postgres that
starts, loads nothing, and leaves its job Running (see also One slow
context stream no longer holds up the others). It lists the lines a bounded copy writes when it
stops (see A database copy that stops getting answers now fails instead of
waiting all day, above), and for a stall that writes none, two queries to
run and send us.
A database copy says where it is while it copies
While a context stream copying your Oracle or Postgres is being copied, the tick's log now has a line for it every minute: the rows handed over so far, the chunks, and how long the copy has been doing what it is doing now: opening a session on the source, waiting on the source, writing to Prism's database, or one of Prism's own pauses between requests or after a failed attempt. Before, nothing in the log said how far a copy had got until the stream (or one of its partitions) finished, so a stalled source and a slow one looked the same. Troubleshooting says how to read it. The line carries counts and seconds only, never the statement or a value.
A column that mostly holds another kind of identifier no longer publishes observed zeros
A matched column that mostly held something other than the identifier it is
declared as could still name people as having done nothing. Before, the zeros
were withheld only when not one value in the column matched anybody, so a
single stray match was enough to publish them. A column of Jira account ids
declared as github_login, one of which happened to be a login, counted nine
people as observed zeros. None of them could ever have appeared in that column.
What happens now. On every answer that asks for participation counts and
has somebody who would otherwise be an observed zero, Prism also compares the
column's values with what the declared kind looks like. An email has an @.
A github_login starts with a letter or digit and has no @, spaces, colons or
dots; a dotted value counts against the column, because a GitHub login cannot
contain a dot. An employee_id is only checked for being addresses or logins
ending [bot], and any other spelling fits it, hyphenated HR ids included. A
value that matches somebody counts as fitting. The zeros move to
unknown_identity in two cases: at least half the column does not fit, or at
most half its values match anybody and at least half of the rest look like a
different kind. The answer then leads with a sentence naming the column, its
declared kind, and what its values look like instead. identity.fact.shape
carries the counts.
One active person no longer vouches for the column. Before, the column was
checked only when the answer came back empty. If the one value that matched
belonged to somebody active in the window, everyone else in the group was
published as an observed zero. If the check does not answer, no zero is stated:
the would-be zeros are unknown_identity beside any observed activity, and the
counts are withheld when there is none.
What it costs. The check reads every distinct value in the column over the
whole table, and makes an answer that needs it take about one and a half times
as long in the database. Measured in review, it reaches the default 15-second
statement timeout on a table of about 20 million rows. Past that size, every
participation answer over that table loses its observed zeros, and says why,
until you raise mcps.semanticMcp.statementTimeoutSeconds.
What does not change. A healthy column that matches only a minority of its
values keeps its zeros, because the values it does not match still look like the
declared kind. That is the ordinary case where most of a forge's authors are
outside your directory. Kinds that look alike cannot be told apart. In a
github_login column, that includes Jira Server and Data Center usernames and
user keys. A kind Prism has no shape for is not judged this way. See What does
NOT withhold an observed zero in the limits page.
Nothing to set. If you see this sentence, correct the column's declaration or join on another column.
Security: a context stream you write can no longer read the appliance's environment
A manifest registered under a name of its own could declare env: on a
parameter, or override_env: on a discovery rule, naming any variable on the
ingest pod, including the appliance's own secrets. The ingest walk read that
variable and sent its value to the source's host: as the hostname, in a query
string, or in a request path. 0.15.0 closed this for database sources. This
release closes it for HTTP sources.
- GitHub, Jira and GitLab under their own names are unchanged. They read the chart settings their recipes declare, exactly as before.
- Any other source reads no environment variable. Its parameters come from
the values stored when it was registered, or from the manifest's
default. That includes a copy of a shipped recipe registered under another name, such as a second GitLab. Registration stores every value you enter and every default, so such a source is affected only where a field was left blank and the chart's value for the shipped source filled it. A registered source's parameters are fixed, so delete that source and register it again with the value, re-entering its credential. - Registering or editing a manifest that names a variable no shipped recipe reads is refused, with a sentence naming the line. A registered source whose stored manifest carries such a line keeps running, and the line is ignored.
A snapshot source with partitions keeps every partition's rows
A snapshot context stream that declares a partition (one copy per team, per
organisation, per project) kept only the last partition's rows each day and
reported success: each partition's walk replaced the whole day's copy. Every
partition's rows are now in the day's copy, written in one transaction, so a
walk that fails part-way leaves the previous copy in place instead of a partial
one. A same-day re-run still replaces the day's copy. No shipped recipe
declares a partition on a snapshot stream, so this only affects a manifest you
wrote yourself. If you have one, its copies before this release are short, and
its next run writes a whole one.
A partition value listed twice is now walked once. A partition list whose entries return the same rows, such as a group listed beside its own subgroup, is refused by name: the refusal names both partitions and asks you to remove the overlap. It does not ask you to change the stream's key.
Two more corrections for manifests you write:
- A
request.bodyon a GET is refused when the manifest is registered. A GET sends no body, so those values were never sent and the stream was read without them. Move them toparams, which is the query string, or declaremethod: POSTif the API reads a body. No shipped or preview recipe has one. A source already registered with one is marked invalid at its next run, with a sentence naming the line, and it is no longer answered from: the question-answering service does not load a manifest that fails validation. Before this release such a source could not be ingested anyway, so no data is lost. To repair it, edit the stream on the source's Manifest tab (orPUT /api/admin/sources/<id>/manifest) and apply the corrected document. - A substream's
{{ parent.… }}reference is recognised however it is spaced.{{ parent.id }}with two spaces, or with a tab, was resolved by the template but walked as if it named no parent, and the run stopped. It now runs once per parent record, like{{ parent.id }}.
A roster with a repeated employee id is refused when you upload it
Since 0.14.0 the scheduled run has refused a roster in which two rows share an employee id, because only one of them can be stored and a roster short by the difference would be a wrong directory. The upload itself still accepted the file, with a note, so you found out at the next run: hours later, and on a different page.
The upload now refuses a file whose key column repeats a value exactly as exported, with a sentence modelled on the run's: how many rows repeat a key, how many distinct keys the file has, and the key column as your export's header names it. Nothing is stored. A source you are setting up for the first time stays where it was, and a source that already holds a roster keeps that roster. De-duplicate the export and upload it again.
If you have been re-uploading an export that has always carried a repeated id, this is the release where that upload starts being refused. It was never loaded: the run has refused it since 0.14.0. A file like that which you uploaded before this release is still the stored file, and the run still refuses it, so upload a corrected export to replace it.
One case the upload does not catch yet: two keys that differ as text but are
the same key once your document's type or transform is applied — 007 and
7 in a column declared as a number, or E1 and e1 in a column that is
lower-cased. Those are still accepted at upload and refused by the next run,
as before.
A relative-period filter works, and a question Prism cannot compile no longer blames your database
Asking for Jira issues resolved "in the past two years" as a filter — the exact repair Prism itself suggests when a question on resolved issues has no period — failed every time, and the answer said Prism's stored copy of this data could not be read. Nothing was wrong with the stored copy: Prism handed its own database driver the period in a form the driver does not accept, and the question never reached the database. It now runs, and "the past two years" means the same 730 days it means everywhere else in Prism. The same period given as the question's window was never affected.
Separately, if Prism ever composes a statement the driver refuses in this way again, the answer now says Prism could not turn the question into a statement it can run, not that your stored copy could not be read. It is still reported as a failure, so it is not hidden, but it no longer sends you to check a database that is fine.
A question asking about thousands of years is refused, not reported as a broken database
A relative period can now reach back at most 1,000 years, whether it is written as a filter ("in the past 1,000 years"), as a question's window, or as a number of days. Before this, a longer period either failed with an internal error, from about 2.7 million years, or, from about 7,000 years, was reported as Prism's stored copy of this data could not be read, when nothing was wrong with the stored copy. A longer period is now refused with a sentence naming the limit, and the assistant is told to use a date range for anything older. No real data is anywhere near that old, so no answer you could get before is lost.
The same limit applies to a source's own document. A relative period in a
dataset's required or default filters, in a metric's filters, or in
policy.freshness.expect_within that is longer than 1,000 years is refused
when the source is registered or its document applied, with a pointer to the
line. Otherwise questions on that source would be refused for a value the
person asking never wrote. A source registered before this release with such a
value keeps ingesting. An overlong expect_within on it is harmless. An
overlong required filter refuses every question on its dataset, and an
overlong default or metric filter refuses the questions that use it. The
refusal says the period is too long, not that your document is at fault, so if
questions on one source are all refused this way, check its document and
shorten the value.
The draft-test API accepts a document that needs no credential
POST /api/admin/manifest/test tests a manifest you have not registered yet.
It used to refuse any document whose connection.auth.type is none, because
it required a credential to go with the draft. It now tests such a document
with no credential. If you send a credential key with a document that
authenticates with nothing, the request is refused, because the document and
the request disagree about the source. This is an API route; the Test
button on a registered source's Manifest tab is unchanged. See Test a manifest
before you register it.
The footer says when a listing left rows out, and the agent may not blame your data for it
When Prism lists the rows behind a figure by grouping them, a row whose grouped field is empty is counted in the total but appears in no row of the listing. The engine already reported how many rows that was, but only to the agent. In one reported case the agent listed 7 of 10 incidents and said the other three were empty in your data. They were not empty: they were the three still open.
Two changes. When a call in the turn grouped its rows and left some out, the answer's footer, the exported PDF and the feedback report now say so, whatever the answer's prose says: how many rows that call's listing did not show, and the grouped fields (the time period among them) one of which is empty on each. Those rows are still in that call's overall figure. The folded footer shows a warning with the count. And the agent is now told that such a gap comes from its own query, to report the count or list the rows another way, never to blame the source's data, and never to say that asking again will not help.
The default scope has a control on Admin → Definitions
0.14.0 shipped the installation's default scope — the one definition every
question that names nobody in particular runs under — with only an API to set
it, and Set a default scope for every question said there
was no control for it on the admin page yet. Admin → Definitions now opens
with a Scope band that shows the line answers name the scope with, and a
chooser that lists every activated definition. One that cannot be the scope is
listed, greyed out, with the reason. The chooser shows that line before you
save, and Everyone in the index clears the scope. Saving from the page
refuses if somebody changed the scope after you opened it, and a clear is now
recorded with who did it, as a set always was. For scripting,
GET /api/admin/definitions/scope now also returns candidates.
A source that is not on no longer reads as connected
If an administrator disables a built-in source (GitHub or Jira) under Admin → Context streams, the source panel beside the chat now says switched off on this installation and withdraws that source's suggested questions. Before this release the panel still said connected whenever the source's table had rows in it, even though the source's own row said it was disabled. A built-in source that is registered but has not been enabled yet, for example after it was deleted and added again, now says not enabled on this installation.
The semantic query tool (list_sources) also stops listing such a source.
This does not stop the chat answering about it. The built-in GitHub and
Jira statistics tools are wired into every installation and still answer a
question about a switched-off source from the rows already collected, and the
legacy collection job for that source keeps running while your chart sets it
to live. This release does not change either of those.
The rows already collected stay where they are, and the panel still shows how many there are. A demo installation's sample data is unaffected: a seeded source was never switched on, so it is not reported as switched off.
Adding a context stream starts from a shelf of prebuilt streams
Admin → Context streams → Add a context stream is now a shelf of tiles, one for each context stream this release knows how to read. Each tile names the stream it adds and lists the questions it makes answerable, what it needs from you (the credential, the network reach and any read-only role, with a link to the setup page), and whether it is mirrored, snapshotted, queried in place or uploaded. A tile that allows only one per installation, or that is a template you adapt, says so rather than offering a second. The last tile, Add your own, opens the manifest editor. See Add a context stream from the shelf.
Two things to know before upgrading. The manifest of every built-in source changed in this release, so a GitHub, GitLab or Jira source on the ingest engine whose first backfill is still running when you upgrade may start that backfill again; a source that has finished its first backfill is unaffected. And a source you register after upgrading is stored in a form 0.15.0 cannot read: if you roll back, register such sources again.
A question about a past window no longer names newcomers as having done nothing
A group read from today's directory, asked about a window that ended before
today, counted everybody who joined since as an observed zero. A reporting
line, a directory field, a classification, and an uploaded ownership table or
activated definition are all read from the directory as it stands when the
question is asked. Someone who joined in August had no rows in March because
they were not here, and the answer said they did nothing. Now any window that
ended before 00:00 UTC today withholds its observed zeros under
membership_after_window, and the answer says the group is today's, not the
period's. A window that reaches today is unaffected.
Nothing to set. Expect no observed zeros on historical questions asked through a group. Prism does not hold dated membership, so there is no repair other than asking about a window that reaches today.
A change in reach during an encryption-key rotation is no longer lost
A refresh that found the installation's encryption key rotated skipped the comparison entirely. If the credential's reach also widened or narrowed in that refresh, the change was never recorded, and observed zeros over the days walked at the narrower reach were published. The number of repositories is not tied to the key, so it is now compared on a rotation too. Whether the credential itself was replaced cannot be told on that refresh, so the refresh is now treated as a possible replacement: a window that straddles it publishes no observed zeros, as it would for a replaced credential. The Ingestion activity page says it was the key.
A refresh killed at the wrong moment no longer loses a change in what the credential can see
The end of a refresh made two separate writes: the reach it had just measured, and the change that reach represented. A refresh killed between the two (a pod eviction, a dropped database connection) kept the new reach and lost the change. The next refresh compared the new reach with itself, found nothing, and observed zeros over the days walked at the narrower reach were published. The two writes now commit together or not at all, so the next refresh detects the change again.
Nothing to set. A change lost this way before the upgrade is not recoverable.
A source you just tested no longer says it was never tested
When a test could not be carried out at all, because Prism refused to open the connection before dialling (a Postgres source set to verify a certificate it has no root certificate for, for example) or because this build cannot test that kind of source, the source's row said never tested or not tested yet, and its detail said Tested: never, straight after you pressed Test. 0.15.0 said this would be corrected.
The row now says tested with the time, then could not test it and the reason, and the detail's Tested line shows when the test ran. A source whose credential was stored without testing, or that the installer enabled, still says never tested, because it never was. A source's word does not change: a test that could not be carried out establishes nothing about the source, so it still cannot be enabled on that result.
The same detail line now also shows when an uploaded file was read, where it used to say never.
Every call Prism makes outside itself is now written to its logs
Each HTTP request and each statement against a source database now writes one
JSON line to the container's standard output, with "event": "external_call":
which source and stream it was for, the host and path, the status, how long it
took, and how much came back. Credentials, query-string values, statement text
and row data are never in it. A team that owns a source can now find Prism's
load on it in the logs you already collect. It is on by default;
externalCallLog.enabled: false turns it off. The external-call
log lists the fields and what is not covered.
The self test lists every context stream you have registered
The self test's source lines were a fixed list: GitHub, Jira and Router spend. A source you registered yourself, GitLab for example, appeared nowhere on the page, so the report could say every check passed while that source was failing.
With the source registry on, a new CONTEXT STREAMS section has one line for every registered source. Each line shows the word and reason the Context streams page shows for that source, without anyone's name, and says whether a failed ingest run has been recorded since the last one that completed. The organizational context stream is the one source that can fail the whole report, because every person- and team-shaped answer depends on it. It does so when a failed run is recorded or it cannot be ingested at all; a failed Test on it is a warning. Anything wrong with any other source is a warning, because only its own questions are affected. The built-in self test has the detail.
The installation's GitHub and Jira tokens go only to the installation's own host
The GitHub and Jira tokens set in the chart (secrets.githubToken,
secrets.jiraToken, or a GitHub App) are now used only for the host the chart
itself configures: ingest.github.apiUrl (or GitHub.com) and ingest.jira.url.
A stream whose own settings on Admin → Context streams point it at a
different host no longer borrows the chart's token for it. Its run fails with
a sentence saying so. The fix is one of two. If it should use its own host,
enter a credential for it on its page on Admin → Context streams. If it
should use the chart's host and the chart configures it (sources.github or
sources.jira set to live), delete it there: it is set up again straight
away with the chart's current address. (A stream the chart set up keeps the
address it started with once it has been tested or switched off, so after you
change ingest.jira.url or ingest.github.apiUrl its own address can be the
old one.) A stream whose address matches the chart's is unchanged.
A context stream of your own is walked under any name
Until this release, the ingest job walked a mirror or snapshot only if it was registered from a prebuilt stream. A document you wrote, pasted or edited before registering it was accepted, tested and enabled, and then never walked, whatever it contained. The only remedy was to register again from a prebuilt stream and lose your edits.
Now the ingest job walks the document a stream holds, under whatever name you gave it, with no further step. It checks only that every part of the document is one this release implements; a document that names a part it does not implement wears not ingested with the part named.
A stream of your own needs its credential stored on the source. The job never lends a document of your own a credential from its own configuration, because the document chooses where its requests go. Until you enter one, the stream wears not ingested and the self test's ingest dispatch line names it.
If you renamed a GitHub, GitLab or Jira stream and it has been taking the
token set in the chart, enter the token on that stream's page: a renamed stream
is a document of your own and no longer borrows the chart's. A stream that keeps
its prebuilt name (github, gitlab, jira) is unchanged.
Go-live has the details.
A manifest whose root streams partition differently is refused when you save it
A document with two root streams that each split the walk on a different parameter, for example one per group and one per team, used to pass validation and then fail on its first run. The walk splits every stream on the first root stream's parameter. Such a document is now refused when it is registered or edited, and the refusal names the stream. Two root streams that split on the same parameter under the same name, as the prebuilt GitHub stream does, are unaffected. A stream nested under another that declares a split of its own is refused for the same reason.
Each pod now says which Oracle driver mode it is in
oracle.mode is read separately by the app, the ingest runs, the query
service and Stream setup's discovery service, and until now nothing reported which mode any of them actually used.
So a connection test that connected beside a scheduled run refused for
encryption could not be told apart from a setting that had not worked at all.
These now say:
- the self-test bundle's
configcarriesoracle_mode_app, the value the app pod read:thin,thick,unsetorinvalid(unknownon an install with no source registry); - an Oracle source's detail on Context streams shows Oracle driver mode, the mode its connection test used;
- an ingest run logs
oracle session opened in <mode> modewhen it connects; - the query service logs
oracle: session opened in <mode> modeat its first Oracle session, and so does Stream setup's discovery service, which opens Oracle sessions through the same code.
The refusal for a database that requires Native Network Encryption is
shorter and names the value this pod read first, for example "this pod read
PRISM_ORACLE_MODE unset; set oracle.mode: thick in the chart values, or use
a TCPS listener". For a connection test, that sentence is the verdict on
Context streams. For an ingest run, it is the source's recorded reason, in
the tick's log and on the source's ingest state row. It no longer tells you to set
connection.database.mode: thick on the source as well. That value was never
needed: the chart's oracle.mode: thick alone is the switch, and a registered
source's connection cannot be edited anyway. The same correction is made in
the Oracle pre-flight page,
which also gains a section on
which driver mode an installation should be in
and what thick mode costs. The pre-flight script's advice and its --help,
and the troubleshooting on Read an Oracle view as a context
stream, are corrected in the same way. A source that declares
mode: thick on an installation still set to thin is now told only to set the
chart value; it no longer mentions the images, because every image that connects
to Oracle has carried Oracle Instant Client since it first shipped: the app, the
ingest engine and the query service since 0.14.0, and Stream setup's discovery
service from this release, its first. Adding mode: thick to the
document of a source already registered is refused, because the value is fixed
at registration; the refusal now says so and points at the chart value, rather
than advising a change that could not have made the edit succeed.
0.15.0 (18 September 2026)
Upgrading to 0.15.0
Two changes in this release can stop something working on an installation you upgrade. Both need a check before you upgrade, and each has its own entry below with the detail.
- A database source no longer reads anything from the appliance's
environment — not a parameter, and not its password. A Postgres or Oracle
source that took a value from an
env:line, or its password from acredential_ref, stops on its next run and names the parameter or the missing password. Sources with stored values keep running. To repair a parameter, remove itsenv:orderive:line from the manifest — registration refuses a manifest that still carries one — then delete the source and register it again with the value, re-entering its password. A Postgres source's role is the exception: it is a session setting, and can be set withPUT …/manifestwithout registering again. A missing password on its own is entered on the source's credential form. See A database source's settings and password are never read from the appliance's environment. - A failing source no longer fails the ingest Job. A green Job in
kubectl get jobsno longer means every source ran, and an alert on Job failures no longer catches a source that simply fails. For a signal a monitor can watch, use the ingest tick's own output, if your cluster keeps it: each tick ends with a JSON line whoseingest_tickisdoneand whosefailedlists every source that failed. Neither the self test nor the diagnostic bundle replaces the alert — the self test covers GitHub, Jira and spend only, and both need a super-administrator's session. See A failing source no longer fails the ingest Job.
One new chart value, off by default. disclosure.participationDetail lets
an answer name the people behind a participation count; nothing changes unless
you turn it on. See Prism can now answer "how many have NOT done this".
A correction to 0.14.0's description of disclosure.perPersonFloor. It said
per-person counts are never withheld. On GitHub they were: the shipped source
opts its count of distinct people into the floor, and did in 0.14.0 too. No
answer that 0.14.0 gave changes on upgrade — only the description was wrong,
and parameters.md now says what it does.
A Postgres source set to verify a certificate now says why it could not connect
If you set sslmode: verify-ca or verify-full on a Postgres context
stream, Test told you almost nothing and one thing that was wrong. The probe
failed instantly with a transport error and no explanation, and its record of
what it tried said the credential had been sent — when in fact nothing left
the appliance and no connection was opened at all.
What was happening. Verifying a server's certificate needs a root
certificate to verify it against. The appliance ships none for a source's
database, so unless the source's DSN names one with sslrootcert, the Postgres
client refuses those two settings before it dials. That refusal was being
reported as though the database had been unreachable.
What you see now. The Test result reads transport_error — followed by a
sentence saying that nothing was sent and why the client would not open the
connection, where before it said nothing. It is not evidence about the source,
because nothing was asked. The sentence names the actual cause rather than
guessing from the setting: a DSN that is not a postgresql:// URL is named as
that, and only a source with nothing to verify against is told about the root
certificate. Two things on the page still read as if no test ran, and are being
corrected (prism #1597): straight after Test, the source's row says needs
setup · not tested yet, and its detail says Tested: never.
Nothing you could do before, you can do now. A source still cannot be enabled on this result, and a credential entered on it is still refused, with the one you had stored kept — both exactly as before. What changed is what you are told.
If a source uses verify-ca or verify-full and its connection is in host
form, or its DSN names no sslrootcert, it has never connected: switch it to
require, which encrypts the session without verifying the certificate, and
probe it again. A source whose DSN names an sslrootcert is not covered by
this change: if the file it names is not on the appliance, or is not a
certificate, the probe still fails without connecting and still reports the
credential as sent. That is tracked as prism #1596.
A database source's settings and password are never read from the appliance's environment
Before you upgrade, check how your database sources get their connection
settings, their parameters and their password. From 0.15.0 every parameter of
a database source (its host, port, service or database name, schema, role and
login, and any value its statements are run with, such as a partition list)
comes from the values stored for the source when it was registered, or from a
literal default in its manifest. Its password comes only from the credential
stored for it. None of them is read from an environment variable on the ingest
pod any more. Until now a manifest could declare env: on a parameter, or name
a credential_ref, and the ingest walk would read that variable from its own
pod, where the appliance's own secrets live, and send its value to your
database: as a schema name, a login, a password, or a value in a query.
- Existing sources with stored values keep running. A line declaring
env:on one of their parameters is now ignored. - A source that relied on a variable stops on its next run and names the parameter or the missing password, never the value. A registered source's parameters are fixed at registration, so for a parameter the repair is to delete the source and register it again with the value, re-entering its password. A missing password is entered on the source's credential form.
- Registering a database manifest that declares
env:orderive:on any parameter is refused, with a sentence naming it, on Postgres, Oracle and Snowflake alike. The shipped Oracle recipe no longer declaresCLARITY_DB_*variables, and a copy you keep of an older recipe needs those lines removed before it registers.
GitHub, Jira and GitLab, which read their URLs, logins and tokens from the chart, are unchanged.
Security: Test on a context stream could send an appliance secret to a host an administrator named
Fixed. If you have not given anyone but your own administrators the super admin role, nothing in your installation has done this — but read the paragraph below on what to check.
What it was. Admin → Context streams offers Test on a registered stream: it
compiles the document you are looking at and makes one request with the
source's own credential, so you can see whether the connection works before you
switch anything on. For the sources Prism ships a recipe for — GitHub, Jira,
GitLab — the credential is not on the source's row at all: it is the one you set
in the chart (secrets.githubToken, secrets.jiraToken), and Test borrows it
so that those two can be tested like any other source.
A super admin could register a source under one of those shipped names that
your installation does not configure (gitlab is unconfigured on every
installation unless you connect GitLab, and a source you do use can be deleted
and registered again), give it a base_url of their own and an auth block
naming any variable in the pod as its credential, store no credential on the
row, and press Test. The request went to their host with that variable as the
bearer token. ENCRYPTION_KEY — the key that decrypts every stored source
credential — is one of the variables it could name, and the request itself
reaches anything the pod can reach on your network.
What it is now. The installation's own credential is lent only to the source the installation configured: the row's connection must be the one this image ships under that name — the same comparison the ingest run already refuses a modified shipped source with — and the source must be enabled, which for a shipped source carrying no credential of its own only ever happens through Prism's own start-up from your chart values. Registering a source under a name of your own and entering its credential on the row is unaffected, and so is testing GitHub or Jira on an installation that configures them. A source that is refused says so, and says which of the two it was, instead of testing nothing silently.
What to check. Everything this needed is recorded: Admin → Context streams
shows every registered stream with its origin, and the source audit records
every registration and deletion with who made it. Look for a source registered
under github, jira or gitlab by an administrator rather than by Prism
itself, or for one of those deleted and registered again. If you find one,
rotate the credentials in your prism-secrets Secret — the source tokens and
ENCRYPTION_KEY. Rotating ENCRYPTION_KEY makes every stored source
credential unreadable rather than re-keying it, so each source's credential has
to be entered again; Rotating credentials is the
procedure.
Observed zero is withheld where Prism cannot establish it
observed_zero is the one participation count that can mean "they did not do
it", and it counted people Prism could not vouch for. Those people now move to
an unknown count, or the counts are withheld with a reason:
- what the source could see changed in a way that reaches the window. Before,
only a widening inside the window, or an answer broken down by time, withheld
the zeros. Now a narrowing or a replaced credential inside the window does
too, and so does a widening from any date whose not-yet-re-walked span
overlaps the window, however the answer is grouped. These people are
unknown_observation. - the change is remembered after its notice clears. The reach notice still clears after a few refreshes. The record the zero check reads keeps every change. A re-walk from a date clears a widening's days from that date on, and the days before it stay at risk until a re-walk reaches back to them — so the notice for a partly re-walked widening now stays up instead of clearing on the next refresh. A narrowing or a replaced credential whose notice had already cleared before this upgrade is not in that record. See What does NOT withhold an observed zero in the limits page for that and the other known gaps.
- work Prism cannot attribute. A matched column that is entirely empty or
NULL, or any qualifying row in the window with that column blank, moves the
would-be zeros to
unknown_identity. - a check that did not answer. If the check whether the column matches
anybody, or the count of blank rows, fails, the counts are withheld and
states_unavailable_reasonsays which.
Nothing to set. Expect fewer observed zeros on sources whose credential has changed, and on columns with blank values.
The scope line said "every row" over answers that were not every row
A question scoped to a population answered under scope: "every row of pull_requests (no filter asked for)" — the one field named for what an answer
covers, and one an agent is told to quote. It now names the population that
narrowed the answer instead — including the installation's default scope, on a
question that named no population of its own. It says which people, not how
many.
The same line said "every row" over any answer a source's own defaults had narrowed — on GitHub that is every question nobody filtered, because bot-authored pull requests are excluded by default; on an uploaded roster it is the difference between current staff and everyone who ever worked here. Those answers now name the dataset and say that the installation's defaults removed rows, rather than claiming every row of it.
The defaults applied: clause now says what each default did, not just
which field and operator it used — author_login not_ends_with '[bot]' rather
than author_login not_ends_with. The value is the part that tells a reader
what was left out.
A Postgres source's TLS, timeout and role can be changed after it is registered
The three lines the Postgres recipe marks yours — sslmode,
statement_timeout and the role — can now be edited on a registered source,
by editing its manifest. Until this release every change to a source's
connection was refused, so an operator whose database offered no TLS, or whose
view took longer than ten minutes to copy, could not fix the line the probe's
own refusal named: the only way through was deleting the source and registering
it again.
An edit that changes one of them keeps the stored password (where one was stored — a password whose probe failed never is), withdraws the probe verdict, and takes the source back to registered. Probe it again — the probe runs under the settings you just wrote — then approve and enable it. The source's address, database, schema, login and credential still cannot be changed this way, and the refusal names the line that did.
read_only_role does nothing on Postgres, and the recipe now says so. No
SET ROLE was ever issued for it, and a role of that name was never required.
For the session to assume a role, replace the line with role_parameter and a
parameter holding the role's name; the probe then signs in as that role and
reports it.
A Postgres probe now says whether the connection Prism opened was
encrypted, and the source's Overview shows it. It is Prism's side of the
connection: behind a connection pooler, the pooler's own link to the database
is not what it describes.
sslmode: allow connects in clear text even to a server that offers TLS — it
tries an unencrypted connection first — and prefer does so to a server that
offers none; until now nothing but the raw manifest said so. Changing sslmode is
recorded in the audit log with both values. Copy a Postgres table or view
has the steps. Snowflake and Oracle sources are unchanged: their connection
still cannot be edited.
Definitions refusals now call a kind what the documentation calls it
When Prism refuses something you sent to the Definitions page or its API, the message
now names the kind the way every install page names it — a classification, a
reporting line, a list of identifiers, a context document, an ownership table.
It used to print the internal identifier instead: 'tuples', 'ownership',
and in one case the whole list as a raw code value,
('tuples', 'reporting_line', ...).
Those identifiers are what the database stores and what the API field takes. They are not words this documentation has ever used for the objects themselves, so a refusal saying "nothing here can count a 'identifiers' definition" was describing something you had no name for, on a page whose own prose calls it a list of identifiers.
Eleven messages changed, across writing a definition, previewing one, activating one, choosing the installation's default scope, and importing an ownership table — including the note added to a definition's summary when it reuses a withdrawn name.
Only the wording changed: every refusal happens on the same input, at the same moment, with the same status code. Where a message has to tell you a value to send, it now gives both — "classification (tuples)" — because the word is what you read and the identifier is what the field takes.
One thing this does change, and it is a fix rather than a side effect. When you activate an ownership table, Prism writes a one-line summary for each area and product it compiles — and those summaries are capped at 200 characters. On a register with long names, the summary Prism composed could exceed the cap and be rejected, and the failure was quiet: the activation still returned success, and the affected area was left with no active definition — including one that had been live before the upload. No reuse of a withdrawn word was needed; an ordinary upload with an area name and a table name totalling about a hundred characters was enough, and this release's longer words widened the range of names it happened to.
Prism now shortens the summary it composes so that it fits, keeping the part that explains what the definition covers. Summaries written by earlier versions are left exactly as they are. If an area has disappeared from a long-named ownership register in an earlier version, re-uploading it on this one will compile it.
An answer with several caveats now says which one explains the figure
A population answer carrying more than one caveat now leads with the one that accounts for the figure, and marks the rest as ranked below it. Nothing is hidden: every caveat that was published before is still published, and each one is still true. What is new is that the answer says which of them is the reason, so a reader is not left to take whichever they read first as the explanation.
The change matters most where two true sentences point in opposite directions. An answer of zero can be zero because a column holds a different kind of identifier than the one it is declared as — in which case the figure is not about those people at all — and the same answer can carry a true sentence saying that some of the population have no resolvable identifier. Read in the wrong order, the second lends credibility to the first: of course it is low, only some of them resolve. The answer now leads with the broken match, and the coverage sentence is printed under a line saying it ranks below and must not be led with.
Two sentences that used to be printed in two places, once by the answer and once
by the structured quality block beside it, are now printed once. If you have
built anything on the exact text of an answer's reading list, read the next
paragraph.
Which caveat leads is decided by what actually fired, not by a category. One of the conditions an answer can report — "this index cannot establish that nobody did anything here" — stood for six different underlying reasons at once, ranging from "the window runs past what the index holds" to "nothing records whether the last refresh finished". Because it was ranked as a single thing, the weakest of those six could take the lead sentence: an answer whose figure was zero because every row in the window carried no identifier was explained to the reader as an unverified refresh instead. Each reason is now ranked on its own, so a measured fact leads and "nobody established whether" comes last.
This matters most on an index whose completeness has never been recorded, which is the state of any index ingested before 0.12.0 — there, the weakest reason was live on nearly every answer.
Two caveat sentences also changed wording. Where an index cannot establish a zero for a question, the answer now states that once and leaves the conditions behind it to the caveats that name them — rather than restating each condition in a second set of words. And where a refresh came up short, the ranked sentence is the one that says what was short (which partition, which days), not a general "the last refresh did not complete" printed above it. An index that simply keeps no record either way is no longer described as one whose refresh failed; those are different claims and an installer can act on only one of them.
What is in the payload. Every population answer now carries a
result_quality object. It is new in this release — no earlier version
published any of it, so there is nothing to migrate. It holds population,
identity, measures and explanation; contract is the structure's version
(1), and superseded_fields lists the older fields it replaces, which are
still published.
explanation is the part a reader acts on. cause is the one caveat that
accounts for the figure and headline is its sentence; causes is every caveat
that fired, in precedence order, with cause first; detail holds the
sentences for the rest. There are thirteen causes, highest precedence first:
join_total_miss, population_unmeasured, scope_change, fact_nulls,
partial_intersection, window_truncated, failed_partitions, capped_days,
identity_coverage, identifier_kind_absent, window_opens_before_data,
window_before_horizon and completeness_unknown.
The population's size and how much of it resolves are two sentences rather than one, because only the second is a caveat: the size is the denominator of every share in the answer and is stated whether or not anything is wrong.
Each entry in measures carries observation — supported, reason and the
full reasons list — which is the field to branch on if you need to know
whether a zero can be established for that measure.
Prism can now answer "how many have NOT done this"
Ask about a group and the answer can now carry four counts over that group — observed activity, observed zero, unknown identity and unknown observation — instead of only a figure over the people who did something.
Only one of the four can mean "they did not do it." That is observed zero: everyone the other three do not take.
Here is what all four mean. Two of them are named after only one of the ways they are reached, which is worth knowing before you act on either.
- Observed activity — an identifier of theirs that Prism resolves to them alone appears in the work that counts for your question.
- Unknown identity — Prism could not attribute this work. Not "could not find this person": people counted here may be perfectly well identified. It is reached when an identifier that more than one person could own carries work in your window; when every identifier Prism holds for them of the kind this source is matched on is contested that way; or when the answer is empty and the source's identity column holds values, but not one of them matches an identifier of that kind Prism holds for anybody — a column of Jira account ids read as GitHub logins, say. Everyone moved into the count that way holds a clean identifier, and Prism cannot say why none of them appears in the column.
- Unknown observation — an absence here is not evidence that nothing
happened. Either they hold no identifier of the kind this source is matched on,
so it could never have shown their work; or the window and the source's
coverage do not support stating a zero for anybody — an unfinished refresh,
a window reaching past what the index holds, and several more. The answer
names every one that applies, in
observation.reasons, and Limits describes them; the two named here are examples, not the choice.
The percentages can be withheld while the counts stand. Prism counts the group and classifies its members with two separate reads, and compares them: if they do not add up, you get the four counts and no shares. That is ordinary on a running system — ingest writes while a question is answered — and asking again will normally settle it. Percentages are also withheld when the group's size could not be measured at all.
Do not reconcile these counts against the identity figures beside them. The
resolved and ambiguous counts in identity.cohort answer a different
question — who this source can identify at all — and will legitimately disagree.
In particular ambiguous counts everyone holding any contested identifier,
including people who also hold a clean one and were classified on that.
Limits has the full account: every route into each count, what repairs each, and what the answer says when the classification did not run.
The people behind a count can now be named too, and that is off until you turn
it on. disclosure.participationDetail is false on a new installation, and
while it is false an answer gives you the four counts and says plainly that the
names are not available. Set it to true and an answer can also list the people
in one state, bounded at 200 to a page, always beside the count of how many
there are in total — a page can never change a figure.
Two things that setting does not do, because both are easy to assume. It
does not decide what a name is made of: that is the hidden flag on your
directory's own columns, so an installation that publishes staff names lists
names, one that publishes employee ids lists ids, and the answer tells you which
name and identifier columns it withheld. And it does not close off your directory — a
question that groups by a name column still returns those names, exactly as it
did before, whichever way this is set. What it governs is narrower and is the
part worth a decision: whether an answer may attach a name to a statement about
what that person did or did not do.
If you set disclosure.perPersonFloor, do not rely on it to keep a small
group's names out of these answers. It covers these counts only on a source
that declares distinct-people counts (of the shipped sources a participation
question can reach, GitHub, not Jira or GitLab), and never the two unknown
states, whose people are listed at any size. A question over a longer window
can therefore name a group that a shorter window withheld. limits.md, "What
the min-N floor does and does not withhold", has the detail. If names beside these counts are not acceptable, leave
participationDetail off.
Admin → Definitions asks which job you are doing, not which kind to pick
The page opened on a JSON editor. Name, Also called, Summary, then a
Kind menu offering tuples, reporting line, identifiers, document and
ownership — the names this software uses internally — a Context stream
box, and then a Content box already filled with a worked example,
{"fields": ["job_family"], "tuples": [["Software Engineering"]]}. Below that
sat a second panel asking again for three of the same things, and the words on
the page were not the words in these pages.
It now opens on three jobs.
- Who counts as a developer — a spreadsheet of the job families, profiles and titles that count. Prism counts what it would select in your directory before anything is stored, and it can go live at once.
- Who owns what — your owner, area and product list. Each owner you can name a directory key for becomes a group for their area and for each of their products; a row with no key is recorded and adds nobody. On this version you write the table on the page: the CSV import that connects owners to people for you has no screen yet.
- What a word means — an acronym, a synonym, the real name of a product.
A context document is now written as prose. Five boxes — the word, what
else it is called, one line, what it means, and a caution — where before you
typed {"body": "", "defined_terms": [], "caution": ""} and filled it in by
hand. This is the kind that needs no counting, so its preview is instant — it
is still previewed and activated from its row, like every other kind — and it
is the quickest thing a new installation can usefully teach Prism: what your
acronyms mean.
The next step is in the row. Saving a draft told you to preview it and then activate it, and both buttons were inside History — a per-name panel rather than the list. Saving did open it for you; coming back to a draft later, or picking up one somebody else had left, meant opening it by name to find out what to do next. A draft's row now offers Preview, and once previewed, Activate, whether or not you were the one who wrote it.
The words on the page are the words in these pages. What the menu called
tuples is a classification here and is called a classification there now;
ownership is an ownership table; document is a context document.
The JSON editor has not gone. It is behind Advanced, for revising a definition and for shapes the two forms do not cover — Who owns what is still written as JSON, so it is a job on the page rather than a third form. What has gone is being shown it before you asked. Every kind's starting shape there is now a filled-in worked example the store accepts: four of the five were blanks it refused the moment you pressed Save.
Nothing about what a definition does has changed: the same kinds, the same counts and the same revision history. The store refuses what it refused before, and the page adds two refusals of its own:
- if the page had listed a definition and it was saved again after you opened the form — by another administrator, or by you in another tab — Save asks you to reload and apply your change to the new revision rather than writing over it. It is the form's own view that counts: a definition this page never listed, or a revision written while a listing was failing, is not one it can tell you about;
- a word that is live as another kind is refused by name, and points you at Edit on its row, instead of drafting a second kind over it.
In thick mode, a refused Oracle credential now says a long password may be why
If your installation runs oracle.mode: thick, a refused Oracle logon now
names a second cause. Oracle Instant Client — the 19.x line the images carry —
refuses a password longer than 30 bytes with ORA-01017, which is the same
code, and the same message, as a genuinely wrong password. Thin mode accepts the
same password. So an installation that switched to thick mode could be told its
working credential was wrong, and the obvious next step — rotating the
password — did not help.
The refusal now says so, and tells you to check the password's length before
rotating anything. It appears in all three places Prism opens an Oracle session:
the connection test on Admin → Sources, the ingest run's failure reason
for a snapshot or mirror stream, and an answer from a query-through
source, which the semantic MCP serves in place. The ingest run's is a shorter
wording than the other two, because that one is stored truncated; A password
longer than 30 bytes is the
whole of it wherever you read it.
It appears on every thick ORA-01017 refused at logon, including a password
that really is wrong. That is deliberate. Prism does not look at your
password's length to decide which sentence to print — a message chosen by a fact
about a stored credential would tell anyone reading the verdict something about
that credential — so it names both causes and gives you the test that separates
them rather than claiming either. Nothing in the driver's refusal distinguishes
the two, which is measured, so no better sentence was available.
No thin-mode refusal gains this clause, and thin is the default — what an installation with no Oracle source requiring Native Network Encryption should stay on. One thing did change there: the ingest run's wording for a refused Oracle credential is shorter than it was, in both modes. It had been running past the length a failed source's reason is stored at, so its last sentence was being cut off before anyone could read it; it now fits. The facts it carries are the same. A password longer than 30 bytes has what was measured — bytes rather than characters, the 19.x client line rather than thick mode as such — and how to tell a capped password from a wrong one.
Jira answers and live GitHub drill-downs now say the default scope did not apply
Two more kinds of answer now carry the line that says they are org-wide. 0.14.0 introduced the installation's default scope, and with it a line on the answers that cannot apply it:
org-wide: this installation's default scope does not apply on this path
It reached the GitHub index and the two internal leaderboard endpoints. The 0.14.0 notes named the two lanes it did not reach — Jira index answers (story points and issues completed, by assignee, project, sprint or day) and live GitHub drill-downs (the individual pull requests behind an answer, and one pull request read in full). Both now carry it: on every answer, on every refusal they return — an unusable argument, a repository outside your allowlist, the live page and drill-down caps — and on text passed back from GitHub.
One limit, so it is not a surprise. A question that FAILS carries no payload fields at all, this line included — whether it was stopped before an answer was built (a date Prism cannot parse) or failed while building one (a database it could not reach). Both are reported by the server framework rather than by the tool, and both reach you as a failed question rather than as an answer. It is the same on the GitHub index and is not new; what is new is that it is written down.
Nothing is configured and nothing has gone wrong when you see it. Those lanes read their index, or GitHub, directly — without the identity crosswalk that turns a leader's reporting line into a set of Jira assignees or GitHub logins — so they are org-wide by design. The line is there so that an answer nothing narrowed cannot be mistaken for one that was, in a conversation where the answer before it was scoped.
It sits beside each lane's own scope line, not in place of it. A Jira answer already says which issues it counted, and names the assignee filter where a question asked for one; a live GitHub answer already says which repositories this installation allows. Those can narrow an answer and they are not the default scope, so they stay separate fields with separate wordings — and the Jira one is the reason this could not be one line: it is replaced on a filtered call, so a fact true of every call would have vanished from exactly the answers a reader is most likely to misread.
Limits is the current list of where the default scope does not reach, and there are now no lanes on it that do not say so.
Two install pages sent you to controls that do not exist
Set a default scope for every question said to open Admin
→ Definitions and choose Default scope, and Upload who owns
what described an upload you make on the page. Neither
control exists. Both are admin API calls, with a super-administrator session,
and both pages now show the call and say plainly that there is no page control
yet. The 0.14.0 entries that announced them carry the same correction. Nothing
about either feature changed. The comment on oracle.mode in values.yaml also
stopped calling thick mode harmless: it names the exception to check before you
switch, a password longer than 30 bytes, as
the Oracle pre-flight
does.
Admin → Definitions: a link you could not see, and a dead button that looked live
Two presentation defects on Admin → Definitions, both on the panel that imports a classification from a file.
The link to the upload instructions was almost invisible. How to prepare
the file is the one route from that panel to Upload a classification of your
people — the page that explains what a row of the
file means, what each of the four counts in the preview is, and why
unclassifiable is published beside people. It had no style rule of its own,
so your browser drew it in its own default blue, and in a dark maroon once you
had followed it. On this dark panel that visited colour measures 1.6:1, which
is not "dimmer than the text around it" — the text around it is 5.5:1, and at
1.6:1 the link is very nearly not there at all. It is now the product's accent
blue at 7.2:1, and underlined, so it is both readable and recognisably a
link.
The "Import as draft" button looked available when it was not. That button is deliberately switched off until you press Check the file, and switched off again the moment you change anything in the panel — because Import writes exactly the file and mapping that were checked, and an edit made after checking has not been checked. It was drawn as the ordinary blue button at half brightness, which on this dark page is close enough to the live one that the difference is easy to miss — so the moment an edit switched the button off again did not look like anything. Off and on are now different shapes: the switched-off button has no fill and a dashed outline, so it is visible that Check the file has to come first.
Nothing about what the page does has changed, and no stored definition, preview count or refusal is affected.
A failing source no longer fails the ingest Job — and no longer makes it run twice
If one source fails, the ingest tick now finishes green. Until this release
it exited non-zero, and the CronJob's backoffLimit: 1 with restartPolicy: OnFailure turned that into a second complete tick, seconds after the first.
That second run could not retry the source that failed — it is already backed
off — so all it did was walk every other source that was due, again. (The
exception is an uploaded file, which has no retry backoff: a failing roster
was re-attempted on the restart as well as re-walked by it.) For a
database source that is a second read of your view within seconds of the first.
The source that failed was not the one doubled; its healthy neighbours were. And
the Job was marked failed whatever the second run found.
What a failed ingest Job means now, and it is a narrower thing than before:
- the tick itself could not run — it could not reach the database that holds its state, or it crashed or was killed. A restart can genuinely help here, so the CronJob still performs one. (A source another ingest is already walking is not this: that has always been a clean skip, and still is.)
- or a source failed and Prism could not write down why. The reason would otherwise exist only in a pod that the Job deletes, so the Job's own status is the last thing carrying it.
What it no longer means: a source failed. Where that is now reported depends on the source, and for the sources most likely to fail it is weaker than what it replaces. Read this part before you upgrade:
- the diagnostic bundle's
ingestblock carrieslast_failure.errorfor every source, with when it failed and why, as soon as the run ends. This is the one place that covers all of them, and for a source you registered yourself it is currently the only one. - the self test's SOURCES line calls a source failed once it has missed
ingest.stallAfterIntervalsof its own scheduled intervals — four by default, so 24 hours on the 6-hourly schedule. It covers GitHub, Jira and spend only. A source you registered from a recipe — GitLab, an Oracle or Snowflake view, an uploaded roster — has no line on that page and gets no verdict; prism #646 is the issue that gives it one. (Since 0.16.0 the self test's CONTEXT STREAMS section has a line for every registered source, and a switched-on source's line gives the reason for a recorded failure.) - the tick's own log carries the reason on the failing source's line each
tick. Not on the
doneline after the first one: the source backs off for an hour, so later ticks report itnot_due— with the reason appended.
Two things you may be relying on will stop working. Both are worth checking before you upgrade:
kubectl get jobs— a green ingest Job no longer means every source ran.- an alert on Job failures in the namespace, which Capacity and limits recommends. It still catches a wedged run, a run killed at its deadline, and a tick that cannot reach its own database. It no longer catches a source that simply fails, which for a registry source means nothing at cluster level catches it.
Why the trade is still worth it: what you lose is a signal that went red for
any failing source, every fifteen minutes, without saying which one or whether
the retry recovered — and that vanished with the pod. What you gain is that a
single failing source stops causing a second read of every other source's data
seconds after the first. If you need a cluster-level signal today, watch the ingest tick's
own output rather than Job status, if your cluster keeps it: each tick ends with
a JSON line whose ingest_tick is done and whose failed lists every source
that failed. The bundle's ingest block and the self test are not alert
targets — both need a super-administrator's session, and the self test covers
GitHub, Jira and spend only.
The reason is not yet on a page of its own — it is in the bundle rather than beside the source on Admin → Context streams. That gap is tracked as prism #1445 and is not closed by this change. (Since 0.16.0 the self test shows it, on the source's CONTEXT STREAMS line; Context streams still does not.)
0.14.0 (14 September 2026)
A source that cannot finish no longer empties its own tables every run
If one stream of a source failed, the next run deleted what the other streams had already copied — every time, for ever. A source that has never completed a run is treated as a first run, and a first run clears the tables before it starts, so that real rows never mix with the synthetic sample data. A source whose last stream always fails never records a completion, so every attempt looked like the first one again.
The effect on a source with one view that cannot be read inside its time limit: the other views copy, the last one fails, and the next hour deletes everything the others just copied. The source reported connected throughout, and the streams were offered to readers, while holding nothing. Questions against them came back empty rather than saying anything was wrong.
Now the first run records that it has taken the tables over, and later runs read that record: a source that cannot finish keeps what its own earlier runs copied and adds to it, instead of starting from nothing every hour. Nothing changes for a source that completes — and a first run still clears the sample data, which is what that behaviour is for.
If you have a source in this state, it will begin accumulating rows from its next run without any action from you. It will still not report the failing stream on the source page: that is a separate gap, and the run's own log names which stream failed and why.
Editing the source's manifest starts it over. A run whose document differs from the one the tables were taken over under clears them and takes them over again — the rows were written by a different document, and narrowing a stream's query is the usual remedy for a stream that cannot finish. [Troubleshooting] (troubleshooting.md) also has the one statement that starts a source over without editing anything.
One deliberate limit. The record is written only where the first run
actually clears the tables, so an installation running with
INGEST_TRUNCATE_ON_FIRST_RUN=0 — which is not the default — never gets one.
Nothing is destroyed on that path either way; it does mean that turning that
setting back on later clears the tables once, as a first run does.
Run now asks for one walk again, not one on every tick
Asking for a run once (POST /api/admin/sources/<id>/ingest/run-now) made the
source walk on every tick afterwards. The
walk it asked for ran at the next tick, as documented. That walk then erased the
record that it had honoured the request, so each later tick found the same
request apparently unanswered and walked the source again. It did this every
ingest.engine.tickMinutes (15 by default), whatever the source's own
schedule, until a run failed. Every one of those runs succeeded, so the source
read as healthy throughout.
For a database source this is load on your database, not a wrong figure. Measured on a weekly Oracle snapshot, one request walked it at two consecutive 15-minute ticks. Left alone, that is up to 96 reads a day of a view scheduled for one a week.
Now the record survives the run that honoured it. One request asks for one walk however many times the tick runs afterwards, which is what Troubleshooting has always said it does, and the source then returns to its schedule. Asking again later asks for another walk, as before.
If you have asked for a run on a source since 0.13.0, you need do nothing. It may walk once more at the first tick after the upgrade, because the record was already erased by the last run before it. That walk keeps the record, and the source then returns to its schedule.
A copy whose key is not unique is refused, instead of quietly holding fewer rows
Read this one if a context stream goes stale after upgrading. It is the most likely reason, and the fix is a line in your recipe.
A snapshot stream copies a view or a file and stores one row per key per
copy. If the key you declared is not actually unique in those rows, two rows
sharing it cannot both be stored — the second overwrites the first. Until now
that happened silently: the run reported success, the log stated the number
of rows it had read, and the copy held fewer. Every figure drawn from it was
then quietly low, and an assistant answering from it would call the short table
the full population, because nothing had told it otherwise. Measured on a real
installation: a view of 100 rows stored 98, and three other datasets lost one
or two each.
What changes. The run now compares the rows it read with the rows that landed, and refuses that stream's copy if they differ, naming the stream, the key, both counts and what to do. The refused stream's previous copy stays exactly where it is and it reads stale — the safe direction, because a stale figure is visibly old and a short one is invisibly wrong.
A refusal is per stream, not per source. If a source has forty views and one of them cannot be copied, the other thirty-nine are copied as usual; only the offending one is rolled back. The run as a whole is reported as failed, so nothing advances and the reason is recorded, but you do not lose a day of good data to one bad view — and you find out about all of the bad views at once rather than one per run.
Which faults are one stream's, and which stop the source. A stream is
refused on its own for anything that belongs to its own statement: a key that
is not unique, a view that does not answer inside
connection.database.statement_timeout, a view that is not there, a column
your SELECT names that the view has not got. A fault that belongs to the
source still stops the whole run at once — a credential the database
rejected, a listener that is not answering, a server that will not meet the
sslmode you asked for. The difference is whether the next view could
possibly behave differently: a slow view says nothing about the next one, and
a rejected password is the same password for all forty-one. (That second case
is also why they are not treated alike: presenting a rejected credential once
per stream would put forty-one failed logons on your database in a single run,
which on an estate with a login-attempt limit locks the account before you can
test the corrected one.)
What a copy date means when a run refused a view. A database source's
views are normally read inside one read-only transaction, so a dated copy
shows every view at the same instant — and every run that copies cleanly still
does. When a run refuses a view, the session ends with it, so the views
copied after the refused one are read a moment later than the views before
it, while every row still carries the same as_of. If you correlate two
views inside one dated copy, that date's copy is not a single instant across
them; the next clean run replaces it from one transaction. A single view is
never split: its own rows always come from one transaction. This was the
trade against the alternative — every view after the refused one not copied
at all, that day and on every retry. Copy a Postgres table or
view says the same where you set the recipe up.
Where to read the reason. It is recorded against the source, so you do not
need the ingest job's log to find out why. Since 0.16.0 the self test shows
it. Its CONTEXT STREAMS section has a line for every source in your
registry, not only the ones built into Prism. (If the registry itself could not
be read, there are no such lines, and the source registry line says why.) A
switched-on source with a failure recorded since its last completed run says
so on its line, with the first 160 characters of the reason. A switched-off
source's line shows no reason. A reason Prism composed itself, as
this refusal is, is shown as written; a raw error from a database driver is
shown as its type alone, because a driver's message can quote a value.
Admin → Context streams still does not show the reason: the source keeps
its usual word there, and nothing on that page says this run was refused. That
page is prism #1445. The whole of what is recorded, the first
300 characters, is in the diagnostic
bundle: download it from the self test
page and look in its ingest block, under the source's name, for
last_failure.error.
So a stream that was losing rows before the upgrade will now go stale rather than update, and that is the change working. To fix it, confirm the key against your own data —
SELECT count(*), count(DISTINCT (<your key columns>)) FROM <your view>;
— and if the two numbers differ, make the SELECT return one row per key
(GROUP BY, DISTINCT ON, or a filter that picks the row you want).
Widening the key instead is a bigger operation than it looks, and the refusal deliberately does not suggest it: the key you declared is the table's primary key, and adding a column to it does not change the table that already exists — the copy would then fail on every run with a message about a missing constraint. Copy a Postgres table or view has the procedure if widening is genuinely what you want.
The same applies to an uploaded roster with a repeated employee id, where the fix is the export: de-duplicate it and upload it again. The upload page already counted the repeats for you at upload time; what is new is that the scheduled run no longer loads a roster it would have to shorten.
An Oracle database that requires Native Network Encryption can now be connected
Many bank estates encrypt Oracle Net traffic with Oracle Native Network Encryption rather than with TLS, and require it. Prism's Oracle driver ran in python-oracledb's thin mode, which cannot negotiate NNE at all — so on such a database every connection was refused before a session existed. Nothing an installer could set made any difference: the credential, the service name and the network path were all fine and the source still never got a session.
Setting oracle.mode: thick in your chart values now connects those databases.
The images carry Oracle Instant Client, so nothing is downloaded and there is no
client to install. The chart value alone is the switch. A source's manifest may
optionally declare connection.database.mode: thick, which means "never
connect this source in thin mode" rather than selecting anything: a source that
declares it on an installation still set to thin cannot be registered, have a
manifest applied to it or have its connection tested, and every ingest run refuses
it. (Corrected in 0.16.0: this paragraph
first read as though the manifest value were a second required step.)
It is an installation-wide setting and cannot be set per source, which is
the driver's constraint rather than a choice: python-oracledb fixes thin or
thick for a whole process at its first connection and never revisits it. In
thick, every Oracle source in the installation connects through Instant
Client — including sources whose databases never required encryption. For those,
thick mode connects exactly as thin does with one exception you should check
before switching, below. Either way the switch is one decision for the
installation rather than one per stream.
Before you switch: a password longer than 30 bytes stops working. The
Instant Client in the images (19.30) refuses an Oracle password longer than 30
bytes with ORA-01017 — the same code, and the same message, as a wrong
password — while thin mode accepts it. Because the setting is installation-wide,
this can break a source that connected in thin mode for months and never needed
encryption. Rotating the password does not help unless the new one is 30 bytes
or fewer. It is a property of the 19.x client line rather than of thick mode:
19.30, 19.31 and 19.32 all refuse, and the 23.x client accepts. Count bytes,
not characters — a character outside ASCII takes more than one byte, so a
30-character password can be refused. Pre-flight an Oracle view before you
connect it has how to tell
this apart from a wrong password. 0.15.0 names this cause beside
ORA-01017 in the verdict itself; on 0.14.0 the verdict says only that the
credential was not accepted.
All three paths that open an Oracle session honour it: the source probe, the
ingest walk (a snapshot or mirror stream built on an Oracle query) and the
semantic MCP, which is what answers a question against a query-through source
in place.
One statement-timeout fix travelled with it. A statement cancelled by the
timeout Prism arms reports one of two driver codes depending on where it was
cancelled, and only one of them was recognised. The other was treated as a
transient and retried five more times — each attempt costing your database the
whole timeout again — before the run gave up. Both are now reported as what they
are, once, with the advice about statement_timeout and Resource Manager that
the recognised one always carried.
Leave it alone unless you need it. thin remains the default and is what
every installation before 0.14.0 ran. The symptom that says you need thick is
DPY-3001 on an Oracle source's verdict; the pre-flight script also finds it
before you register anything, and now retries in thick mode by itself to prove
which it is. Pre-flight an Oracle view before you connect
it has
how to tell, what to set, and the TCPS listener alternative — thin mode connects
to those, so an estate that can offer TLS needs none of this.
The agent works through a question in four phases
The instructions in front of every question now open with a procedure: orient, plan, elaborate, respond. The agent first asks what this installation is connected to and reads the one-line account of each dataset; then states which quantities the question needs and which datasets carry them, and asks for the detail of those datasets only; then goes deeper one step at a time, reading the allowance each response reports and stopping when the plan is satisfied; and only then answers. The procedure names no particular source, so it holds for a warehouse an administrator connects next week as much as for the sources that ship. It relies on two changes to the semantic query tools in the same release: a per-dataset summary on the source listing, and a dataset selector with a per-turn allowance on the schema call.
The agent is told about the live-GitHub tools only where it has them
The live-GitHub tools — the ones that read one pull request's reviews, files
and timeline straight from GitHub — are optional: mcps.githubLive.enabled in
the chart, off by default. The instructions the app puts in front of every
question described them regardless, about a thousand words a turn on an
installation that did not have them, including "you may answer from live
GitHub" as the route for a truncated window. Those paragraphs now appear only
when that setting is on. When it is off, the instructions say instead that
there is no second GitHub backend on this installation, that a truncated window
is a floor and stays one, that a pull request by its number, its title, its
draft state and the reviews it collected are answered from the GitHub context
stream through the semantic query tools, and that only a review's text, a pull
request's files and its timeline are held nowhere here. The four-phase
procedure above is likewise sent only where the semantic query tools are
attached. Nothing in the chart changes; an installation that has the tools sees
the same instructions as before.
The same instructions now spell out how a question about a set of people is scoped: the three depths a reporting line can take (direct reports, two levels, or everyone under a leader), that leaving the depth out is read as direct reports, the set-of-values and named-definition shapes, how two shapes compose for an intersection, and when the column the people are matched on has to be named. Before, the instructions showed the depth as a placeholder and named none of those keys, and a question about "everyone under X" could come back about X's direct reports under the wider heading.
Credential reach is measured on the manifest engine too
The credential reach line on Ingestion activity — the one that tells you
a GitHub credential now reaches more or fewer repositories than it did, names
the days walked under the old grant, and otherwise says checked … and
unchanged — was measured on the legacy GitHub lane only. With
ingest.engine.mode at its default of authoritative the manifest engine
walks GitHub instead, and the engine did not measure, so on a default install
the whole of that disclosure never fired.
The engine now measures. Before each walk it makes one call per organisation
(GET /search/repositories?q=org:<org>&per_page=1, reading only the count),
compares it against the baseline the previous completed run stored, and on a
completed run records the same baseline, the same notice — widened, narrowed,
replaced, baseline reset — and the same checked stamp the legacy lane does.
After the second completed run on this version a default install reads
checked … and unchanged; the first records the baseline and says nothing,
as it did on the legacy lane. The probe never fails a run: a GitHub that does
not answer it leaves the stored baseline standing and the walk proceeds. The
call is counted on the page's request meter under its own endpoint.
What the lane sentence means. Not yet measured on this lane, in the
gap colour, is drawn only where the engine walks GitHub and no reading is
recorded — which on this version means an ingest image older than 0.14.0
behind an upgraded app, or a probe that has not succeeded since the upgrade.
It names the version so you can read your own tag against it. If you held
GitHub on the legacy lane (ingest.github.engineMode: "off") to keep the
measurement, that is no longer needed for this reason, and flipping back is
safe: the engine's first run compares against the baseline the legacy lane
left, and with the same token and the same reach it reports nothing.
The probe runs for every source whose recipe is GitHub — the shipped github
stream and any instance you registered from that recipe under a name of your
own — and the page asks the same question by recipe, so such an instance shows
its own credential reach line.
A table or view in your own Postgres can now be copied into Prism on a schedule
Prism can now take a dated copy of a table or view in a PostgreSQL database of yours — the shape for a source that knows only "now", such as a mirror of a ticketing system, and whose views are too expensive to read live. There is a recipe for it under Add a context stream (Service desk (Postgres copy), a fictional worked example you replace with your own view and columns), and a page, Copy a Postgres table or view, that walks through what to ask your DBA for, the connection values, what each line of the recipe is, and what the probe proves.
How the copy is taken is the part worth knowing. Prism opens one read-only
transaction pinned to one snapshot, runs the recipe's one SELECT, and
streams the result through a server-side cursor into its own memory — so a
view over a dozen joins is evaluated once per copy however many rows it
has. The other two strategies the recipe can name, keyset and offset paging,
re-run the statement per page; measured on a real Postgres that is one
evaluation against eleven for a hundred rows at ten a page, which is why the
recipe's default is the cursor. Your database sees one statement per scheduled
run and nothing between runs, bounded by the statement_timeout you set — a
ceiling that covers the whole copy, every fetch from the cursor under one
clock, and that is reported as the ceiling whether the recipe set it or your
login's role carries one. A session your server ends between fetches
(idle_in_transaction_session_timeout) is named as that, not as a fault in
the recipe; the page says what to ask your DBA for.
Every copy is kept for as long as you say, and a question reads the latest copy unless it says otherwise: the recipe's model pins it, so a ticket is counted once and not once per day it was copied. A question that means "between copies" lifts the pin by naming the field; the page shows how.
What arrives lands as the recipe's five column types say: a numeric keeps its
digits, a timestamptz its instant, a timestamp without time zone is read in
the zone you declare (with the same refusal of a wall clock inside a DST gap as
every other database source), a uuid lands as its text, an array or a
composite as one JSON document, a bytea as hex text, a range as Postgres's
own form, and an interval as an ISO 8601 duration — with months flattened to
thirty days before Prism sees the value, so cast the column to text if the
months matter. Postgres folds an unquoted column name to lower case, and so
does the copy: a mixed-case column has to be quoted in the recipe's own
SELECT and aliased in lower case, or the copy refuses it by name rather than
writing an empty column.
A Postgres context stream can now be probed, and so enabled
A source whose connection.database.engine is postgres could be registered
and never enabled: the probe that enable demands a passing verdict from
refused the engine by name, because nothing in the app opened a session to a
Postgres that was not its own. It does now, with the driver the app already
carries. The probe opens one session with the row's own connection values —
host and port or a DSN parameter, the database, the login, the schema, an
optional role — sets the session up exactly as the ingest will (a statement
ceiling, read-only, UTC, the role, the search_path) and reads back who it is,
where it is and which schema it landed in. It proves the server answered, the
credential was accepted and the session is the one asked for. It does not
prove any table exists or is readable; the first ingest run finds that out.
Two things to know when you write the connection block. sslmode follows
libpq's meanings and defaults to require when absent: encrypted, not
verified, and never a clear-text session because the line was left out
(verify-ca/verify-full need a root certificate the appliance does not
carry, and are refused before a dial without one; write disable to mean
clear text). And Postgres accepts a search_path naming a schema that does not
exist and silently resolves past it, so the probe asks for the schema exactly
as you spelled it — quoted, so Sales is Sales and not sales — compares
the schema it landed in with that, and refuses on a mismatch; a role without
USAGE on the schema reads as "resolved to nothing", which is what it is.
A kind: query-through Postgres source is still refused at the probe, by
name: this release has no Postgres executor separate from Prism's own
datapond, so such a source could not be answered from where its tables are.
Register the same connection as a snapshot to copy it instead.
The ingest driver that copies such a source into the datapond is the second half of this work and ships separately.
Discovery scales to a source with dozens of tables
When the agent starts a question it asks the semantic context stream server
what is connected (list_sources) and then for the model of a source
(describe_source). The second call described every dataset of the
source at once: fine at the nine datasets the shipped streams carry, and not
at the forty-eight tables a customer's own warehouse connection can bring —
one realistic table describes to about four thousand tokens, so forty-eight
would end the turn before a single row was read.
describe_source now takes a dataset argument — one name or a list — and
describes only those, naming the rest in datasets_not_described. Each turn
has a schema budget beside the drill budget it already had: at most twelve
describe_source calls describing at most sixteen datasets in total, echoed in
every response as schema_budget, and a call that would exceed it is refused
with the source's dataset names so the next call can choose. list_sources
now lists each dataset as one line (its name, its grain in a sentence, its
stream, how many fields and metrics describing it would cost, whether it
correlates to people) where it listed bare names, which is what makes choosing
possible; the self test's inventory reads the names off either shape. The
listing itself is unchanged in cost: it still reads the datapond one dataset
after another on the one connection it holds, which on Prism's own tables is
the fastest way and the only one that keeps to the connection budget in
Limits.
Nothing changes for a source the budget fits: a call with no dataset
describes the whole source as before. The agent's own instructions are being
updated to plan from the summaries and describe selectively.
A population's size is always one Prism measured
A definition that selects people — a reporting line, a classification, a
list of identifiers — carries the number of people it matched, and answers
quote that number beside the definition's name. Until this release the number
for a reporting line came from whoever previewed the definition: the preview
call accepted a counts figure and stored it in the same field a measured one
occupies, so a super administrator could publish an invented size. Answers
already refused to print such a figure and said "size not measured" instead;
that same refusal, though, applied to every population an ownership table
compiles, because the compile handed its own measured figures to the store the
same way. Every population an ownership table produced read "size not
measured" in answers although each had been counted.
Now a reporting line is counted by Prism itself: a population compiled from an
ownership table takes the figure the table measured when it was previewed, and
a reporting line written by hand is walked by the semantic query tools when it
is previewed. A counts figure sent with a preview is refused for every kind
that selects people. Answers about a compiled population now print its size
("1,874 people when it was activated on 1 September 2026"). A list of
identifiers cannot yet be counted on this release and its preview is refused
with a sentence saying so; the counter for it arrives with the release that
gives the engine a statement for that kind.
The agent is no longer told to call a tool that does not exist
Since Prism's own user directory was retired (in 0.10.0), the
instructions the app puts in front of every question kept naming the tool that
read it, resolve_user_directory, as the way to turn a person's name into a
login — seven times a turn — and kept describing three mechanisms that went
with it: a roster lookup on the GitHub statistics' team argument, a
roster-age reading beside per-person figures, and a name check on per-person
spend figures. None of those exists on an installed appliance; the model was
being told to reach for them and finding nothing there.
Those instructions now say what is true on this release: a name is resolved
with resolve_people, which reads the designated organizational context stream
and nothing else; a team, group or office is answered through that stream, or
refused with a request for the people meant when none is designated; the team
argument on the GitHub statistics refuses rather than expanding a roster; and a
per-person spend figure carries the account id its own response echoes, never a
name the model supplied. Nothing else in the instructions changed. On an
installation with no organizational context stream designated, a question that
names a person or a team is now answered with a plain refusal naming the
missing designation, where before it could be an instruction the model could
not carry out.
A classification can be uploaded as a CSV, and counted before it is activated
Since 0.13.0 a classification — a named list of accepted combinations over your directory's own fields, the definition that says who counts as a developer — could be written on Admin → Definitions only by typing its content as JSON. The file this kind exists for is 4,290 rows. Nobody converts that by hand correctly, and a mistake in it was silent: a transposed or truncated paste validated, activated, and reported a plausible-looking number of people.
Admin → Definitions now has Import a classification from a file. You choose the CSV, say which of your headings holds which directory field, and press Check the file. Prism reads it with the same reader the staff-list and ownership uploads use — the same refusals for a UTF-16 export, a NUL byte, a repeated heading, a missing column, a ragged row — and refuses a blank cell by row number, a mapping key the directory does not declare, and a file with no rows. Nothing is stored at that step. What comes back is the row count the reader found beside the number of combinations kept, and the four numbers the classification's own statement computes over your directory: people selected, people in the directory, people no combination names, and combinations naming nobody. Import as draft writes the revision with those four stored beside it; activating it is the same button as before.
Read the row count against your spreadsheet before you import. A file cut off on a line boundary is a valid file of fewer rows, nothing can refuse it, and the count is what tells you.
The counter is the store's own, so the numbers it stores are the numbers
every answer reads back as a measurement — the index publishes them, the
population sentence quotes them, and an export prints them. It counts a
classification typed as JSON on the same page too, against the same
designated directory: the Preview button that used to answer "nothing here
can count a tuples definition yet" now counts, and a preview that hands the
store its own numbers for this kind is refused, as it already was for an
ownership table. The same grammar checks a typed one at the moment it is
saved, so a field bound twice or a nine-field definition is refused when it is
written rather than when it is first asked about. Upload a classification of
your people is the page. The count runs only
against a directory whose rows are in Prism's memory; a query-through
directory is refused with the reason, as the semantic query tools already
refuse to scope a population against one.
A ranking now carries the sum of the rows it shows
Across nine answers exported from a 9 September install, every figure the tools returned reconciled exactly, and five of the nine documents stated a wrong number anyway. In each case the answer had added up a column already printed in its own table: "these 20 together merged 573 of 1,451 (39%)" over rows that sum to 537; "the L5 rows sum to 562 … 2 higher than the org figure" over rows that sum to 560, the org figure exactly, followed by a paragraph attributing the 2 to a reporting-line ambiguity that does not exist. Nothing in the results carried the sum of the rows actually shown — the org-wide figure is over everybody, rows past the limit included — so the stock sentence for any top-N answer had no route except the model adding the column up.
Every grouped result from the GitHub and Jira statistics and from the semantic query now carries, beside the org-wide figure, the subtotal of the rows it returned: how many rows, what their column sums to, that sum's share of the org-wide figure, and a sentence saying whether the two reconcile — "the 20 rows sum to 537, 37% of 1,451; the difference, 914, is held by authors past the limit, and not a finding about anyone in these rows" — or, where they match, that a discrepancy arrived at in prose is an arithmetic slip and not a finding about the data. A median or an average is said not to add and gets no sum; a figure withheld under the disclosure floor anywhere in the answer — on a row, in the rows no group holds, in the org-wide figure, or in a small group the row limit cut — gets no sum, share or remainder, so the floor cannot be undone by subtraction; rows that overlap (an issue under each of its labels) say so. The model's standing rule about derived figures now names sums, subtotals, shares and min/max over a column, tells it to state the returned subtotal rather than add the column, and says a discrepancy between rows and a total may be stated only when the result itself says they do not reconcile. Like the sentence below, this reaches the model in the tool result; it is not part of an exported answer's footer. No figure changed.
Ask who is responsible for something, and get the people
Uploading an ownership table already created a group for each area and each product in it, so "how much did the Ledger team ship last month" worked. The question that did not work was the simpler one: "who is responsible for Ledger?"
It could not, and the reason is worth stating because it is not an oversight. The groups an ownership table creates are everyone under an area's owners, and they deliberately exclude the owners themselves — so the one fact the table is most obviously about was the one fact no group contained.
A reader can now ask. The answer gives the owner's name as your own spreadsheet spells it, alongside the name your directory holds for the same person, and it names the table, the revision and when that revision was activated. It answers for products as well as areas.
A name we could not place is an answer, not a blank. If your table names somebody the directory does not hold, the answer says so — the table names this person for Reconciliation, and this installation could not identify them — and it is careful to be a different sentence from "no table names that word". The first means your file has a name to correct. The second means nobody has told Prism who owns it. Neither means the area has no owner.
That distinction reaches down to the group as well: an area whose only owner could not be placed has no group, because a group with no members would answer questions with a confident zero. The owner is still returned. The missing group never silences the uploaded claim.
What comes back, and what does not. The owner's name and, where your
directory allows it, their directory key. Email addresses do not, wherever
they sit: if your directory is keyed on email the key is withheld, and if your
own table spells its owners by address then the address is replaced with
[email address withheld] in the name and in the sentence alike, with a count
of how many were removed. Your people's addresses do not enter a chat answer
because somebody asked who owns a product.
The per-person figure floor does not apply here, and that is deliberate rather than an omission: the floor exists so a figure cannot be traced back to an individual, and this is your own document saying who someone is.
Upload who owns what has the three answers and what to do about each.
The org-wide figure now says, in words, what it is over
In a 9 September export the answer told the reader that the 2,130 beside
overall was "the total merged PRs for these 20 authors only (it is filtered),
not the org-wide total". It was the org-wide total: no author filter had been
applied, the figure's own name (merged_prs_all_authors) said so, and the
twenty rows summed to 734. The model read past the name, and fifty-one minutes
later read the same field correctly.
Every overall block from the GitHub and Jira statistics now carries a sentence
beside the figure saying which population it is over, written for each call:
on an unfiltered question, "is over every author in scope, not only the 20
rows in the table: authors past the row limit are counted in it too, where
there are any"; when the question named people, "is over the 8 authors this
call was filtered to, and about nobody else — not over everybody in the
window". A figure withheld under the disclosure floor is described the same
way without a number. The sentence sits beside the figure in what the model
reads, which is where the misreading happened, and — since the entry below —
in the exported answer's footer. Nothing about the figures changed.
The footer now carries what the figure was over, in the source's words
The sentence above, and the one the GitHub statistics have carried since the installation scope arrived ("org-wide: this installation's default scope does not apply on this path"), reached the model's tool result and nothing else. A reader checking an exported answer saw the model's own prose about which population a figure covered, not the source's sentence — the same trust gap the entry above closes, one surface further out.
Both sentences are now recorded off the tool's reply and printed in the
"Freshness / scope" cell of the answer's footer, on screen, in the PDF export
and in the feedback page's Markdown export: overall: … beside scope: …,
and the installation-scope line verbatim. They are read from what the tool
actually sent, never inferred: a tool that publishes neither (the Jira
statistics publish no installation-scope line; the support-ticket statistics
publish no overall sentence yet) shows nothing for them rather than an empty
line. Where one source was asked twice in a turn and its org-wide figure was
over a different population each time, the cell prints the first and says
there was another, exactly as it already does for scope. A reply too long
for the record to keep whole loses the overall sentence with the rows it
follows; the installation-scope line leads the payload and survives.
One thing to expect: the GitHub statistics publish the installation-scope line on every reply by design, whether or not your installation has set a default scope, so every footer row from that source now carries "org-wide: this installation's default scope does not apply on this path". On an installation with no default scope that is still true — nothing narrowed the figure — and it is the same sentence the answer's Source column already quotes.
The ingest engine's deadline guard now knows what a capped day costs
Before a GitHub run starts, the ingest checks that its request ceiling
(ingest.github.maxRequestsPerMinute) and its run deadline
(ingest.runDeadlineSeconds) can make progress together: progress is recorded
per completed day, so a configuration in which no day can finish inside the
deadline records nothing, run after run: an install that has never completed a
run reads each one as a first run, and an install with a completed run behind
it stays where that run left it. The check refuses to start when even an empty
day cannot finish, and warns when a busy one cannot.
The warning's idea of a busy day was ten requests per stream. It has been wrong for as long as it has existed: since 0.9.0 the engine halves a day that trips GitHub's thousand-result ceiling down to fifteen-minute windows, and 0.13.0 added a third stream that halves and a re-read of truncated review lists. A day that trips the ceiling on every stream costs the engine 5,214 requests per organisation, and the warning could not have fired below one request every 48 minutes. It is now derived from the same subdivision arithmetic the walk uses, plus a bounded term for the re-read of truncated review lists, and it says on the line that the figure is a bound on the worst day, not a forecast.
What that means at the shipped defaults. With thirty requests a minute and a 24-hour deadline, the worst day for one organisation needs about 2.9 hours, so a default install with up to eight organisations sees nothing new. From nine organisations the warning appears on every run, and it is a true statement: nine organisations that all trip the ceiling on all three streams on the same day cannot finish inside 24 hours. If you see it, read the line for the number of requests and decide whether such a day is one your organisations can have; quiet days still complete and the walk still advances either way. Nothing about what is refused has changed.
The profile sweep's warning fires only when an address was refused, and says why
Two log lines from the GitHub profile sweep, and one sentence in the install
documentation, said the wrong thing about ingest.github.identityEmailDomains
— and said it in opposite directions, so an operator debugging identity
coverage was told both to change that key and that it does nothing.
The warning. A sweep step that reached members who had published no
address at all — one member, on a rolling pass near the end of its list — was
followed by a warning that "every address looked up was dropped" and that the
domain allowlist may be excluding the domain your members use. Nothing had
been dropped. The warning now fires only when addresses were actually
disclosed and none was kept, and it names the reason from its own counters
(domain_not_allowed took 3 of 3 (the whole of it)) rather than asking you to
read the drop counts yourself. A step whose disclosed addresses were all
no-reply ones says so and does not point at the allowlist, because widening it
would keep none of them.
The coverage line. identity coverage: … members asked and nothing kept: no_email=1 (cumulative over every member ever swept) was not cumulative over
every member ever swept: reasons have been recorded per member only since
0.11.0, so the count is over the members the rolling pass has revisited since
then, and the line after it said how many were outside that. The scope claim is
now true on its own line — (cumulative over the 1 of 11772 member(s) whose sweep recorded a reason). Nothing about the count changed.
The documentation. The ingest.engine.mode description in values.yaml
and Install parameters, and two 0.10.0 entries below, said the GitHub identity
harvest does not run in authoritative mode and that identityEmailDomains is
inert there. That describes the commit-author harvest, and only that. The
profile sweep — on by default since 0.11.0 — runs on the ingest tick in
shadow and authoritative alike (install-wide off renders no tick, and so
no sweep) and is narrowed by the same setting; on an install in
authoritative mode whose organizational context stream carries no GitHub
login, it is the only route from GitHub activity to a person. The
sentence is corrected where it is generated from, the historical entries carry
a dated correction rather than a rewrite, and How Prism reads GitHub and
Flip a source to the ingest engine say the same thing.
One estimate is also described more honestly rather than changed: the GitHub walk's per-day request bound now states that it does not count the engine's overflow reads, and why (they never run on the lane it guards). The engine's own guard is the one that understates, and that is tracked separately.
The manifest test refuses a verdict larger than 256 KB
Testing a manifest from the admin surface had no bound on the response the App would read: it asked the semantic query MCP for a verdict and buffered whatever came back, so a pod on an older or a faulty build could hand the App an unbounded body. A verdict larger than 256 KB is now refused with a sentence saying so, and the body is never read past that point rather than being read and then measured.
A legitimate document does not approach this. The largest a verdict can be is about 125 KB, at the 200-statement bound that limits what one document may ask for — and that is a document whose every statement failed, since a refusal carries the driver's message and the repair material while a pass carries neither. A passing verdict for the same document is about 12 KB.
If you meet this refusal, the fix is a smaller document — fewer datasets, fields or metrics — not a larger cap. The same bound already applied to the two other places the App reads an MCP's answer; this is the third.
All three of those reads now ask for an uncompressed answer, and refuse one that arrives compressed anyway. This is a memory bound rather than a preference: a compressed body is decompressed before its size can be checked, so a few tens of kilobytes on the wire can become tens of megabytes in the App's memory — measured at over a thousand to one on repetitive content. The three requests go to the semantic query MCP inside your own cluster, where compression saves nothing worth having against answers this small, so nothing is lost by declining it. You do not need to configure anything; if you see a refusal naming a content encoding, something between the App and that pod is compressing responses and should be asked not to.
Ask for a figure per job family, per business unit, per manager's org — in one question
A question can now be broken down by a field of your organizational context stream, not only by a column of the data being asked about. "Merged pull requests per job family", "median review time per business unit" — one question, one table.
Before this, that answer took one question per group. A reader asking for pull request counts across thirteen leaders' organisations asked thirteen questions and assembled the table themselves; adding a second measure made it twenty-six.
What you need for it. An organizational context stream designated on Admin → Context streams, and enough of your people matched to the data being asked about — the same crosswalk that already scopes a question to a leader's organisation. Nothing new to configure.
Every group says how many people are behind it. Each row carries people
(how many people are in that group) and resolved (how many of those Prism can
identify in this data source), so a small figure that means "a quiet team" can
be told apart from one that means "we could only match two of them".
Three rows are not job families. Facts Prism cannot attribute to anybody
appear as (unresolved); people it identified but your stream does not list
appear as (not in the directory); and where your stream gives one person two
different values in the same snapshot, (the directory disagrees). They are
rows rather than silently dropped facts, so a breakdown's parts still add up to
the whole and you can see what is not placed.
A group with nobody in it is still a row, with its figures at zero. "Nobody in Operations merged anything" and "Operations does not exist" are different answers and only one of them is usually true.
If you have set disclosure.perPersonFloor, read the next section — it now
applies to these breakdowns, and it tests a different number on them than it
does elsewhere.
The per-person floor now covers a breakdown by an organizational field
Only if you have set disclosure.perPersonFloor to something other than 0. On
a default install nothing changes.
A group of one person is that person's figure with a job family's name on it, so
the floor you set now reaches these breakdowns too. On them it compares your
number against the distinct people behind the figure rather than the
observations behind it — because one person with two hundred pull requests
clears any observation count and is still one person. Withholding figures about small groups: a floor you set in Limits and known
gaps has the
two triggers side by side and which column carries which.
A withheld group keeps its row and its people and resolved counts, so a
table never quietly loses its smallest units. (unresolved) is never withheld —
there is nobody behind it to protect, and it is the number that tells you how
much of the answer is missing.
A fresh install no longer restarts agent-runner and synth-seed while the bundled Postgres comes up
On 0.13.x and earlier, a fresh install with the bundled Postgres showed one or
two restarts on agent-runner and on the synth-seed Job in its first minute:
both run a schema migration at boot, and on a fresh namespace that boot landed
before Postgres was accepting connections. The install converged on its own,
but a first kubectl get pods looked like a broken deployment, and nothing said
otherwise.
Both workloads now carry an init container, wait-for-postgres, that holds
them until the bundled instance answers. The chart adds no image for it: it
runs pg_isready from the Postgres image you already mirror — which does mean
the runner's and the seed's nodes now pull that image, the largest in the
release, before the wait begins. During the first seconds the two pods read
Init:0/1 instead of restarting, and RESTARTS stays at 0 —
Troubleshooting shows what the normal first
minute looks like now.
The wait is bounded by a new value, postgres.startupWaitSeconds (180 by
default). If the bundled Postgres has not answered by then, the init container
stands aside and the main container starts and fails the way it did before, so
a Postgres that never comes still surfaces as the refused-connection error the
troubleshooting guide names rather than as a pod stuck initialising. Raise the
value on a cluster where a first boot takes longer than that to bind a volume
and pull the image; above about four minutes raise helm install --timeout
(5 minutes by default) with it, because the synth-seed Job is a post-install
hook and helm stops waiting for hooks at that timeout.
On OpenShift the shipped default postgres.bundled: true now fails earlier and
differently: the init container runs as the Postgres image's own UID, which the
restricted-v2 SCC refuses, so agent-runner and synth-seed are refused
admission instead of crash-looping. The remedy is unchanged — the bundled
Postgres never started there either — set postgres.bundled: false.
Nothing changes for an external database (postgres.bundled=false): no gate is
rendered, because the database is not something the install is starting, and
an unreachable one fails immediately and clearly as before. Nothing to do on
upgrade.
The bundled Postgres now passes its readiness probe under a tight CPU cap
Found on a cluster while verifying the gate above: with
postgres.resources.limits.cpu set to 50m to fit a namespace quota, the
bundled Postgres started — initdb ran, the server logged that it was
listening 50 seconds after the pod did — and the pod never became Ready.
Every readiness probe timed out. Because the pod was not Ready the
<release>-postgres Service had no endpoints, so every client, the new gate
included, was refused by a ClusterIP in front of a server that would have
answered a direct connection (on that cluster the refusal was ECONNREFUSED;
a network that drops instead shows a connect timeout). The install read as "Postgres never came up"
when what never came up was the probe.
The chart set no timeoutSeconds on the probe, so Kubernetes applied its
default of one second, and pg_isready forked inside a container throttled to
a twentieth of a core did not start and connect in one second. The probe now
allows five seconds, its own period: the same install capped at 50m became
Ready 48 seconds after pod start, and on an unthrottled node pg_isready
answers in milliseconds and nothing changes. A default install was never
affected — the default limit is 2000m — so this matters only where you cap
the datapond hard.
The probe's schedule is now yours to set, under postgres.readinessProbe
(timeoutSeconds, periodSeconds, initialDelaySeconds, failureThreshold),
and a value that is not a whole number is refused at helm install by name
rather than by the API server later. Note that failureThreshold is not the
first-boot knob: it decides when a Ready pod is taken out of the Service, and a
pod that has not yet passed the probe is simply not Ready however many times
the probe fails on the way. The troubleshooting page's entry for the two
refused-connection errors now says how to tell a server that is down from a
server that is up behind a probe that is not.
A figure that changes on upgrade: leader-scoped counts may drop
Read this one before you upgrade, because a number moving is worth knowing about in advance.
If your directory has rows with a blank identifier — no employee id, or whatever column your directory uses to say who somebody is — then figures scoped to a leader's organisation were too large on earlier versions, and get smaller on this one. The smaller number is the correct one.
What was happening. Prism walks your directory to work out who reports to whom. A row with a blank identifier was walked as though the blank were an identifier — so it joined the reporting line, and then everyone whose own manager column was also blank joined underneath it, because one blank matches another. People with no connection to that leader were counted as part of their organisation. On most directories this affects at least a few people, because a blank manager column is normal for anyone at the top of the organisation and for any row an export left incomplete.
What changes. A row with a blank identifier is no longer treated as a person in a reporting line. It is not counted, and — the part that matters — it can no longer join two unrelated parts of your organisation together.
How to check whether it affected you. Ask the same question on 0.13.1 and on 0.14.0:
How many people report to
<a leader's name>?
If the 0.14.0 answer is smaller, your directory has blank-identifier rows and the earlier figure was counting people who do not report to that leader. If the two answers match, nothing here affected you.
Nothing else about the population changes: the same people, the same depths, the same crosswalk. Only rows that never identified anybody are gone.
Ingestion activity now says when a credential's reach was checked and did not move
On 0.13.0 the credential reach line on Ingestion activity appeared only
when the reach had changed. A run that measured the credential, compared it
with the previous run and found nothing to disclose recorded nothing — so the
page showed the same blank for "we checked and it did not move" as for "we
have never looked", and the same blank again for a source such as Jira that
has no credential reach to measure.
Each completed run that compares now records that it did, with a timestamp, and the line says which of three things is true: checked and unchanged (with the count and when), measured once and not yet compared (a fresh install, or the first run after this upgrade), or not yet measured (no run has recorded a reading — an older ingest image, or a probe that has not yet succeeded). A source with nothing to measure still shows no line, on purpose. A change the ingest reports that this version of the page does not have words for is now shown with its raw state rather than left blank.
Where this applies. On both lanes: see Credential reach is measured on the manifest engine too, above in this same release — the probe ran on the legacy GitHub lane only until then, and on 0.13.0 the row was blank. The sentence for an engine-walked source with no reading — not yet measured on this lane — names the version boundary, and is a gap like the legacy lane's.
Two things to know about the upgrade. First, the credential reach notice
that tells you a credential now reaches more or less than it did was
never drawn on a real installation before this release — the page read the
stored notice in a form it did not recognise. The fix is in the page, so a
notice that is still recorded appears as soon as the app is upgraded, with no
ingest run needed. A widened notice persists until the span is re-swept, so
one recorded on 0.13.x will be there; the narrowed, replaced and
baseline reset notices clear themselves after a bounded number of runs, so
one of those raised weeks ago may already have gone. Second, between the app
upgrade and the next completed run on the legacy lane the line reads
measured once, because no run before this release recorded that it
compared; the first completed run after upgrading compares against the
baseline 0.13.x already recorded and says checked.
A per-record stream declared under another substream now runs, with a bound
A context stream whose parent is itself a substream and which declares its
own request — "for each row this enrichment produced, fetch this" — was
accepted by the manifest editor, registered, and then never executed: no
requests, no rows, no error, and nothing to notice it by except a row count
sitting exactly at the ceiling of the stream above it. No shipped stream has
the shape; one you author in the register-time editor can.
It now runs, one request per parent record, under a bound you declare beside
the parent — parent.max_records, which works exactly as overflow.max_records
does: a bound, not a target, applied per page of the parent stream rather than
per run, and absent means unbounded. When the bound is
reached the run says so in its log, in ingest_state and in the coverage an
answer carries, the same way the overflow bound and the search ceiling report
themselves, so a partial read never reads as a complete one. The manifest
reference (docs/source-manifest-schema.md) has the field.
A question that names a metric no longer has to name its dataset too
Three of fifteen tool calls in one customer's exported answers were refusals Prism raised against itself — the model named a source and a metric, was told "say which dataset", and re-sent the same call with the dataset added. Each cost a round trip and a line in the exported document reading "Semantic Query (2 refused)".
Where every metric a question names belongs to exactly one dataset of the
source, that dataset is now used, and the tool payload's query.inferred says
dataset so the record of what was asked never reads as if the model supplied
it. Where two
datasets could serve, or a metric belongs to none, the refusal is unchanged.
The third refusal in those exports stays, and was right: a question over
pr_reviews scoped to a leader's reports must say whether the people are the
reviewers or the authors of the reviewed pull requests, because the dataset
carries both, and those are different questions over the same rows. A dataset
carrying one such column has never needed matched_on.
Your own words, written down once and used by every answer
Prism is learning what your words mean. An administrator will be able to write down that "developers" is a particular set of job families, profiles and titles from your directory, that "Ledger" is another word for one of your platforms, or who owns a product — and every answer that uses one of those words will use your definition and say which version of it it used.
This release note grows as the release does. What has landed so far is the store underneath it and the page you write on: Admin -> Definitions, where a super admin writes a definition, sees what it would match before it counts for anything, activates it, and withdraws it when it stops being true.
What works today, and what is still arriving. A context document — the prose kind, where you write down what an acronym stands for or what a product is — can be written, previewed and activated now. A definition that selects people (a classification over your directory's fields, a reporting line, a list of identifiers) can be written and saved now, and can be activated once this installation can count how many people it matches; that counting arrives with the rest of this release. Prism says so when you preview one rather than guessing at a number, and nothing you write is lost in the meantime.
Four things about that page are worth knowing before you use it:
- Nothing takes effect until you activate it. A definition you have written is a draft; readers' questions do not see it. Activating replaces the previous version in one step — there is no moment in which the word you defined means nothing.
- You see the size before you commit to it. Preview counts how many people a definition would match, and Prism refuses to activate a definition nobody has previewed: the figure beside an activation is the figure your answers will quote. The count is measured by Prism, never taken on trust from whatever asked for the preview — and where Prism can measure a kind for itself, a figure sent with the preview request is now refused rather than quietly ignored. That is visible on the admin page: a definition's size is labelled with how it was arrived at, and that label is a fact about this installation rather than a word beside a number somebody else supplied.
- Every version is kept. Editing writes a new revision and supersedes the old one; withdrawing takes the word out of use without deleting anything. An answer given last month named the revision it used, and you can still read it.
- One word means one definition. A name or an alias another active definition already answers to is refused, and says which definition holds it. If two administrators edit the same definition at once, the second is told what changed rather than quietly overwriting the first.
Who owns what, uploaded as a table. If your organisation keeps a spreadsheet of who is responsible for which area and which products, you can now upload it and have Prism connect each owner to a person in your directory. (Corrected in 0.15.0: the upload is two calls to the admin API. No page control reaches it; Upload who owns what shows the calls.) It is a CSV with a column for the owner, a column for the area, and — if you have them — a column for the products, plus one thing Prism will not guess: what separates two products in one cell. A comma is a separator in some organisations and part of a product name in others, so you say which, and Prism splits on that and nothing else.
Three things about the import are worth knowing before you use it:
- Every owner is matched to a person once, when you import, and never again. A name that matches exactly one person in your directory is connected to them; a name that matches several is shown to you with the candidates, and you choose. Prism never picks for you — a question answered about the wrong Marc is worse than a question that could not be answered.
- You see what the table says before anything is stored. The first step reads the file and reports what it found — how many claims it makes, how many distinct owners, how many of those Prism could place in your directory, which rows it could not read and why — and stores nothing at all. Only when you are satisfied does the second step write a definition, and even then it is a draft until you activate it.
- Re-uploading a corrected table tells you what moved. If a name you previously connected to one person now matches somebody else, Prism says so before you activate, naming the owner and both people. That is the change worth catching: everything that name selects would quietly become a different team's work.
Counts are reported at the grain they belong to and are not mixed: the number of claims your table makes is not the number of owners in it, and neither is the number of people — a table naming one person under two spellings has more owners than people, and Prism says so rather than picking one number.
Activating the table creates the groups it describes. One for each area and each product, named exactly as your table names them, so a reader can ask about Ledger or Compass with no further setup — and each is everyone under that area's owners, not counting the owners themselves. If a group of 41 people has two owners, those two are not among the 41; that is what "everyone under" means, and it holds however the owners are arranged, including when one of them reports to another. Withdrawing the table withdraws its groups, and re-uploading it replaces them, so an area you drop from the file stops answering questions. Replacing the table with a definition of another kind does the same — if you write a context document under the name your ownership table had, the groups that table created go out of service with it, rather than staying live under a table that no longer exists.
A word another definition already uses is not taken: the rest of the table still activates, and the report says which words are held before you activate.
Upload who owns what is the page, and it states the limits: a 2 MB file, 5,000 rows, 250 distinct owners, 50 distinct owners on any one area or product, and 250 areas and products in one file. One word cannot be both an area and a product — they would become one group with one name.
The last two are about the question rather than the file: a group of everyone under more than fifty owners cannot be counted within your directory's time limit, and a file describing hundreds of areas is a list of work rather than a statement of who is responsible for what. Both are refused when the file is read, before any lookups, naming the column and the rows rather than the value. Both are generous for what an ownership table is, and meeting either usually means the column mapped as the area or the product is not the one you meant.
They are fixed rather than settings — a table of who owns what is a short document by nature — and the refusal names the one you met.
And Prism now reads them when it answers. A question that uses one of your words — "how many developers", a term one of your context documents explains — is answered with your definition, and the answer says which one it used and which revision, so a reader can check it. A figure can be scoped to a definition by name — ask about "developers" and the set is the one you defined, not one Prism assembled to match. (Asking who is responsible for what arrives later in this release.) Three rules go with that, and they are worth knowing because they are what stops a definition doing more than it should:
- Inside the ground it covers, your definition wins over whatever a column in your directory happens to be labelled.
- A context document that settles an ambiguity means Prism does not ask. That is what writing one is for. It still asks when two of your definitions could both apply to the same word — that is a choice only you can make.
- A definition never decides what data exists. That nothing is defined for a subject means nobody has written a definition, and never that the data is missing. What Prism is connected to is a separate question with a separate answer, and defining a word cannot change it.
A definition also cannot change how a figure is calculated, which filters a question must carry, or any refusal — those do not read your definitions at all.
One default scope for the whole installation, and where it does not reach
Your credentials probably reach more of your organisation than the people you are asking about, and until now every unscoped question was answered about all of it. An administrator can now name one population as this installation's default scope — one of your own definitions — and a question that names nobody in particular runs under it. (Corrected in 0.15.0: it is set through the admin API. There is no control for it on the admin page; the page below shows the call.)
Set a default scope for every question is the page.
Every answer that ran under it says so, naming the definition and the revision it used:
this installation's default scope: 'Payments engineering' (revision 3) — 214 people when it was activated on 8 September 2026
That sentence is the point of the feature rather than decoration. A default nobody states is a hidden filter, and a denominator that moves without saying so is worse than one that is wrong, because nothing shows. For the same reason a figure Prism did not itself measure is never quoted as one: the sentence says size not measured instead.
A question that names someone overrides it, silently. Ask about a particular leader, team or definition and that is the answer you get, with its own population stated in full. Nobody is locked out of anything — a reader widens by asking a wider question.
It is deliberately hard to make a question fail because of this setting. Withdraw the definition the scope names, or change it into a context document, and questions keep working: they run about everyone and say which setting stopped applying, so you can see what to fix. The one thing that does refuse is the CHOICE: a definition of a kind Prism cannot yet turn into a scope — a list of identifiers — is refused on the admin page when you pick it, naming the kinds it takes, because you are standing there choosing and a choice that could never work should fail while you can still pick another. Only Prism being unable to read its own database refuses, and then it says to ask again — because a question answered as though no scope were set, on an installation that has one, would quietly be about more people than the reader was told.
Two kinds of answer cannot apply it and say so in a line of their own: answers built from the GitHub index — "who merged the most pull requests" and its neighbours — and the two leaderboard endpoints that fed the At a glance panels before 0.13.2 removed them. Both read their indexes directly, without the crosswalk that turns a leader's reporting line into GitHub logins or Agent Router accounts, so they are org-wide by design:
org-wide: this installation's default scope does not apply on this path
There is nothing to configure and nothing has gone wrong when you see it. It is there so that an answer nothing narrowed cannot be mistaken for one that was. Limits is the list, and it also names the two lanes — Jira index answers and live GitHub drill-downs — that are org-wide in the same way and do not yet carry the sentence.
What an installer needs to know now, before the rest of it arrives:
- One new place your data lives, and it is inside the database you already have. Definitions are rows in the Prism database (the datapond), in three new tables created automatically the first time the app starts on this version. Nothing is written outside it: no file, no volume, no second store. There is nothing to run and no parameter to set.
- What that means for your backups. Whatever you use to dump the Prism database carries the definitions, their whole revision history and which revision of each is in force; restoring that dump reproduces exactly those active revisions. Restarting the app preserves them. A dump of some tables does not: the three tables are written together and have to be restored together. Back up and restore is the procedure, and the check at the end of it is the one that proves a restore worked: ask Prism a question that uses one of your words and read which revision it says it used.
- Downgrading is safe. An earlier version of Prism ignores the new tables rather than failing on them, and does not delete them — so if you roll back and roll forward again, your definitions are still there.
The diagnostic bundle says which lane is feeding each source
Every entry in the bundle's ingest block used to carry lane: "authoritative"
unless it was a shadow row. That word was read off the entry's name and nothing
else, and on one installation it labelled a source the ingest engine had never
walked — the source's context stream was switched off, and its rows were
arriving from the per-source ingest job from before the engine.
The entry now says engine, legacy or shadow, from the facts that decide
it: whether the context stream is switched on and the engine is running
authoritative for it, whether the ingest job has actually recorded a run of
it, and whether the older per-source job is still rendered for it. Where Prism
could not establish the answer — or where nothing is collecting the source at
all — the word is left out and lane_why says which, rather than guessing.
legacy on a source that has a context stream, on an installation running
authoritative, is worth a look: it means the engine is not walking that
source and the job that is walking it is the older per-source job, which is to
be retired in a coming release. For a source with no
context stream, such as Router spend, legacy is simply the lane it runs on.
The built-in self test describes the
field.
An answer that scopes to one of your words now says what its figure was filtered by
Two numbers, both right, that count different sets of people — and until this release nothing in the answer explained the difference.
When you activate a definition, Prism stamps it with a size: how many people that definition holds. A question scoped to that definition is an ordinary question, so it also applies whatever defaults the source it reads declares — on the roster upload, the latest upload only and current staff only. So a population stamped at 573 can be answered from 523 people, and the missing 50 are the leavers, exactly.
Asked to explain the gap with nothing in the payload about those defaults, the assistant reached for the only other thing it had and told the reader the larger figure was stale. It was not stale; it was a different set.
An answer scoped to one of your words now says so in the same breath as the size, naming the fields that narrowed it — and says nothing at all where the source declares no defaults, because then there is no gap to explain. Neither figure changed, and no setting controls this.
The self test names a source only the older ingest job is collecting
On an installation running the ingest engine (ingest.engine.mode: authoritative), a GitHub or Jira context stream that is switched off on
Admin → Context streams is not walked by the engine — and is still being
collected, by the per-source ingest job from before the engine, which is to be
retired in a coming release. Until now
the source registry line reported that state as "registry disagrees with
the legacy lists on 1 point(s) — legacy still serves", which was true and
named neither the source nor what would happen next.
The line now leads with what happened — switching the stream off did not stop
collection — names the source and the job, says the job is to be retired in a
coming release, and gives the three ways to settle it rather than choosing for
you: move the stream onto the engine (enter its credential on Admin →
Context streams, where Prism tests it as it is stored, approve it if the page
still offers Approve, then enable it — and afterwards check the ingest
dispatch line, because the ingest job can still turn a stream away for a
reason the page cannot see); keep it on the older job deliberately
(ingest.<source>.engineMode: "off", which the line respects); or stop
collecting it (sources.<source>). Enabling alone is refused for a stream
Prism enabled from your chart values and somebody later switched off, because
such a stream carries no test result of its own; the sentence gives the order
that works. The line stays amber rather than red: the data is arriving today.
If your installation reads this sentence, settle it before the job is retired. The built-in self test describes the line.
A roster answer no longer calls itself a floor
If your organizational context stream is an uploaded roster — a whole copy of your directory, uploaded when somebody exports it — every answer from it used to carry a red warning in its provenance block: the requested window is not fully covered — 82% of it covered … Figures from this source are a floor, not a total, and the assistant repeated it in the answer. A headcount from the latest upload is not a floor; it is the headcount as of that upload. What is true is only that the copy is some hours old, and the same block already said that.
Prism was measuring a whole copy as if it were a windowed mirror — a source it
walks forward in time and may hold only part of. It now tells the two apart: a
dated whole copy reports the date of the copy and how long ago it was
loaded (whole copy as of …, loaded 26h ago), and no percentage, no gap and
no floor. The floor warning stays exactly where it is true — the GitHub,
GitLab and Jira mirrors — and the assistant is told, in its own instructions
for the answer, that a copy's figures are complete for their date and not a
floor. A question that deliberately spans several uploads (joiners and leavers
between two rosters) says so in the same place.
Nothing about staleness is hidden: a copy older than the source's freshness threshold is still reported stale, with its age, and that is the sentence to read.
An Oracle database that requires encryption now says so
An Oracle database can be configured to accept only encrypted connections
(SQLNET.ENCRYPTION_SERVER = REQUIRED and its checksum counterpart). The
driver's default thin mode cannot negotiate that, and until now nothing said so.
Testing the connection from Admin → Sources reported that the driver had
refused the connection parameters before anything was dialled and told the
administrator to check the address, the port and the service name — none of
which was wrong. The listener had answered; it was the encryption that the
connection could not agree.
The test now says what happened: that the database answered and requires Oracle
Native Network Encryption, that the address, the service name and the credential
were not the problem, and that the remedy is either a connection through a TLS
(tcps) listener or thick mode, which this release also adds — see An Oracle database that
requires Native Network Encryption can now be connected, above.
Copying from such a database stops on the first attempt with the same sentence
rather than retrying six times against something that cannot succeed in thin
mode, and a question asked against one through the query tools reports the same
fault instead of "the listener could not be reached".
If your Oracle accepts unencrypted connections, or you connect through a TLS listener, nothing changes.
Three images move to a Debian base and carry the Oracle client
Three of the nine images — the app, the ingest engine and the semantic query
server — are built on python:3.12-slim-bookworm instead of Alpine, and each
now carries Oracle Instant Client 19.30 "Basic Light". They are the three
that open Oracle sessions. The other six are unchanged.
Why. An Oracle server can be configured to require Native Network Encryption, and a great many bank estates are. The pure-Python Oracle driver Prism has used until now cannot negotiate it at all — it refuses before authentication — so on such a server a correctly configured Oracle context stream never gets a session. Reaching those servers needs Oracle's own client library, which Oracle builds for glibc only. That is the whole reason for the base change; nothing else about these images differs. Using the client is a per-source setting and is not enabled by this release.
What it costs, measured on linux/amd64, which is what we publish:
| Image | On disk before | After | To pull (compressed) |
|---|---|---|---|
prism-app | 136 MiB | 327 MiB | 52 MB → 108 MB |
prism-ingest | 101 MiB | 293 MiB | 37 MB → 94 MB |
semantic-mcp | 120 MiB | 311 MiB | 43 MB → 99 MB |
About 191 MiB more on disk and 56 MB more to pull, per image. The two columns
are in different units on purpose, because the tools are: the on-disk figures
are du inside the image (mebibytes, which is what a node's disk usage reads
in), and the pull figures are the compressed sizes a registry reports
(megabytes). Roughly 116 MB (111 MiB) of the increase is the Oracle client and
the rest is the Debian base. We ship the reduced "Basic Light" package rather
than the full one for that reason, and delete the three Java archives it
bundles — ojdbc8, ucp and xstreams — which nothing here loads.
The CVE position, stated rather than implied by a clean scan. Our release
gate blocks on a fixable HIGH or CRITICAL in any image, and these three pass it
as they did before: the Debian base carries two fixable HIGH findings in
libpcre2, and each image installs the fixed version, exactly as the Alpine
images pinned three packages of their own. What is new is a gap no scanner
closes. Oracle ships no package metadata with Instant Client, so Trivy — ours
and yours — reports nothing at all for those binaries. A clean scan of these
images is not a statement about the Oracle client. What stands behind it
instead: the client is pinned by version and by SHA-256 for each architecture,
so what is in the image is exactly what Oracle published; the three Java archives
that a scanner could have read are removed; and we track Oracle's quarterly
Critical Patch Update by hand, because nothing in either pipeline will raise it.
If your own scanner reports differently from ours on these images, that is the
difference to look at first.
What your own scanner will show on these three images, which it did not
before. If your scanner reports findings that have no fixed version, expect
60 CRITICAL and HIGH findings on each of the three, where 0.13.x showed
none. They are 21 distinct CVEs across 19 Debian base packages, 5 of them
CRITICAL. None has a fixed version, which is why our gate passes them and
cannot do otherwise. The list is identical on all three images, because they
share one base. Most are in parts of the base that no Python or Oracle code in
these images links: 49 of the 60 are in perl, the util-linux and mount tools, gzip,
libacl, libsystemd/libudev and the ncurses programs. The remaining 11
are in libraries Python's own extension modules link: libsqlite3-0, zlib1g,
libuuid1 and ncurses. Prism's code does not import sqlite3, curses or
readline. The other six images are unchanged from 0.13.x at every severity.
Measured 13 September 2026 on linux/amd64 builds with Trivy 0.74.0.
The Oracle client against Oracle's own advisories. The image carries 19.30, the January 2026 release; 19.31 and 19.32 have been published since. We read the April and July 2026 Critical Patch Updates for fixes that apply to client-only installations. April's one fix is for 23.x clients only. July's three (CVE-2026-47045, CVE-2026-47060 and CVE-2026-47061, CVSS 6.8 and below) are all in the JDBC driver. JDBC is Java: these images delete its archives and contain no Java runtime to load it. None of the fixes those two advisories list for client-only installations applies to what ships here.
The client is redistributed under Oracle's Free Use Terms and Conditions, and
the licence text is in the image at
/opt/oracle/instantclient_19_30/BASIC_LITE_LICENSE.
One limit worth knowing before you need it. The reduced client supports databases whose character set is US7ASCII, WE8DEC, WE8MSWIN1252, WE8ISO8859P1, WE8EBCDIC37C, WE8EBCDIC1047, UTF8 or AL32UTF8. A database on any other character set is refused at connect with ORA-12737, and the fix for that is a different image rather than a setting — tell us if you meet it.
A caveat about excluded rows no longer says every column is empty
When a question groups by a field that is blank on some records — asking for incidents with their resolution date, where the unresolved ones have none — those records fall outside every group and are left out of the listing. Prism already detects that, counts the records it left out, and publishes a note about them for the assistant to pass on.
That note named every field the question grouped by, and said all of them were empty on the excluded records. Usually only one of them was. On a service-desk source, four major incidents left out solely because they had no resolution date were described as having no number, no state and no opened date either — a statement that the customer's records were blank when they were complete, and one an assistant would repeat to you because the note is written to be repeated.
The note now says what was actually established: that at least one of those fields is blank on the excluded records, that it does not say which, and that it may differ from record to record. Where the question groups by a time period, the note now names the period column the answer actually shows rather than the underlying date field, which was not in the answer to look at. Nothing else changes — the same records are excluded, the same count is published, and a question that groups by a single field still names that field plainly.
A wrong Oracle password is reported as a credential problem, not a view problem
When a scheduled copy from an Oracle database was refused because the login name or password was wrong, the copy's failure told the administrator to "check the view name, the column names and the grant on the service account". None of those was the problem. Testing the connection from Admin → Sources named the credential correctly, so the two places an administrator would look gave two different answers for the same fault.
The copy's failure now says what the database said: the credential was not accepted, no statement ran, and the view is not the problem — correct the credential on Admin → Sources and re-run the connection test before the next copy. A wrong password is still refused on the first attempt of a run rather than retried within it, so one scheduled copy costs the account one failed logon, not several. The next scheduled copy will try again, so if your Oracle profile locks accounts after a few failed logons, correct the credential before then.
0.13.2 (9 September 2026)
"At a glance" is gone; the chat has the whole page
The two always-on panels in the right-hand rail — top contributors by merged pull requests, and, where Router spend is indexed, top spenders by inference cost — have been removed, along with the rail itself. The composer and the answer log now use the full width of the window.
Why. Those panels were built directly from the org-wide statistics routes, which know nothing about your organizational context stream. On an installation whose GitHub credential reaches the whole organization, the leaderboard ranked whoever appeared across every mirrored repository — for the most part people the reader had never heard of — and it did so on the first screen, before anyone had asked a question. A per-person ranking that nobody requested is also the one place in Prism where a figure about named individuals appeared without a question behind it to scope it.
Nothing is lost that you cannot ask for. Both panels were shorthand for questions Prism still answers, and answers better, because a question carries its population, its coverage sentence and its provenance:
- Top PR authors over a window, for the organization or one repository —
the
top-pr-authorsentry. - Top 5 AI spenders and what each spent over a window — the
top-spendersentry.
Both are in the What can I ask? catalogue and both were there before this release.
On scoping, which is the point of the change. The pull-request question takes the population grammar like any other — "top PR authors under Grace Hopper", "whose job family is Software Development" — so the ranking the panel could only show org-wide is now something you can ask for your own organisation. Inference spend is not there yet: Router spend is recorded against Router user ids, and an installation generally has no crosswalk from those to the people in your directory, so a spend question narrowed to a leader or a directory field is answered org-wide or refused rather than silently scoped. That limit predates this release and is unchanged by it.
Nothing to configure, and nothing else changed. No install parameter is
added, removed or reinterpreted. The /api/dashboard/panels endpoint and the
/stats/pr-leaderboard and /stats/spend-leaderboard routes on the statistics
servers are untouched and still answer; only the page that drew them is gone.
Upgrading needs no action beyond the usual helm upgrade.
The per-person floor now governs every backend, not just Semantic Query
If you have set disclosure.perPersonFloor, it reached one of Prism's four
answering backends until this release. The value was rendered into the
Semantic Query server and read there; GitHub Org Stats, the Jira lane and
per-person Router spend never saw it. So "rank the top 10 engineers by merged
PRs" answered through GitHub Org Stats ran under no floor, while the same
question through Semantic Query minutes later ran under yours — and which route
Prism takes to a question is not something you choose.
It now reaches all four, with one set of rules applied identically:
- a per-person average, median or percentile computed over fewer than N observations is withheld — returned as no value, and named as withheld, rather than published with a warning beside it;
- the observation count stays, so an answer can say a figure was withheld and over how few observations, without giving the figure. (The one exception is unchanged and is Semantic Query's: where the count IS the figure — a distinct count is its own observation count — it goes with it, because publishing it would publish the figure.);
- an observation count of zero is never a withholding: nothing was observed, and the answer still reads as "no data in this window";
- per-person counts and sums are published as before — merged pull requests, issues closed, story points, lines changed and Router cost. Ranking named people by a count is a supported answer and this release does not change it.
It follows the people, not just the grouping. Ask for one named engineer's figures broken down by repository, by day, by model or by API key and every row is that person's, so the floor governs all of them and the across-everybody figure beside them. Ask the same question with nobody named — every repository, every model — and it is not an answer about a person and is not floored. Before this release the floor followed only the grouping, so naming somebody and grouping by repository published exactly what grouping by author withheld.
Every per-person answer that carries a statistic now carries a
per_person_floor block naming the floor that governed it, the setting it
came from, and the aggregations it did not cover — present even when the
floor is 0, so you can see that two routes to one question were governed
alike rather than inferring it from two silences. Where the floor cannot really
protect a figure the block says so: on Router spend, cost per request and
error rate are withheld but recoverable by dividing two figures published on
the same row, because this floor does not withhold the money.
No figure changes on a default install, and one answer may read
differently. The floor still ships 0 — off — and with it off every number
in every one of these answers is what 0.13.1 gave. Two things about the
payloads are new and deliberate: they gain the per_person_floor block
described above, and a per-person average now travels with the count of
observations behind it. That count is what lets an answer say a figure was
withheld and why — and it also means the engine's standing rule about small
samples can now see how few observations an average came from, so an average
over one or two pull requests may be reported with that caveat where it was
previously quoted flat. Nothing is withheld that was not withheld before.
The correctness rule is not this setting and is unchanged. A median over fewer than five observations is still not something Prism will report as a median, whatever you set here — a statement about the statistic rather than about who may see it. In the review-pair matrix that has always been a withholding; elsewhere it is an instruction the engine gives itself with the observation count beside the figure. Setting the floor above five raises the review-pair threshold with it, so the two never disagree inside one answer.
What a withheld figure still tells you. A ranking is returned in the order
the figures put it in — Prism sorts on the real value and applies the floor
afterwards, because that order is the answer to "who is slowest to get a
review" and re-sorting it would make the ranking wrong. So a floored ranking
gives no number and still gives the comparison. The per_person_floor block
says this on the payloads where it applies, and a rule that withholds the
comparison as well is a different control from this one.
Limits is the full statement of what the floor covers.
A context stream that will never collect anything now says so
A mirror or snapshot stream carrying a document you wrote or edited yourself — the manifest editor's YAML view, with an edit in it, or a document posted straight to the API — could be given a credential, pass its test, be approved, be switched on, and then be skipped by every ingest run, unless you happened to name the stream after the prebuilt one it came from. Nothing collected, no error, and Admin → Context streams reading exactly like a healthy stream. The only record that it would never run was a line in the ingest job's log.
That state is now visible where you look. The stream wears not ingested in
the error colour, with a line under it naming the stream and what is true:
nothing is collected from <your stream> · nothing recorded which prebuilt
stream this document came from, so the ingest job does not know how to walk it.
It will not start on its own. Register the stream again from a prebuilt one —
your edits to this document are not carried over — or ask Tetrate before you
change anything. The self test grows an ingest dispatch line in the SOURCES
group that fails, names every switched-on stream in that state, and says the
same.
Do not rename the stream to a prebuilt one's name. It looks like the fix and it is not: on most installs the name is already held by the prebuilt stream and the rename is refused, and where the name is free the prebuilt document takes over the walk, so your edits stop being used. Either way the warning goes away and nothing is collected — which is worse than the silence this release fixes. Registering the stream again from a prebuilt one collects data; it does not carry your edits over. If those edits are the reason you wrote the document, ask us — making an edited document walkable under its own name is a change we have to make, and it is not in this release.
What this release does not cover. A stream that keeps a prebuilt name but whose document differs from the shipped one in kind or connection is refused by the ingest job rather than by this page, and Admin → Context streams still reads healthy for it. That is a second silence, and it is not fixed here.
Two kinds of stream are not affected and are not counted. A query-through
stream copies nothing by design — its rows are read from your own system while a
question is answered. A file stream, such as a roster upload, is collected
without a prebuilt stream at all. Neither turns red, and a stream nobody has
switched on does not either.
A narrower case stops arising. If a prebuilt stream's document reaches Prism as text — pasted into Paste a manifest…, reformatted without changing what it says, or posted by a script — Prism now recognises it as that prebuilt stream and records which one, so the stream is collected under whatever name you gave it. This does not change the picker's ordinary path, which was never affected: opening a stream under Add a context stream, naming the instance and changing nothing already registered it from the prebuilt stream and was already collected. A document you have edited still records none, because the walk it describes is no longer one this release has proven; it now says so on the page instead of leaving you to find out from an empty view.
Nothing to configure, and no stream changes state on upgrade. Streams that were being collected are collected exactly as before. A stream that was silently being skipped turns red on the page and red in the self test, which is the change: the state is not new, only the reporting of it.
0.13.1 (9 September 2026)
A question can now name a leader's organisation AND a directory field
"How many people under Grace Hopper with a job family of Software Development opened a pull request in the last 30 days?" was refused on 0.12.1 and 0.13.0. Prism could scope a figure to everyone under a leader, or to everyone whose directory field held a value, but not to both at once — and the answer said so: "a population is either a leader's reporting line or a predicate on a directory field, not both." Asked anyway, Prism tried the same refused question several times, then answered with a ceiling worked out by hand from two separate figures.
The two now compose in one question. A population may name a leader, the depth, and a directory field with one value or a set of them: "everyone under Grace Hopper whose job family is Software Development", "the direct reports of Ada Lovelace who are contingent workers". Prism reads the people in that leader's organisation from your directory, keeps the ones whose field matches, and scopes the figure to them — one query, no client-side join.
Two things follow that you can now ask directly. Every scoped answer already says how many people the population holds and how many of them resolve to an account on the source you asked about. With the two halves combined, "how many of the contractors under Grace Hopper have a GitHub login" is that coverage sentence on the narrowed population, and "how many developers under each of her leaders opened a pull request" is one question per leader rather than two and a subtraction.
What the answer says. It names both halves every time — the leader and the depth, and the field and value — because "everyone under Grace Hopper" and "the developers under Grace Hopper" are different denominators. The coverage caveat is unchanged: only people Prism has matched to an account on that source can be counted, and the answer states how many it could match.
Nothing to configure. A question that names only a leader, or only a field, behaves exactly as before.
The agent runner picks up three dependency fixes
The vendored agent runner moves forward five commits, and three of them are security fixes to its own dependencies:
| package | from | to | why |
|---|---|---|---|
next | 16.3.1 | 16.3.3 | two CRITICAL unauthenticated-RCE advisories |
sharp | 0.35.0 | 0.35.4 | a HIGH advisory in the bundled libheif |
fast-uri | 3.1.5 | 3.1.7 | four HIGH SSRF and host-confusion advisories |
None of the three reaches the image this appliance runs, and they are taken
anyway. The runner image installs only the two workspaces it executes, so
next (which belongs to the runner project's web UI) and the workspace-root
packages are never installed into it — which is why the image scans were clean
before this change as well as after. They are upgraded because a published fix
leaves nothing to write an exception against, not because the appliance was
exposed.
The same bump adds one entry to the published acceptance list above, for tar
inside the npm CLI's own vendored tree — again in the runner project's
coding-sandbox images, which this appliance does not ship.
Nothing to do beyond upgrading. No configuration changes and no values move.
A security fix in four of the MCP images
Two HIGH advisories were published against httpx2 / httpcore2 on 9 September
2026, hours after 0.13.0 was built and scanned clean:
| CVE | what it is |
|---|---|
| CVE-2026-84381 | WebSocket traffic sent in plaintext when a SOCKS5 proxy is configured |
| CVE-2026-84382 | Denial of service through memory amplification when decompressing a streamed response |
The package is pinned at 2.9.1 in the github-stats-mcp, jira-stats-mcp,
github-live-mcp and management-mcp images and moves to 2.12.0, which carries
the fix for both. semantic-mcp was already on 2.12.0 and does not change.
Whether either is reachable in your install depends on how you run it. CVE-2026-84381 needs a SOCKS5 proxy configured for outbound traffic, which the chart does not set up. CVE-2026-84382 needs a response large enough to matter after decompression, from the systems these servers read. We are not claiming either was exploitable here; the fix exists upstream, so we take it rather than reason about reachability.
Nothing to do beyond upgrading. No configuration changes, no values move, and
those four images are the same in every other respect; the semantic-mcp image
carries the population change above.
The install tells you every value it wants, in one go
If you have installed Prism from an empty values file, you met its required
values one failed render at a time. Helm stops at the first missing one, so
setting it only revealed the next: seven helm runs on the bundled datapond,
eight on an external one, each a real install or upgrade against your cluster.
A render now checks them all at once and fails once, listing every value it
is missing — numbered, each with the same explanation the single-value message
carried, so one edit of your values file can answer the whole list. The list is
complete for your values file rather than in the abstract: under
existingSecret the chart renders no Secret and asks for none of the
secrets.* keys, and postgres.external.host appears only on
postgres.bundled=false. Values that another setting turns on — the management
URL once spend goes live, the GitHub orgs or the Jira URL once those sources do,
the ingress host, and the four that app.identity.mode: local needs — are not
in this pass. They are checked where they are used, so they still arrive one
render at a time; Installing Prism lists them.
No value became required that was not required before, and no Kubernetes object
changes.
One correction to the documentation came with it: secrets.postgresPassword
was described as the bundled Postgres's password, and it is required on an
external instance too, where it is the password for postgres.external.user.
The restarts you will see in the first minute of a fresh install are expected
A fresh install with the bundled Postgres shows agent-runner and
synth-seed restarting, with the connection to Postgres refused in the previous
container's logs. Both run their schema migration at boot, before the bundled
Postgres is accepting connections, and Kubernetes restarts them until it
answers. The two report it differently: agent-runner is Node and logs connect ECONNREFUSED <postgres-ip>:5432, while synth-seed is Python and ends its
traceback ConnectionRefusedError: [Errno 111] Connect call failed. Expect one
or two restarts of those two workloads, settling inside a minute — the count is
however many attempts land before Postgres listens, not a fixed figure. The
install converges on its own, and the two reactions it tends to provoke, a
support case and changing things underneath a converging install, both cost more
than the problem. On an external database (postgres.bundled=false) a refused
connection is not this and does not converge; it wants a reachability and DSN
check. Troubleshooting now carries both
verbatim errors, the measured settle times, and the point at which it stops
being normal. The restarts themselves are unchanged by this release — the readiness
gate that removes them is tracked as issue #1254 for a later one.
The quota figure to size against is the one you cannot see
0.13.0 published the per-component compute table. What it did not say is which
of its numbers a namespace ResourceQuota actually meets — and three are on
offer, spanning a factor of thirty. A fresh install uses about 150m of CPU
at idle, reserves 650m / 1.5Gi, and needs 4,500m / 7Gi of limits
permitted before any of it starts — plus one Job slot for the synthetic-seed
hook, which runs on every install as well as every upgrade, making 5,000m /
7.5Gi the floor a fresh default install actually has to clear. Only the
limits figure sizes a quota, and it is the only one that never appears while
you watch the install run.
Compute, and the namespace quota you need
now states all three and says which is which; the pre-flight in
Installing Prism carries the figure
where a first-time installer meets it.
If you are upgrading from 0.12.x, the number moved under you. The bundled
Postgres gained a ceiling of 2 CPU / 4Gi in 0.13.0 — on its own, 44% of the CPU
total and 57% of the memory total — so a quota sized against a 0.12.x install is
short by that much on this release. Installs against an external server
(postgres.bundled=false) are unaffected: their totals are 550m / 1.25Gi of
requests and 2,500m / 3Gi of limits.
A shortfall names none of this. helm reports Error: INSTALLATION FAILED: failed post-install, which is the same sentence any failed hook produces, so it
reads as a broken cluster. The page now carries that string, the pod events that
sit behind it, and how to tell a too-small quota from a full node pool — the
second of which reports Insufficient cpu on a 90%-idle cluster and is telling
the truth, because the scheduler counts requests and never usage.
0.13.0 (8 September 2026)
"Fully covered" now means covered at both ends
If you have exported an answer and read the provenance block underneath it, this changes two sentences in it — and one of them was wrong.
Every indexed source reports whether the window you asked about is covered by the data it holds. That check has a deliberate tolerance: the ingest runs on a schedule, so being a few hours behind "now" is the healthy steady state, and a warning that fired on it would appear on every answer. What the tolerance was never meant to buy is the opposite claim. Beneath it the footer said "the requested window is fully covered" — on one export, over data that stopped three and a half hours before the requested end, with both dates printed directly underneath.
Each end of the window is now checked and reported on its own. Where the window runs past where a source's data ends, or opens before the earliest row that source holds, the block says which end and what is therefore not counted, in plain text. "Fully covered" is now reserved for a window that is covered.
The second sentence is one that fired on the right answer. Ask for three periods at once — "in the last 30, 60 or 90 days" — and the block printed a red warning that "the sources below did not all cover the same period". They had: there was one source and it was asked over the three periods the question named. The warning now fires only when the periods actually split the sources — when one source covered a period another did not, which is the case a reader cannot see in the answer itself and the case it was built for. Several periods that every source ran are still listed, without the red.
Nothing about the figures changes, and no answer becomes a warning that was not one before: the red band above a source's figures still fires on exactly what it fired on before. What changes is that a description stops contradicting the dates printed beside it.
Your CI accounts are yours to name, and ours are no longer in your answers
Prism excludes bots from contributor figures. Most of that needs no
configuration: GitHub tells us when an account is an App, and gives it a
[bot] suffix. What no pattern can catch is a CI account driven by a personal
access token — that is a real user account, and GitHub cannot tell it from a
colleague.
Prism shipped a fixed list of three account names for that case. They were Tetrate's, which had two consequences on your installation: our account names could appear in your answers, and your own service accounts were never excluded, because those three names match nothing you run.
The list is now yours, in ingest.github.botLogins, and it is empty by
default:
ingest:
github:
botLogins: "acme-ci, acme-deploy" # your PAT-driven automation accounts
Leaving it empty is a supported answer, not an omission — bots are then excluded by account type only, which is what GitHub itself can tell us.
What this release fixes
- No Tetrate account name can appear in your answers or exports. This is complete: nothing we ship names an account any more — not the manifest, not the images, not the chart.
- Your named accounts are excluded from REVIEW figures — reviews given, reviewer matrices, time to first review. Those are classified when the review is stored, so your list reaches them.
What it does not fix yet, and you should know before you set it
Pull-request and issue AUTHOR figures still count PAT-driven CI accounts as
people. A figure like "378 people opened at least one pull request" is
filtered on the stored author name directly, and that filter cannot yet read
your setting. So setting botLogins will not change a head-count of pull
request authors or issue reporters, and re-running the ingest will not change it
either.
This is not something the release broke — the three Tetrate names never excluded anything you run, so those figures have always counted your automation. It is tracked as issue #1247, whose fix classifies each pull request and issue author when the row is stored, exactly as reviews already are.
Setting it today is still worth doing: it takes effect on review figures immediately, and it is the same value #1247 will use, so nothing you configure now has to be redone.
After upgrading, set it if your organisation runs CI under a personal access token, and re-run the ingest — that is what carries the change into stored review rows. If you were relying on the old behaviour, nothing you run was ever in that list.
"How many people" is answered by the figure, not by counting a list
If you have asked how many people did something and the answer came back as a number in the hundreds, it was probably right — and it was arrived at the wrong way, in a manner that stops being right as your organisation grows.
The answer was asking for a per-person breakdown, capping it at a thousand rows, and reporting how many rows came back. Under a thousand people that is the correct number by arithmetic accident. Over a thousand it is one thousand, whatever the real figure is, and a capped list looks exactly like a complete one.
Two changes, and the second is the one that holds whatever an answer does next:
- The count comes from the metric. Prism has figures that count people rather than pull requests, and an answer to "how many people" now publishes what one of those returns instead of measuring the length of a list. It also costs fewer questions of your data.
- A cut list no longer reports its own length as a total. When a grouped result reaches its limit, the field an answer would read for "how many" is not there — it is replaced by a floor, named as a floor, with a line saying the rows must not be counted. A number that would have been wrong is now absent, which is the only version of this a later change cannot quietly undo.
Nothing to configure. If you re-run a verification question whose answer was cut at the limit, the figure it shows will carry the floor under its own name rather than a row count under a name that reads like a total — the same fact it was always describing, said out loud.
The diagnostic bundle now says WHEN a credential's reach was measured
The bundle already reports how many repositories your GitHub credential could
reach when a run last looked. It now reports when that reading was taken,
as credential_reach_measured_at beside the count.
Why it matters: if the reach probe fails — GitHub unreachable, the credential rejected — Prism deliberately records nothing rather than reporting that your credential lost every repository it had. The consequence is that the exported count stays at the last successful reading. Two bundles a month apart showing the same number were previously ambiguous between "stable for a month" and "the probe has been failing for a month", and the second is the one worth knowing about.
The timestamp is a date and time and nothing else. No credential, no identifier, and nothing about which repositories are reachable — the same line this report already draws around the reach count itself.
A source last measured by an older version has no timestamp to report; its count is exported as before.
One person in your directory is one person in an answer
Ask about somebody by name and Prism looks them up in your organizational context stream. If that directory keeps more than one row per person — retained snapshots, or a view that is one row per resource per month — the lookup counted rows. One colleague held under three snapshots came back as "3 people match 'Milind Nagnur'", with three identical entries, and the answer was then told this had not resolved to one person and never to pick one.
So a question about somebody perfectly unambiguous was declined, and the reason given was that they were ambiguous.
Both the count and the list of candidates are now per person, keyed on the identifier your directory says a person IS. Nothing about who matches changes — several real people with the same name are still several people, and still ambiguous.
If your directory's manifest carries a filter that already reduced it to one row per person — the shipped roster recipe's "latest snapshot only" is one — you will see no difference; that filter was hiding this, and it still applies.
An answer now says when Prism chose the period, and you did not
Ask a question with no period in it — "how many people under Milind had at least one PR merged?" — and an answer comes back over the last 30 days. The window was always stated, in the heading above the figures. What was never said is that Prism picked it.
That is a small difference with a large one behind it. If you asked your first question "over the last 30, 60 or 90 days" and your next two without a period, the three answers read alike and only one of them covers a period you chose. Nothing on the page told you the other two windows were still available, or that the figure in front of you was measured over a default.
An answer that supplies a period you did not give now says so, in one clause, with what else you can have: "you did not name a period, so this is the last 30 days; ask for 60 or 90 and you will get the same over those." Nothing about the figures changes — only whether you can tell whose choice they rest on.
An upgrade no longer stalls on a question somebody is asking
If an upgrade has ever left a new pod stuck "starting" for minutes while the old one kept serving, this is the fix.
Every pod runs the app's schema at startup. Most of that work is nothing —
the tables already exist — but a few of the statements are ALTER TABLE, and
PostgreSQL takes the strongest lock it has before it can find out an ALTER
has nothing to do. So a pod starting while somebody is mid-question waits for
that question to finish. Waiting was unbounded: the new pod hung before it
opened its port, and the only thing in the log was one line from the web
server.
It also stalled the pod that was working. Once the new pod's schema change is queued for that lock, every later write to the same table queues behind it, even though those writes do not conflict with the question at all — that is simply how the database orders a lock queue. So one starting pod could stop the running one from recording anything.
Both are fixed by the same change: the startup schema now gives up after ten seconds instead of waiting indefinitely. Giving up takes it out of the queue, so the serving pod is writing again immediately.
What you will see if it fires, and it is a deliberate trade: the pod starts anyway and logs the failure, rather than not starting. Two things it does at startup are skipped — seeding the super-admin list and preparing the context stream registry — and both were already designed to be skippable. A pod that came up with something missing and said so is better than one that hangs with nothing in the log, which is what this replaces. If you see it, the pod that follows will almost certainly succeed, because the condition is somebody else's question and questions end.
And it stops asking for a minute after it fails. Giving up once is not enough on its own: the pod would simply try again on the next request, take its place in the queue again, and stall the serving pod again — measured at 93% of a window with the retries running back to back. So after a failure the startup schema backs off for sixty seconds, during which it refuses without contacting the database at all. That is what keeps the queue clear for the pod that is working.
During that minute the failed pod answers quickly instead of hanging, and the log carries one line saying what happened and when it will retry — one line, on the way in, not one per request.
Nothing to configure, and nothing changes on a quiet install: with no contention the wait is zero and neither the timeout nor the backoff comes near.
A day that was re-walked no longer counts its rows twice
If you have seen a source report more enrichments than it has pull requests, that number was wrong and it is now right. It could not happen before this release; it arrived with the change that splits a day whose enrichment hit GitHub's ceiling and walks the halves.
The split works by re-fetching: a capped attempt has already written its rows by the time the ceiling is discovered, and the halves fetch the same ground again. Your data was never affected — every write is keyed on the row's own identifier, so writing it twice stores it once, which is exactly why the split is allowed to discard an attempt and start over. What was affected was the count, which added the discarded attempt's rows and then added them again.
It showed on the Ingestion activity page and in the self-test bundle, on precisely the days worth looking at: the ones busy enough to need splitting.
A second figure, rows_written, now appears beside fetched in the bundle,
carrying the same corrected number. They are the same thing under two names for
one release: fetched is the name bundles have always used, and dropping it
now would blank that figure in every bundle sent from an appliance still on an
older version. rows_written is the name that says what it counts, and
fetched goes a release after it.
One thing the corrected number still is not: an exact count of distinct rows. A day split at its midpoint can see a row that falls exactly on the boundary from both halves. That is a handful of rows against the thousands this corrects, and it is why the figure is best read as "rows this run wrote" rather than "rows that exist".
What Prism needs from a namespace quota is now written down
limits.md sized the disk in detail and said nothing about CPU or memory. Those
figures have grown every release, and the only way to find the new one was an
upgrade that failed — which is what happened: a namespace ResourceQuota
refused pods on the pod count, and then, after that was raised, refused six
consecutive synthetic-seed Jobs on limits.cpu. A quota atlas pods, CPU and
memory independently, so clearing one can leave you refused a minute later on
another, with a message that looks like the same failure.
Compute, and the namespace quota you need now carries the per-component figures, the steady-state totals, and what to leave room for. The table is generated from the chart and checked on every change, so it describes the release you are installing rather than the release the page was last edited in.
The advice to size for the upgrade rather than the steady state is the part worth acting on: the synthetic-seed hook runs while the ingest jobs are still going, so the peak is roughly double, and a quota that fits a running Prism can still refuse pods halfway through your next upgrade.
The bundled Postgres now declares CPU and memory
It declared neither. Every other container in the chart sets both requests and
limits, for a stated reason — a namespace quota that atlas limits.cpu refuses
any pod that leaves them unset — and the bundled datapond was the one container
that broke the rule. It has been installing anyway wherever the namespace
supplies a LimitRange default, which is why this was not found sooner.
If your namespace atlas limits.cpu and has no LimitRange, this is the
release where the bundled Postgres starts up rather than being refused.
And if it is already running there, upgrade before you have to. A
ResourceQuota is enforced when a pod is created, not while it runs — so a
datapond started before the quota was added keeps running indefinitely and
cannot be replaced. Nothing looks wrong until something restarts it: a node
drain, an eviction, a rescheduled StatefulSet. Then the replacement is refused
at admission with must specify limits.cpu, and the database does not come
back until the pod declares one.
That is a latent outage rather than a warning, and this release removes it. If you raised a quota on a namespace where Prism was already installed, you are the case it describes.
And if it has been running unbounded, this is the release where it stops.
That is the half to check before you upgrade. With no limits of its own and no
LimitRange in the namespace, the datapond had no ceiling; it now takes the
chart's. The number is deliberately generous — a ceiling to contain a runaway,
not a size we are recommending, because your data decides that and we cannot
see it. But it is a change to a running database, so if your datapond is large,
or your namespace was supplying a higher default, raise
postgres.resources.limits — or put it back the way it was with --set postgres.resources.limits=null. That exact spelling: Helm merges maps, so
replacing postgres.resources wholesale leaves the chart's limits underneath.
The requests are unchanged in character — a floor for the scheduler, and small because a datapond at rest is small. It is the ceiling that is new.
The troubleshooting entry for must specify limits.cpu said the chart always
sets them and told you to look for an override. For that one pod there was
nothing to find. It now says so, and covers the exceeded quota failure beside
it, which is a different problem with a similar-looking message.
Mirroring instructions now cover the chart, not just the images
If your pipeline or your cluster cannot reach docker.cloudsmith.io, step 2
now tells you how to put the chart in your own registry. It never did. It
mirrored the ten images and said nothing about the chart, so the only copy was
ours — and an installer working in a closed environment had no documented way to
obtain one. There was no error message to search for either: a chart reference
pointed at your own registry simply fails as not found, naming a path that looks
like it should exist.
Three changes to that step:
- The chart is pulled as an archive and unpacked separately, rather than
with
helm pull --untar, which leaves you no archive to push. One download now serves both purposes — readingimages.txtand mirroring the chart — so the list you mirror from and the chart you install cannot come from different pulls. - A
helm pushstep, which publishes the chart under the same prefix as the images. Skip it if the machine runninghelmcan reach us; the images still come from your registry either way. It needs Helm 3.7 or later. - The chart reference in the install and upgrade commands now points at your registry, with ours named as the alternative.
What you can verify, and what you cannot. Mirrored images keep the digests
images.txt pins, because copying by digest is byte-preserving — so those can be
checked. A mirrored chart cannot: helm push rebuilds the OCI manifest, so
your copy has a different digest from ours by design. Nothing is wrong. Check
the version with helm show chart instead.
Your images.txt and the one in a handover pack differ by one line, and the
step now says so. The copy inside the chart cannot name the chart — a file
cannot carry the digest of the archive containing it — while the copy shipped
beside it can, and does. Neither is stale. The mirror loop skips that line
either way.
Pull requests with many reviews are no longer missing most of them
GitHub serves at most 50 reviews per pull request on the query Prism uses to enrich a day's pull requests, and it does not say when it has stopped. A pull request with 200 reviews stored 50 and looked complete.
That mattered wherever reviews are counted rather than dated. Review counts, reviewer coverage and any per-reviewer figure understated on exactly the busiest pull requests — the ones most likely to be asked about. When the first review happened was always right, because the earliest review is inside the first 50.
Prism now asks each connection how many reviews it holds, and fetches the rest where it fell short. Two consequences worth knowing:
- A pull request whose reviews were merely exactly 50 was previously reported as possibly truncated. It no longer is — the count is now measured rather than guessed, so the caveat appears only where something was actually missed.
- Where a pull request genuinely lost reviews, the run says how many, and fetches them from a second request.
It costs requests, and only where it bites. The extra fetch happens once per pull request that actually lost reviews, not per pull request — on a typical organisation that is a handful a day. It uses GitHub's REST budget, which is separate from the one the main walk spends, so it does not slow the walk itself.
There is a per-run limit, set to 200 pull requests, so an organisation with an unusual number of heavily-reviewed pull requests cannot turn one run into thousands of extra requests. If a run reaches it, it says so — in the log and in the coverage attached to the source — naming how many pull requests it could not get to, so a partial repair never reads as a complete one. If you see that, the limit is a value in the source's manifest and can be raised.
Image tags now follow the chart, so an upgrade is --version and nothing else
If you have been editing the chart's values.yaml to set image tags on every
release, you can stop. Every image tag now defaults to the chart's own
version, so a chart pulled at a version asks your registry for that version.
There is nothing to untar, nothing to edit, and nothing to --set.
This was worse than an inconvenience, and worth saying plainly. The tags
defaulted to latest, while step 2
tells you to mirror each image under the release version — which is what the
release actually ships, pgvector included. So on a mirrored registry the
documented procedure asked for ten images tagged latest that were never
pushed there, and every pod stopped at ImagePullBackOff. Anyone running Prism
from their own registry has already worked around this; the workaround is what
you can now delete.
An explicit tag still wins, and that is the one thing to check before you upgrade. If your values file carries image tags today, delete those lines. A pin still overrides the default, so a values file left over from an older release keeps pinning that release's images — and the upgrade then moves everything except the components you pinned, which installs cleanly and runs mixed versions. That is the failure this change exists to remove, so do not carry it forward by hand. Setting a single tag deliberately, to hold one component back while you investigate something, still works exactly as before.
Two consequences worth knowing:
- Nothing changes for an install that pins every tag. The rendered manifests are byte-for-byte identical to the previous release's, so an upgrade that changes nothing else changes nothing at all.
- The awkward value names matter less.
mcps.githubLiveandmcps.semanticMcpmatch neither the component names nor their sibling keys, and a mistyped path is accepted in silence — Helm takes any path, and the component simply keeps its old image. You no longer have to type either of them to upgrade. The names themselves are unchanged, because renaming them would break every install that has set them.
Test now works on your GitHub and Jira sources
Test on a context stream fetches one page from the source and checks that
every column the stream declares comes back. It could not do that for github
or jira on any installation: it reported that no credential was available and
made no request, while every source you had registered yourself tested normally.
The cause was where those two credentials live. A source you register keeps its
credential on its own row; github and jira keep theirs in the chart
(secrets.githubToken, secrets.jiraToken), which is where the ingest reads
them — and the component that runs Test was not given them. It is now, so Test
works on the two sources every installation has, which are also the two with the
most columns and the most history behind them.
What this means for where your credentials are. The semantic query
component now holds those two tokens as well as the ingest. They are the same
read-only credentials, it reads them only for a source whose name Prism ships a
recipe for, and only when that source has no credential of its own — a source
you registered under a name of your own is never given them, whatever it is
pointed at. If you would rather it did not hold them, mcps.semanticMcp.enabled: false removes the component and Test with it.
If a source still says no credential is available, the message now names the chart value to set rather than the credential form, and the most likely cause is an upgrade that moved the ingest and not the rest: the Secret is read when the pod starts.
Widening a credential's reach does not backfill the history — now said where you would look
Nothing about this behaviour has changed; what changed is that the pages an operator actually opens now say it.
Re-scoping a GitHub token from a hand-picked list of repositories to All
repositories, or swapping a token for an App installed across the
organisation, takes effect from the next sweep onward. Every day already
walked keeps the scope it was walked under. No ordinary run revisits it, and
nothing turns red: the two verdicts that mark an incomplete day — capped_days
and unenriched_days — both describe rows the walk knew it missed, and every
repository outside the old grant is a row it never knew existed. So a question
spanning the change answers low, confident and without a caveat.
The repair has always existed and is unchanged: a one-off
ingest.github.rewalkFrom (and ingest.jira.rewalkFrom for Jira), with the
procedure in Widen a source's scope.
What is new is that Rotate a credential and GitHub ingest both say so at the point where you are making the change, and that "Widen a source's scope" now explains why nothing will prompt you — so an operator who rotates onto a broader grant without thinking of it as a widening still meets the warning.
A manifest that names something this build cannot run is refused when you write it
The manifest format has a fixed set of choices in several places — how a source is authenticated, how its pages are walked, what a column may be transformed by, which records are dropped. Every one of those is a menu the ingest engine implements, and until now nothing checked that a document you wrote stayed inside it.
A document naming something outside it validated cleanly, registered, enabled, and then failed at the first scheduled run — reported under your source's name, as though your system were at fault. There was nothing in the message to act on and nothing to fix on your side.
Registering or applying a manifest now refuses such a document at the moment you write it, naming the part and where it is in the document:
/connection/auth/type: auth typeoauth_clientis on the manifest schema's menu but this build does not implement it …
Two things worth knowing about the refusal:
- It is about parts, not about whether it will work. "Every part is implemented" is not "this combination has been run". A document can pass this and still need testing against your system — use the manifest Test tier for that.
- There is no configuration that lifts it. Adding a menu entry is Tetrate engineering work, so if you hit this, the document cannot be edited around it — tell us which part you need.
The ingest tick applies the same check to documents stored before this release
and logs them unrunnable with the part named, instead of dispatching a walk
that cannot start.
The "not ported" message no longer quotes your own source name back at you
Enabling a source whose recipe the engine has no proven walk for used to say
"the manifest engine has no proven walk for the my_source recipe". For a
document you wrote, my_source is the name you typed — not a Tetrate
concept you had failed to match — so there was nothing in the sentence to act
on. It now says what is actually true: which walks this build has proven, that
every part of your manifest is implemented, and that an instance registered from
a proven recipe is walked under whatever name you give it.
A failed source no longer waits out its hour after you fix it
A source whose last run failed is held off for an hour before it is tried
again. That is right when something upstream is refusing us — it is what stops
a broken source calling your GitHub every fifteen minutes — and it had one
input: how long ago the failure was. So it survived the helm upgrade you
performed because it fixes that source, and for up to an hour afterwards the
product behaved as though nothing had happened.
Two changes:
-
An upgrade ends the backoff. An attempt is stamped with the version that made it, and a source that failed under an earlier version is tried at the next tick rather than waiting. It needs your chart version to have moved: an appliance running an unversioned chart (
0.0.0) has nothing to compare and keeps the previous behaviour exactly. -
Run now. Each source now has an explicit "run it now" —
POST /api/admin/sources/{id}/ingest/run-now— which the next tick honours, withiningest.engine.tickMinutes(15 by default). It overrides the retry backoff and the schedule, and it is a request rather than a run: nothing starts at the moment you send it. Sending it twice asks for one walk, not two. (It is an API call; no page has a control for it — earlier wording here said you "press" it.) It is refused on a source that is not enabled, because the tick does not consider one — a request stored there would wait and then start a walk you were no longer expecting.A request lasts until a walk honours it, however long that takes: nothing ran, so nothing you asked for has happened. While it is waiting the source's record says so (
run_request_pendingonGET /api/admin/sources/<id>), andDELETE /api/admin/sources/<id>/ingest/run-nowtakes it back — which matters for the one sequence that can leave one waiting a long time (request a run, then disable the source), where re-enabling months later would otherwise produce a walk nobody was expecting and nothing could explain. Withdrawing works whether or not the source is enabled.
Neither changes what a successful source does: a source that is simply not yet due stays not yet due, and an upgrade does not re-walk everything you have connected.
Troubleshooting has the manual escape hatch for an appliance still on an older release, and the caveat that goes with it — clear the one key, not the row, because that row also holds the resume point of an interrupted backfill.
A GitHub day that exceeds the search ceiling now says its enrichment is partial
GitHub's search answers at most 1,000 results for any one query. Prism's GitHub ingest walks a day at a time and has always handled that for the pull requests themselves — a day that hits the ceiling is split in half and re-fetched, down to fifteen-minute windows, and a window still too big is recorded so that every figure over that day reads as a floor.
The second half of the walk did not do this. After the pull requests are fetched, a second query enriches them — sizes, reviews, first-review time, issue links. That query hits the same 1,000-result ceiling, and GitHub reports the truncated answer as if it were the whole one. So on a busy day Prism enriched an arbitrary first 1,000 pull requests, and the run recorded the day as fully enriched.
Nothing was wrong with the pull request counts — those were, and are, complete. What was missing is the columns that come from the second query, on the pull requests past the ceiling: sizes, review counts, first-review times and issue links, all null. The five measures built on them were quietly narrower than they appeared.
The truncation is now repaired, and where it cannot be, it is disclosed. A day whose enrichment hits the ceiling is split in half and re-fetched — the same remedy Prism has always applied to the pull request walk itself — down to fifteen-minute windows. A busy day that was covered to 1,000 is now covered whole.
Where even a fifteen-minute window holds more than GitHub will serve, the
enrichment genuinely cannot be completed, and that is recorded rather than
papered over: an unenriched day carrying a pull_request_enrichment_capped
reason on the source's status, and in the run's log a line saying what it
covered and what was held. Every figure resting on such a day reads as a floor
rather than as a total.
This costs requests, and the enrichment is the expensive half of a walk. A day that needs splitting is fetched more than once — the attempt that hit the ceiling is paid for and then each half is fetched — so on an organisation over the ceiling every day, expect the GitHub enrichment to take roughly twice as long as it did while it was quietly stopping at 1,000. It is the same walk covering more than twice as much. If you run close to your GitHub rate limit, that is the change to watch.
You are affected if a GitHub organisation you ingest exceeds about 1,000 pull request touches in a day. Small and mid-size organisations never reach it. If you widened a GitHub credential recently — from a handful of repositories to an organisation-wide grant — you are much more likely to be over it than you were before, and the days walked since that change are the ones to look at.
Rows already mirrored under the silent cap are not repaired by upgrading.
They are on disk now, partially enriched, and nothing marks them. To re-fetch
them, re-sweep GitHub from the earliest date you want covered —
--set ingest.github.rewalkFrom=<YYYY-MM-DD>, the same lever and the same
procedure as Go live. Days re-walked after the upgrade will
disclose their own coverage.
Which repositories the live GitHub lane may answer about
Prism's live GitHub drill-down holds one credential and, until now, could answer about every repository that credential could read. That is a decision your platform team makes rather than one this appliance could see: a token re-scoped from forty repositories to an organisation's thirty-four thousand produced no error here, no log line and no change in behaviour — the lane simply became able to answer about all of them.
mcps.githubLive.allowedRepos is where you write down the repositories this
appliance may answer about:
mcps:
githubLive:
allowedRepos: "acme/payments,acme/ledger,acme/settlement"
Set it and a live search is confined to those repositories, a question that reaches for any other — or for an organisation — is refused with the allowed set in the message, and every answer carries the scope it was drawn from.
Left empty, nothing changes, and the answers now say so. Empty means every
repository the credential can read, which is what this release upgrades from —
and each response states that in words rather than leaving it unsaid. It bounds
the live lane only: figures from the index come from what the ingest already
walked, and setting this removes nothing already mirrored. A value that is set
and cannot be read as owner/repo entries stops the live GitHub component from
starting, naming the setting, rather than quietly meaning "all of them".
Limits has the detail.
Jira answers are grouped by an identifier and labelled with a name
Where Prism could not resolve a Jira assignee to a GitHub login — which, since the built-in user directory was retired in 0.10.0, is most people on most installs — it used their email address as the grouping key. That key is what a table's rows and a chart's ticks are labelled with, so a Jira answer about people was an answer labelled with corporate email addresses.
It now groups on a stable identifier and labels with the person's display name, as Jira holds it. Nothing about the figures changes.
Issues already mirrored are corrected in place, once, the first time the
ingest connects after the upgrade — no re-walk and no re-ingest. That is the
ingest specifically and not the app: on a six-hour schedule it can be hours
after the helm upgrade, and if you check a chart ten minutes afterwards you
will still see the old keys. An installation whose ingest is switched off, or
failing for another reason, keeps them until it runs. The address itself is
kept, in its own column, which nothing answers from and which is what lets a
person be resolved to a login later.
Read this next part before you look at an answer. The correction can only
change the key; it cannot invent the display name, which Jira supplies and
which arrives on a fresh walk. So on an issue that was already in the mirror,
an unresolved assignee is labelled with their identifier — jira: and sixteen
characters — rather than with a name. On an installation with a large Jira
history that is most rows at first, and it will look worse than the addresses
did.
It corrects itself as Jira touches issues: any issue updated after the upgrade
is re-walked and picks up its assignee's name. To fix the back catalogue in one
go, re-walk the source — ingest.jira.rewalkFrom, set to the date you want
names from — which refreshes the rows in place rather than duplicating them.
How Prism reads Jira has what that costs.
Jira Data Center: a walk no longer stops early on an instance that atlas its page size
Jira Data Center applies jira.search.views.default.max to maxResults on the
server, silently. Prism asks for 100 issues a page; an instance configured below
that returns fewer and says nothing, and the walk read the short page as the
last one — fetching one page and then completing. Because the run completed, the
watermark advanced, so every later run only looked for issues updated since. The
issues in between were never fetched and never would be, and nothing in the run
said so: no failure, no coverage note, just a count that was quietly too low.
Prism now follows the total the response itself reports, and treats a short
page as the end only where the response states no total.
If you run Jira Data Center, check your issue counts after upgrading. If
this was happening to you, the figures were a floor and not a total, and the
gap grows with the size of your Jira. Upgrading fixes the walk from here on; it
does not fetch what was missed. To recover that, re-sweep Jira from the earliest
date you want covered — --set ingest.jira.rewalkFrom=<YYYY-MM-DD>, the same
lever and the same procedure as Go live.
You are affected only if your administrator lowered jira.search.views.default.max
below 100. The default is 1000, and instances at the default were always walked
whole. Jira Cloud is unaffected: it pages by token and never had this shape.
A source with one unreachable partition no longer empties and re-fills its tables on every run
Since 0.12.0 a run that cannot reach one of a source's partitions — a GitLab group the token cannot see, a Jira project that stopped answering — completes as a partial run rather than failing: the partitions that did answer land their rows, and the one that did not is named on the source's status. That is the right behaviour and it is unchanged.
What was wrong is what happened next. A partial run deliberately records no watermark, so the unreachable partition's window is not written off as already ingested. But a source with no watermark looked to the next run exactly like a source being ingested for the first time — so that run truncated the tables before walking, then re-fetched the whole backfill window over the partitions that do answer. And then the next scheduled run did it again. On a source whose partition is permanently unreachable this repeated indefinitely, and the source's row stayed green throughout:
- readers saw the tables emptied and slowly re-filling during every run;
- a full backfill's worth of API calls was spent on every schedule, against a rate limit shared with everything else;
- nothing said any of it was happening.
A run following a partial one no longer truncates. The rows it finds are its own predecessor's, and it walks on top of them.
It still re-walks the whole backfill window, because a partial run has no progress to resume from. So the situation is now merely expensive rather than destructive, and it should still be fixed at the source. The run's own PARTIAL line in the ingest log now says so, and names both levers: restore the failing partition's access, or remove it from that source's partition parameter so the source can complete a run and record a watermark.
If you are on 0.12.x and cannot upgrade yet, setting
INGEST_TRUNCATE_ON_FIRST_RUN=0 stops the destructive half — the repeated
re-walk remains. On this release you do not need it for this.
"What Prism can answer" now lists the context streams you registered
The panel at the top of What Prism can answer — the sheet that tells a reader which data this installation holds — was built from a fixed list of five sources written into the app. Every context stream you register on the admin page was missing from it, however completely it was set up: you could register a source, give it a credential, test it green, approve it, switch it on, watch the ingest fill its tables and get answers out of it in the chat, and the panel would still not mention it. There was no setting that changed this.
It now lists every context stream this installation has switched on, beside the five built-in ones. Each registered stream is shown under the label its own manifest gives it, holding the streams its own manifest declares, with the rows it holds and how far its data reaches — read from that source's own table and its own ingest record, not from anything hard-coded.
What this changes for you:
- A stream you have registered and switched on gets a card. If it holds
rows, it reads
connected; if the ingest has not filled it yet,no data yet; a stream Prism queries through to your warehouse rather than copying has no local rows to count and readsstate unknown. - A stream you have registered and not switched on gets no card, on purpose. It is not data on this installation yet, and the admin page is where a registration in progress is worked on.
- A stream you have verified a question against reads
connectedrather thanconnected — no verified questions yet, and that question is now offered beside its card. It was already being offered; the card it pointed at was the thing that was missing.
The self test's source registry check is affected in the same breath. It
used to report every registered source as registry_only — meaning "absent
from the built-in lists, and expected to be". It no longer excuses the panel
that way: a switched-on source the panel does not list is reported as a
disagreement again, and only a source nobody has switched on is expected to be
absent. If your 0.12 bundle showed registry_only naming sources you had
enabled, that line will be shorter after this upgrade — and it says something
stronger.
Nothing changes on an installation with the source registry switched off
(sources.registry.enabled: false): the panel is the five built-in sources, as
before.
A refresh that came back short of a group now says so where you are looking
When a source walks per group — GitLab groups, and any context stream you have given a partition — a refresh can fetch some groups and not others. That run completes: the source shows a recent last-refreshed time, its rows are there, and it goes on answering questions. Everything on every admin page reads green, and the figures over the missing groups are quietly too low.
Since 0.12.0 an answer has said so, naming the groups that did not finish. The admin pages did not: the fact was recorded in Prism's own bookkeeping and shown nowhere. So an operator asking "why is this source short?" had the sync's pod log or a question typed into the chat, and neither is where they look first.
Two pages now carry it:
- Admin → Context streams — the source's row says how many groups did not finish the last refresh and names them.
- Ingestion activity — the same figure on the source's card, as
partitions short in last run, directly underlast completed run, which is the line it qualifies.
Both name the groups: the first five, then "and N more" — the same names and the
same cap an answer gives, so a page and a chat reply about one refresh cannot
say two different things. none is a claim worth seeing after you have fixed
one; no line at all means the source keeps no such record.
The self test still reports the count and not the names, and that difference is deliberate. Its report is a file you may hand back to us, and a group path is your configuration rather than ours to publish; the two admin pages are read by your own operators on your own appliance, where the names are what makes the fault actionable.
Ingestion activity lists the context streams you registered
The same page showed a fixed list of built-in sources, so a context stream you registered and switched on had no card on it — and the two built-in sources it did show are the two that cannot come back short of a group, which is why the line above had nothing to appear on. It now shows a card for every stream you have enabled, with its own state, its own last-refreshed time and its own partition line.
A stream you have registered and not switched on gets no card: it is pulling
nothing yet, and Context streams is where a registration in progress is worked
on. A stream that has never run reads NEVER COMPLETED rather than NOT LIVE —
it is switched on and waiting, which is a different thing from switched off.
runs every and the rate ceiling are blank for a registered stream: those come
from the built-in sources' own settings, and Prism does not invent a figure it
cannot check.
The self test now says WHY forge addresses do not match your staff list
Prism harvests email addresses from your forge — a member's public profile address, a commit author line — and joins them to the addresses on your staff list to work out who did what. Since 0.12.x the self test has reported how well that join does: "4,589 members looked up, 2,953 pairs held of which 1,342 carry an address this directory names".
That number told you there was a problem and nothing about which problem, and the two possibilities want opposite responses. If people simply never put a corporate address in their forge profile, that is a coverage fact to live with and disclose. If the addresses are there and the join is missing them — a second corporate domain, an alias domain, a different naming convention — that is something to fix, and fixing it recovers real people.
The line now splits the addresses that match nobody three ways:
- a local part your staff list holds at a different domain. The same person
spelled two ways (
[email protected]against[email protected]). This is the one to act on — change one spelling and the person resolves. - a domain your staff list never uses — usually a personal address left in a public profile.
- a domain it does use, under a local part it does not hold.
Expect the third to be much larger than the other two, 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 wrong guess there attaches one
person's work to another.
…and how many members were skipped because of the address allowlist
Beside it, the self test now reports members Prism asked about and kept nothing for, by reason, counted over every member it has ever looked up rather than the last batch. Until now these counts existed only in the sync's own log for one run, so nothing you could download carried them.
The one to look at is domain_not_allowed. That is
ingest.github.identityEmailDomains doing exactly what you set it to do — but
if your organisation has people on a second corporate domain, this is the
number that says how many, and they are currently left out of the crosswalk
entirely. Whether that domain belongs in the allowlist is your decision; Prism
reports the figure and changes nothing on its own.
Give it time before you read anything into it. The reason is recorded against each member as Prism looks them up, and that look-up is a rolling pass of a couple of hundred members at a time — so everyone swept by an earlier version carries no reason until the pass reaches them again. On an organisation of several thousand that is weeks. The line reports how many are in that state beside the reasons themselves, and while that number is large an empty list of reasons means "almost nobody has been asked yet", not "nothing is being refused". The self test says so in those words rather than leaving a zero to be misread.
None of this names a domain, an address or a person. They are counts, for the same reason the rest of the report is: it is a file you may send back to us.
A source whose stored document this version cannot accept says so, instead of reading as connected
Every context stream carries the document it was registered with, and every upgrade re-reads it. Until now the three things that read it did not agree: the sync refused a document this version will not accept and skipped the source, the query engine refused it and stopped serving the source — and the admin page read it with a lenient reader that validates nothing, so the row went on showing the source as connected and enabled, with its datasets listed, while nothing refreshed it and nothing answered from it. The only trace was in the two pods' logs.
Admin → Context streams now checks the stored document the same way the sync and the query engine do. A source whose document this version refuses reads needs setup, the line under it quotes the first thing the validator objects to, and the detail pane lists every objection with the exact place in the document it is about. Correct it on the Manifest tab and the row returns to its normal state.
You may see this on a source that read as connected before the upgrade, and if you do, it was not being refreshed or queried before the upgrade either — the change is that the page now says so. It happens where a release tightens what a document may contain and a stream you registered earlier no longer qualifies. Nothing is deleted, nothing is switched off, and the row stays fully editable: this is a report, not an action.
The chat side stops offering it too. Such a source no longer appears on the What Prism can answer panel, and a question somebody had verified against it is no longer suggested — the query engine has dropped the source, so both would be offering something nothing can answer. It reappears on both as soon as the document is corrected. This is the opposite direction from the panel change above and for the same reason: the panel lists what can be asked about, and that is not the same list as what has been switched on.
Sources whose document this version accepts are unchanged, and a document the page cannot read at all still reads manifest unreadable as before — that is a different fault with a different sentence.
Prism no longer expects your staff directory to carry GitHub usernames
Every question Prism answers about your people is preceded by a short internal description of which context stream you have designated as your directory of people. That description said the directory's own columns hold each person's GitHub username.
For a directory read from a warehouse table that is often true. For one built from a file your HR system exports it is not — that export holds a corporate email address and an employee id, and the GitHub username is matched to it separately, by Prism, when the ingest runs. So Prism could go looking in your directory for a column that was never going to be there, and report a question about those people's pull requests or reviews as impossible to answer when it was not — occasionally proposing, as the remedy, that we build the very thing it already had.
Prism is now told what is actually true: the join between your directory and your other sources is made by Prism itself, inside the query, whether or not the directory happens to carry a username column of its own.
What this does and does not change. It corrects what Prism believes about your data. It does not change any figure, and it does not change what Prism refuses — a question that genuinely cannot be answered is still refused with a reason. We were not able to measure how often the old wording caused a wrong refusal: it was intermittent, and the same question answered correctly far more often than not. So treat this as a cause removed rather than a behaviour you should expect to see change.
The coverage caveat is unchanged and still applies. Prism can only include a person in such a figure if it has matched them to an account on the source being asked about, and on most installations that is a fraction of your directory. Any answer scoped to a group of people states how many of your people it could match, and that sentence is part of the answer rather than a footnote.
Prism tells you when a credential's reach changes
Widening a GitHub credential's scope has always been invisible to Prism. Nothing failed, nothing turned red, and the newly reachable repositories simply started appearing in the sweeps that happened afterwards — while every day already walked kept the narrower scope, with nothing anywhere saying so. The two warnings that normally mark an incomplete day could not help: both describe rows a walk knew it missed, and a repository outside the old grant is one it never knew existed.
Ingestion activity now carries a credential reach line for a source whose
credential can see more, or less, than it could when Prism last looked.
- It reaches more than it did. You are told which days were walked under the
narrower grant, that nothing else will flag them, and given the exact value to
paste —
ingest.github.rewalkFrom=<the first of those days>— to fill them in. The notice narrows as the re-sweep covers the span and clears when it has covered the whole of it, so you can watch the work land rather than guessing whether it worked. - It reaches less than it did. What Prism already holds is unchanged, but figures from here on will cover less, with nothing else to show for it. This is the accident that has no error at all — a replacement token narrower than the one it replaced — and the fix is the grant rather than a re-sweep. This one stays up for about a working week rather than clearing after the next run: nothing else anywhere will report it, and its effect continues for as long as the grant stays narrow. It does clear on its own, because tightening a grant deliberately is a reasonable thing to have done.
- The credential changed but its reach did not. Said plainly, and it clears itself after the next run. No action is needed if you rotated it deliberately.
Prism never re-sweeps on its own. At a large organisation's scale, filling that history is hours of walking and a full backfill of your API budget, and an appliance that spent that because somebody rotated a token would be worse than one that names the dates and points at the lever. The decision stays yours.
What will not raise it: a repository or two created in your organisation. Prism only speaks when the change is bigger than ordinary churn — otherwise every new repository would claim your history was incomplete and offer you an expensive remedy for it. The count it measured is recorded on every run regardless and appears in the diagnostic bundle, so a slow drift is still visible even where no single run reports one.
Nothing here changes a figure, and nothing is sent anywhere: the check is one extra API call per organisation per run, and the credential itself never appears on the page or in the bundle.
Widen a source's scope is the procedure this points you at, and it is unchanged.
A busy Prism now says so, instead of a page that never loads
Prism holds a small set of connections to its own database and takes one for the length of each query. That set was three, and a request that could not get one waited for ever — so under a handful of concurrent users, or behind a single query that had got stuck, a page would sit there loading with no error and no timeout. The only cure was to restart the app, and nothing anywhere said that was what was needed.
Two changes, and they go together:
- The pool is now ten connections, and you can set it —
app.datapondPoolSize. Three was never a sizing decision, and at three, three people using Prism at once was enough to fill it. - A request that cannot get a connection within ten seconds is refused, with a 503, rather than hanging. This covers every part of Prism that reads or writes its own tables — the chat, sign-in, the admin pages, the self test — not only some of them.
What you should do about a 503 depends on which one you get, and Prism tells you:
- "too many requests are using the database at once" — the pool really was
full. Under real concurrent use that means ten is too small for you: raise
app.datapondPoolSize. When nobody is using Prism it means a query is stuck, and a bigger pool would only delay your noticing; restart the app and tell us. - "Prism cannot reach its database right now" — the pool was not full at all. Raising it changes nothing. Look at the database and the network to it.
This setting is the chat application's, and only its. The data refresh jobs, the query engine and the built-in stream servers open a connection for each task and close it again rather than keeping a pool, so there is nothing for this number to limit there and they are unaffected by changing it — but they do still use connections on your server, which is why the sizing below counts them.
Before raising it, check what your database server allows. Limits now has the arithmetic: the whole appliance uses about 41 connections with the default pool, wants a server allowing at least 55, and the bundled Postgres allows 100. A small managed tier can allow as few as 35 in total, which is below what Prism needs before you change anything.
Prism also now ends a transaction left open and idle for thirty seconds. That is the residue the timeout above does not catch — a request that died holding one used to keep its locks until the app restarted, blocking later administrative changes.
A question that will not finish is stopped, and says which part was slow
Every statement Prism runs against a source had a time limit; a whole question did not. A question about a group of people runs up to three of them — finding the leader, working out who is in the group, then the question itself — so one question could take 45 seconds, and a page that has been loading that long looks broken.
A question now has about 30 seconds. Past that it is stopped and the refusal says which part ran out of time — "the population step did not answer within 32s" — rather than a bare timeout that tells nobody anything. Where that happens, ask for a narrower window, fewer breakdowns or a smaller group of people; if it keeps happening, the source itself is slow and worth looking at.
Prism does not answer with a partial figure and a caveat. A number that is quietly incomplete is worse than no number.
The 30 seconds is not a setting: it is derived from the per-statement limit you
already control (mcps.semanticMcp.statementTimeoutSeconds), so raising that
raises this with it and the two cannot drift apart.
We know 30 seconds of silence is a poor experience. Showing progress while a question runs is real work and is scheduled for the release after this one; a fast refusal that says which part was slow answers "is it stuck?" well enough to ship without it.
Asking the same question twice no longer asks your system twice
A reader who thinks a slow page has hung asks again — the normal thing to do — and until now that started a second run of the same question against your system rather than replacing the first. The second ask is now refused with "this same question is already running", and the first one's answer arrives as it always would.
A limit on how fast questions reach your own systems
For a context stream Prism reads live from your own database — an Oracle or Snowflake view — there was no limit on how often a reader's questions could reach it. The stream's manifest already carried a rate for the data refresh, and nothing on the reading side ever used it.
Questions to such a stream are now paced, at 60 a minute by default
(mcps.semanticMcp.readsPerMinute). That is one a second: invisible to a
person asking questions, and a ceiling on a runaway loop, which is what this is
for. Lower it if your database administrators have agreed a rate with you, or
if the view is expensive. 0 turns it off.
Requests are spaced rather than allowed in bursts, so sixty questions cannot all arrive in the same second. A stream's own manifest can set a lower rate for that stream alone, and a registered instance can set one lower still — so one instance of a recipe can be paced differently from another.
This applies only to streams read live from your systems. Nothing Prism holds itself is affected, because the only system this protects is yours.
A warning about your request rate that you may not have seen before
Prism refuses to start if your request rate and your run deadline cannot finish even an empty day of history, and warns — without refusing — when they might not finish a busy one. That warning has always been there. What it believed a busy day costs was wrong, and this release corrects it.
A day whose activity exceeds what one search can answer is split in half and re-asked, repeatedly, down to a quarter of an hour. That is how Prism gets complete counts out of a large organisation, and it was added in this same release. The warning was still estimating a day at the cost of a single search — about thirty requests, where a heavily-split day can be nearer three thousand.
So the warning could not fire where it mattered. It would have stayed silent on a configuration that genuinely could not finish a busy day, right up to the point where the mirror stopped filling.
What this means for you, and for most installations it is nothing. At the
default of 30 requests a minute against a 24-hour deadline, nothing changes and
nothing is printed. If your ingest.github.maxRequestsPerMinute is set below
about 2 requests a minute, you will now see a line in the ingest log that you
have not seen before:
WARNING: at 1.0/min a BUSY day bucket over 1 org(s) can need 46.8h,
more than this run's 24.0h deadline
Read it as a correction, not as a new fault. Nothing about your installation has changed and the walk was always this expensive; the guard was under-counting and now is not. If your row counts have been advancing, they will carry on advancing — a quiet organisation's days never split at all, so most days cost the three requests they always did.
It is worth acting on if the mirror has ever seemed to stall: raise
ingest.github.maxRequestsPerMinute if your GitHub can take it, or raise
ingest.runDeadlineSeconds, which is a ceiling rather than a target — nothing
runs longer for being allowed to. Prism still starts and still walks; this
is a warning and not a refusal, and the refusal — which fires only when an
empty day cannot finish — is unchanged.
A trend that crosses a credential change no longer reads as a trend
If you widen a GitHub credential's reach, the days Prism already walked keep the narrower one until they are walked again. Ask for activity across that date and the two halves are not comparable — the earlier buckets are low because less was visible then, not because less happened.
Asked for a month-by-month trend over such a window, Prism would answer with a rising line and call the coverage clean. Nothing in the figure revealed that the rise was the grant changing rather than the work.
An answer whose window spans a recorded change now says so, on the figure.
It names the date, says the buckets either side of it are not comparable, and
says not to report a rise, a fall or a percentage change across it — with the
ingest.github.rewalkFrom value that would make the comparison possible.
Three things worth knowing about when it appears:
- It applies to a narrowing as much as to a widening. A credential that can see less than it could makes a trend fall for the same reason — the halves were walked at different scopes. A replacement whose reach did not change is also flagged, more weakly: Prism records how many repositories a credential reached and not which ones, so it can tell you the credential changed and cannot tell you whether it reaches the same repositories.
- A question that sits entirely on one side of the date is untouched. Those buckets were all walked at one scope. A source that changed scope once does not carry a caveat on every question about it for ever.
- A total, rather than a trend, gets a different line — that every figure is a floor, because the earlier days count only what was visible then. That one applies to a widening only, since a narrowing does not reduce what is already held.
This changes no figure. It changes what an answer tells you about one.
A population question now tells you what the number is out of
If you have asked "how many people under <leader> opened a pull request" and
been given a count you could not turn into a percentage, this is the fix.
Prism answered that question with a figure — say 378 — and then reported its coverage directory-wide: "2,953 of 4,602 people have a resolvable GitHub login". Both numbers are true and neither is the one the reader needs. The question asked about one organisation; the ratio describes the whole installation, most of whom the question excluded. And 378 out of what was never stated at all, so the two questions people actually ask next — what share of this org opened a PR, what share did not — could not be answered from the answer.
The coverage line now leads with the population's own two numbers:
POPULATION COVERAGE: this population holds 431 people, and 402 of them have a resolvable
github_login. 431 is the denominator for any share or percentage over this answer…
The directory-wide pair still appears, but only where it says something the cohort's numbers do not — and it is now described as everyone this installation can identify at all, which is what it counts. It was labelled the directory's size, and it is not: it is the number of people the installation holds an identifier for, which on most installs is smaller.
Why that relabelling matters on an install like yours. If your directory was loaded from one leader's organisation rather than the whole company, then "directory-wide" reads as "across the company" while meaning "across this one org, which is all we hold" — the more alarming reading, and the wrong one.
Nothing to configure. The extra numbers are one further scan over the set the query already selects, so a scoped question does not get slower in any way you would notice, and if that scan cannot be made for some reason the answer is unaffected: it falls back to the coverage line as it was, and says that it could not measure the population's own rate.
Mirroring: what the procedure needs, and the flag Apple Silicon needs
Three things the mirroring step assumed you already knew. If you have mirrored a previous release successfully, none of this changes what you do.
docker pull needs --platform linux/amd64 on an ARM workstation — any
Apple Silicon Mac. Nine of the ten images are built for amd64 only, so without
the flag Docker refuses them with "no matching manifest for linux/arm64/v8".
What made this worth calling out rather than just documenting: pgvector is
genuinely multi-arch and pulls without the flag, so the first image you try
succeeds and the next nine fail — which reads as nine broken images rather than
one missing flag. skopeo and crane are unaffected; they copy every
architecture without running anything.
The tools the step needs are now named — helm, plus one registry-copying
tool. The examples use skopeo; crane and docker also work, with the ARM
caveat above.
CLOUDSMITH_USER and CLOUDSMITH_TOKEN are now introduced where the other
placeholders are, with a line on setting the token without leaving it in your
shell history. They were previously used without ever being explained.
Nothing about what gets mirrored has changed, and digests are preserved on every route as before.
0.12.1 (8 September 2026)
"How many people" is now a figure you can ask GitHub for
Every GitHub measure counted things, not people. merged and opened count
pull requests, reviews_given counts reviews — so "how many engineers on this
team opened a pull request last quarter" had no figure to ask for. The number
was reachable only by grouping an answer by author and counting the rows that
came back, which is a shape of the response rather than something anyone
requested.
Three new measures on the GitHub pull requests stream close it. The first two are on the pull requests themselves; the third is on the reviews:
authors— how many different people opened a pull request in the window;merged_authors— how many had one merged (a different window and a different clock: someone who opened three and merged none is in the first and not the second);reviewers— how many people submitted a review, besidereviews_given, which counts the reviews themselves.
They answer "how many did", not "how many did not". The second half needs a list of the people the question is about — a staff list registered as your organizational stream — and is a different question with a different denominator.
Read these as accounts, not as a headcount. They count distinct GitHub accounts, so somebody with two accounts counts twice — and every account that opened something is counted, including accounts your installation cannot match to a named person. Against a staff list the figure is therefore neither a subset nor a superset of your people: it holds accounts nobody could put a name to, and it holds nobody who opened nothing.
Where a question names a group of people — everyone under a given leader — only people whose GitHub account your installation could resolve can be matched at all, so the figure is a floor on that group rather than its size. Read it with that in mind: the difference between it and the group's real headcount is partly people who did nothing and partly people nobody could match, and the figure does not separate them.
Some answers carry a coverage line as well. Read what it actually says — it reports how much of your whole directory has a resolvable GitHub account, which is a different denominator from the group you asked about, and the line says so itself. How much of a particular group was resolvable is not something Prism measures today.
A fifth verification question ships with the source so you can check the figure against your own data on the Verification screen. It asks for the people count and the pull-request count together, because the two never add up.
A question can name several job families at once
"How many developers merged a pull request" is answered against whichever job families your installation has declared as developers, and that declaration is yours to write — nothing about that changes here. What changes is that the person asking can now name the families themselves, in the question: "how many people in SW Development and Software Architecture merged a pull request last month" gets a one-line answer without anybody editing a document.
Before this release a question could be scoped to one value of a directory field or to none. A single-source question could always name a set — "how many people in these four families are in the directory" was always askable of the directory alone — but the moment the question crossed to another stream, which is the case that needs the directory in the first place because GitHub rows carry no job family, the set had nowhere to go.
This is not a way around declaring your families, and it does not replace it. It is here because one installation can hold more than one legitimate answer at the same time: Quality Assurance probably belongs in developers for a security-training question and probably does not for a pull-request-throughput one, and a single declared list cannot be both. The declaration is what your installation counts when nobody says otherwise; naming the families is how one person says otherwise, for one question. If people keep asking with names your export does not use, that is a sign the declaration should carry them.
Three things hold whichever route the answer took:
- The answer names the families it used, so a reader can see whether a figure came from your declared set or from the asker's, and argue with the definition rather than with the number.
- Values are matched as your directory spells them, ignoring case, with no guessing at near misses. A family the question names and your export does not carry contributes nobody rather than approximately somebody.
- Any floor you have set still applies. Naming a set of families is not a route around a per-person floor — a set can name a single person as easily as a single value can, and it is treated the same way.
If you have set a per-person floor, it now covers people counts
The per-person floor (disclosure.perPersonFloor, off by default) withholds
statistics about small groups. It never covered counts on the GitHub source,
deliberately — a row there is a pull request, not a person. The three measures
above are the exception: they count people, so they are withheld below the
floor like a median is, and the observation count beside a withheld one is
withheld with it (for a count of people the two are the same number, so
publishing one published the other — the same correction applies to a plain
count on any source whose administrator has floored counts).
Where nothing was found at all, nothing is withheld. A figure over no observations is not a statistic about anybody, so an empty window, or a source whose data has not loaded, now reads as "there is nothing here" and never as "this was withheld to protect someone". Those two readings are not interchangeable, and only one of them is ever true of an empty index.
What that floor is and is not. It withholds statistics; it is not an access control, and it is not a boundary around who did what. The same question asked with a grouping by author still returns each person as a row — the figures are withheld, the names are not — and the drill-down behind a figure returns the underlying pull requests. So on this source a withheld people-count is the length of a list the same reader can ask for. If you need people's activity to be invisible to a reader, the floor is not the control that does it.
Nothing changes on an installation that has not set a floor, which is the shipped default.
0.12.0 (7 September 2026)
The staff-list recipe names everybody, tells leavers apart, and carries a seniority band
Three corrections to the People file (CSV upload) recipe, all found on a real HR export and all wrong for anyone uploading one. If you have already registered a roster, your stored document is unchanged — these reach you when you next start one from the recipe, and the same three edits are worth making by hand to a document you already have. Upload a staff list walks all three.
- A blank preferred surname no longer leaves somebody unnameable. A
Preferred Last Name column is optional in most HR systems and a real export
is mixed — filled for some people, blank for others in the same file. The
recipe mapped the surname from that column alone, so those people resolved
through the crosswalk perfectly and could not be named in an answer, and
nothing about the upload looked wrong. The recipe now declares both surname
columns (
family_name_preferredandfamily_name_legal) and prints the preferred one where it is there, the legal one where it is not. The advice this page used to give — map both names from the legal pair if your preferred columns are sparse — was wrong on a mixed file and has gone: all-legal discards the preferred name of everybody who has one. - Leavers no longer count as current staff. The standard extract carries
them (Workday's is named All Active and Terminated Workers) and the recipe
had no column to tell them apart, so every ordinary question about "the team"
silently included people who had left. An
Active Statuscolumn and anis_activefield are now carried, applied by default, so questions read current staff — and a question about leavers can still ask for them by name. - "Individual contributors only" is answered from a declared band rather than
guessed from job titles. A new
Management Levelcolumn carries your HR system's seniority bands. It is the only field that says whether somebody manages anybody: a business title can read "Director" while the band says Individual Contributor. The values are yours — the list is configured on your own HR tenant, so nothing is shipped or assumed, and a verification question prints your own bands with a count beside each. Read it before you rely on the field: one real export carries a Manager band and a Supervisor band alongside the obvious ones (so an ICs-only question has to name the individual-contributor band rather than exclude the senior ones), and about 9% of its rows carry no band at all (so those people are set aside and counted, not folded onto either side).
Delete any of the three columns your export does not have — the recipe says what else goes with each — and the upload behaves as it did before.
The same release stops the recipe describing two other controlled lists as
though we knew their values. worker_type's description said "employee,
contractor, intern"; one real export spells its two values Employee and
Contingent Worker and carries neither of the other words. Both that field and
management_level now say the vocabulary is your HR system's, illustrate with
a measured example, and tell an answer to list back the values it used. If
your export's vocabulary differs from the examples, two things are yours to
change: the 'Yes' in the is_active field's sql:, and any seniority
filter you write — both are tenant-specific values, not ours.
The self-test report no longer carries a credential typed into a URL
MCP_PROFILES, RUNNER_URL, ROUTER_PLATFORM_URL and ROUTER_PROXY_URL are
free text, and http://user:password@host/mcp is a legal value in each. The
self-test report — the page you screenshot, the file Download saves, and
what python -m prism_app.selftest prints — recorded those URLs whole in a
check's evidence and in its configuration block, so a password written into
one travelled with the report. Every check now records the host and the path
it called and never the URL, a transport error's message has its URLs
redacted before it is recorded, the configuration block echoes each URL as
its scheme, host and path only, and a value typed without its http:// is
recorded as malformed rather than quoted. Nothing else in the report moved. If you have forwarded a report from an install whose URLs
carry a credential, treat that credential as disclosed and rotate it.
A GitHub login is found whatever case it is asked in
The GitHub stream stores each login exactly as GitHub spells it, and a
question naming a person matched that spelling exactly. So alicedev against
an account GitHub spells AliceDev came back as "not a login" from the
pull-request statistics,
and — worse — as an empty review matrix with a concentration figure computed
over nothing, which reads as "nobody reviews this person's work". Both tools
now compare logins case-insensitively, inside the database, and the review
matrix reports a string that is nobody's login as unknown_authors the way
the pull-request tool always has, instead of answering it with an empty table.
Figures still carry the login as GitHub spells it, including the row for
someone with nothing in the window asked about. The upgrade adds one index to
the pull-request table (pr_author_lower_idx) on the next ingest run, with no
action from you; on a large mirror expect that first run to take a little
longer.
An Oracle view is read in place, not copied
Until now the prebuilt Clarity resource plan stream copied its view whole, every Monday, and kept two years of copies. Sized against a real resource plan — one row per person per month over three years, tens of thousands of people — that is about a gigabyte a copy and a hundred gigabytes retained, arriving a week at a time into a database volume that can be grown and never shrunk. Nobody chose that for the data: the appliance could execute a copy of an Oracle view and could not yet query one, so the copy was the shape it shipped.
Now an Oracle source is query-through, the same as a Snowflake one. A
question over the stream is compiled into one SELECT against your view and
sent to your database over a read-only session; nothing is copied, and what
the answer describes is the view as it stands now. The Test on the stream's
Manifest tab compiles every column the recipe names against your real view
before the stream can be enabled, so a misspelt column is refused on the screen
with the view's own column list beside it, rather than reaching an answer as a
silently empty figure. The stream's statement_timeout is the ceiling one
question may cost your database; the semantic query service's own timeout atlas
it further.
What this stream is, and what it is not. It is effort and plan data. It answers "how much effort went to the Payments platform last quarter", and it answers by platform, manager line, cost centre, team or OBS from its own columns — no other stream involved. It is not your staff directory, and Prism will not use it as one: a directory needs one row per person, and this view holds one row per person per platform, period, scenario and version, so there is no single row to read a person's manager or address from. Its population is also the wrong set — membership is "has plan rows", not "is employed", so anyone with no allocation simply does not appear.
So questions about people still come from your directory, which is the uploaded staff list until your Workday tables arrive. Registering this stream as a snapshot does not change that: copying a view does not change its grain or its population. Questions scoped to a leader's organisation ("everyone who reports to X") are answered from the directory and are refused with the reason when there is not one. What the Clarity stream contributes to those answers is the effort figures, joined to a person through the address the view carries.
If you already registered the Clarity stream as a snapshot, this release does not change it, and moving it is a delete and a re-register. A registered stream's kind cannot be edited (the admin API refuses with 409), so the switch means deleting the row and adding it again, and the stored password goes with the row — so have the database password to hand before you start.
The copies it has already taken do NOT go with it. Deleting a stream removes the registry row and its credential; the dated copies it has landed stay in the database, and so does its ingest schedule. On a plan of any size that is tens of gigabytes left behind, referenced by nothing — a single copy of a 2.6-million-row view is about 1.2 GiB. If you are switching to free that space, it will not; ask us and we will tell you what to remove.
One consequence of the schedule outliving the row: a replacement registered under the same name inherits the deleted stream's position in the schedule. Register a weekly stream on a Tuesday and its first copy is not due until the schedule next comes round, while the stream reads as healthy and answers from the copies the deleted one took. Check the age of the figures before you trust them.
Your identity crosswalk is not affected — this stream does not hold your directory. So if you have not yet connected Clarity, connect it on this release rather than the previous one; if you have, plan the switch as a maintenance step, and see If you want the plan's history in go-live for the snapshot shape that stays available for a plan whose history you do want, with a short retention.
Two things in the recipe's view specification changed in your favour: a row identity column is now worth having rather than a condition of connecting, and a last-updated timestamp is not asked for at all.
And when such a source cannot answer, it now says so in its own terms. A stream Prism reads in place used to report a failure as "Prism's stored copy of this data could not be read" — describing a copy that, for this kind of stream, does not exist. The same answer said elsewhere in the same breath that nothing about the source is copied. An operator who followed the first sentence went looking at an ingest that never runs for it. The message now says the system holding the data did not answer, which is what happened, and points at the database rather than at Prism.
Jira Data Center is now a connected stream, and you declare which edition you run
Reported by a customer on 0.10.1. On a Jira Data Center install the Jira
issues row on Admin → Context streams showed not connected, offered a
credential form, accepted what was typed, and then refused to connect for want
of the email parameter — which a Data Center install cannot supply, because
that edition signs in with a bearer token and has no account email. The prebuilt
stream covered Jira Cloud only, and nothing said so.
Now the one prebuilt Jira stream covers both editions. A new install value,
ingest.jira.deployment, says which one yours is — cloud (the default: the
account email plus an API token, as before) or data_center (a bearer personal
access token as secrets.jiraToken, and no email). The stream's deployment
parameter on Admin → Context streams is the same choice, and the credential
form asks for what that edition signs in with. The value is declared, never
guessed: an empty email no longer means anything on its own. It is also
checked — every ingest run and the self-test ask the instance which edition it
is, and a wrong declaration stops with both words in the sentence and the value
to change (edition_mismatch in the self-test's evidence). Proven against Jira
Software 9.12 Data Center.
If you run Data Center, this release moves your Jira ingest onto the engine,
and it takes two steps in this order. Set ingest.jira.deployment: data_center (with ingest.jira.email empty — the chart refuses the two
together) and upgrade. Until now a Data Center install was the one kind of
Jira the engine declined and the older lane kept serving; from the next
scheduled run the same job runs the engine. It keeps your data: both lanes
record progress under the same state and write the same table, so the engine
continues from where the older lane stopped, existing rows untouched, only
issues updated since fetched — no refill, no interruption to Jira answers —
and setting the value back returns the older lane the same way (both measured
on Jira Software 9.12.39). Then delete the Jira row on Admin → Context streams — it is re-created on the spot
from this release's recipe, already set to data_center from your chart — and
enter the personal access token on the new row and enable it. A row registered
before this release holds a copy of the recipe from before the Data Center
edition existed, and the page reads that copy; until it is deleted the tick
reports it as invalid with this remedy rather than walking it. Nothing is
lost: the row was never enabled on a Data Center install. Cloud installs need
do nothing. See Cloud and Data Center.
One thing to do after upgrading, if the row already carries a verdict: the sentence on the Context streams list is the probe verdict the row stored, so a row last tested before this release keeps its old wording until you press Test on it once. The credential form's note is live and needs nothing.
The roster upload's size limit is now yours to move
Reported by a customer on 0.11.0 with an 18.7 MB export. The 12 MB ceiling on
an uploaded roster was the right size for a staff list and the wrong kind of
number: a constant, with a comment telling the operator to raise it and no way
to. It is now app.rosterUploadMaxMiB in the chart, and the refusal names it —
beside app.resources.limits.memory, which it has to move with, because reading
an upload costs about ten times its size in memory while the file is parsed.
The default is unchanged. Limits says how to size the
two together, and why a file that exceeds the default is usually a whole
directory rather than a staff list.
A GitLab group that cannot be fetched is a coverage note, not an outage
This is about GitLab, and about a GitLab source configured with more than one group. That is the only stream this release changes here, and the limit is not arbitrary: it is the one walked group-by-group with each group's own paging, so a group that stops answering is a part of the walk the rest can carry on without. GitHub is walked day by day across all its organisations at once, and a Jira source is one walk over one window, so neither has a part to isolate — if the fetch fails there, the run still fails, as it did before. A GitLab source with a single group is in the same position: one group failing is the whole walk failing, and that run still fails too.
For a GitLab source with several groups, one group that could not be fetched used
to leave the whole run recorded as failed. The rows from the groups that did
answer were already being landed (that has been true since 0.10.2, and this
release does not change it), but the run never completed: last_completed never
advanced, no status was recorded, and the source was then held off under the
failure backoff and tried again an hour later. A source with one permanently
unreachable group therefore stayed in that loop indefinitely — and, if it had
never once finished a clean run, re-read its whole backfill window each time —
while reading as broken.
Now such a run completes, with the groups that did not finish named, and the source returns to its ordinary schedule. Its figures are a floor for those groups: what a group had already sent before it stopped is kept, the rest was never fetched, and nothing can count what is missing. Answers drawn from the source carry that as a coverage statement rather than presenting a partial figure as a whole one, and the sync's own log names the group.
Read the coverage statement rather than only the number. A group that has never answered contributes nothing and looks exactly like a group with no activity. And because the sync reads each merge request by when it last changed rather than by when it was opened or merged, a group that stopped answering this week can leave a figure short for a month a year ago — do not read the coverage statement as being about recent dates only.
One thing this does not do, and it matters if you are diagnosing an incident: it does not make the walk continue past an unreachable part. That shipped in 0.10.2. What is new is that the run is no longer recorded as a failure, and no longer held back by the failure backoff.
And one thing to watch. A source whose run never completes cleanly re-reads its whole backfill window every time it runs. Before this release that happened under an hourly retry; now it happens on the source's own schedule, which is fewer times a day — but the state is no longer conspicuous, because the source reads as working. If a source reports a group that never answers, treat that as something to fix rather than to live with — take the group off the list until it will answer, or find out why it does not. While it persists, a source that has never finished a clean run is doing its full backfill on every run, and a reader asking a question while it is mid-refill can see a partial table.
When a corrected stream recipe has stopped reaching a source
A context stream you registered keeps its own copy of the recipe it was created from. That is deliberate — it is your configuration, and an upgrade must not silently overwrite what you wrote — but it means a correction we ship in a later release does not always reach a stream you registered before it, and until now nothing said so. A stream could sit on a recipe with a fix in it that never arrived, running, reporting healthy, and quietly missing the fix.
Prism now compares each registered stream against the recipe it came from and says when they have diverged, on the source's own row and in the self test. It distinguishes the two reasons they can differ — a release moved, or you edited the document — and it does not tell you to re-apply the recipe, because doing so would replace your stored copy including any disclosure settings you have set on it.
A stream you registered from an edited document has no recipe recorded to compare against, and the row says that rather than reporting no difference.
A stream you have switched off no longer offers a verification test
Reported by a customer on 0.11.0. On the Verification tab of a context stream, Run was offered on every question whenever the internal query server was attached — including on a stream that was switched off, which that server does not serve. Pressing it returned a paragraph explaining that the source was not enabled here. The paragraph was accurate, and it said in as many words that it was not a finding about the source; it still arrived where a failed test would, under a question badged pending, and it read like one.
The buttons now follow whether the stream can actually be tested. On a stream that is not switched on, Run and Run all questions are dead, and one line above them says why before you press anything — that the query server serves the streams this installation has switched on and this is not one of them. It points you at the stream's own state word for what it is waiting for, rather than telling you to switch on a stream that may still be waiting for a credential. On an installation with no internal query server at all, that same line says so instead, and says it whether the stream is on or off: there, switching it on changes nothing.
Nothing about the questions, the verdicts you have recorded, or what a run means has changed, and a stream that is switched on behaves exactly as before. Prebuilt streams — GitHub, GitLab and Jira (renamed in this release, below) — stay testable whether or not their row is switched on, because the query server carries their definitions in its own image rather than reading them from your registry.
A verification test that finds no stream now tells you what the query server actually said
Reported by a customer on 0.11.0, and it cost two rounds of email before anyone doubted the screen. An administrator applied a corrected people manifest to their organizational directory, opened the Verification tab and pressed Run. Every question came back with a paragraph saying that the internal query server does not carry this stream, so either the stream is not switched on here or the server could not read your registry. Neither was true. The stream was switched on, the server was reading the registry perfectly well, and the real cause was a third thing the paragraph did not mention: one of the stored questions named a stream that does not exist on that installation at all.
That paragraph was written into the product and printed whatever the query server had said. It named one possible cause as though it were the only one, and on this installation it was the wrong one — so an administrator went to look at a switched-on stream, found nothing wrong with it, and reasonably concluded the product was broken.
The tab now prints what the query server said, not a guess at it. Where a question names a stream the server does not carry, you get the name it was actually asked for — which nothing else on that screen showed you — followed by the streams it does carry. Between them those two settle the question in one line: if your stream is in the list, the name in the question is the thing to fix; if it is not, the stream is switched off or your registry could not be read. The fixed sentence is still there, framing what happened — a request went out, an answer came back, no verdict is in it, and none of this is a finding about your data — but it no longer pretends to know why. What the query server said below the message holds the whole reply, including the full list where the message shows the first twelve.
A second case was being reported as the same thing and is not. A query-through stream — Snowflake or Oracle — whose stored connection cannot be used is carried by the query server and switched on; what fails is running the statement against your database. That was arriving under "does not carry this stream", sending an operator to inspect a row that was present and fine. It now says that the stream is carried and the statement could not be run, and gives the server's own account of the connection, which is a credential or a network path to repair rather than a figure to judge.
Nothing about the questions, the verdicts you have recorded, or what a run means has changed, and a verification test that answers behaves exactly as before.
The prebuilt streams are named for what they read, not for how they are read
The three prebuilt context streams were called GitHub (index), GitLab (index) and Jira (index) — in the recipe picker on Admin → Context streams, and in the source line under every answer drawn from them.
index there is not part of their names. It is how the data is read — Prism
keeps a copy and answers from it, rather than querying your system live — and
since 0.10.0 every answer already carries that separately. So the name said the
same thing twice and never said what the stream actually is. They are now
GitHub, GitLab and Jira. Nothing else about them changed: same data,
same connection, same parameters, same questions.
A stream you have already connected keeps the name it was connected under. Registering a stream copies the recipe into your own configuration, and Prism does not overwrite what you have — so after upgrading, the picker offers GitHub while an existing stream stays GitHub (index) until you re-register it. This is deliberate and it is not reported as your stream having drifted from its recipe: the display name is yours to set, so it is excluded from that comparison. If you would rather your existing streams read the new way, edit the name on the stream itself; there is nothing else to do, and nothing breaks if you leave it.
Smaller, and each one a state that used to be silent
- A first install whose database is slow to accept connections now finishes setting itself up, on its own. The stream registry was created once at start-up with no retry, so an app that started before its database was ready ran indefinitely with no registry, no Context streams page, and no way to add a source — while reporting healthy, and while the ingest tick blamed a setting that was correctly on. A restart always fixed it; nothing told you to perform one. Prism now keeps trying — every few seconds at first, then once a minute for as long as it runs — so the install completes itself whenever the database arrives, however late, and no restart is needed. While it is waiting, the self test's source registry line says the set-up has not finished yet and how many attempts it has made, and the ingest tick reports that the registry is missing without guessing why.
- The refusal you meet when two streams claim the organizational role now offers the remedy. It said which stream held the role and left you to work out that you could keep it and enable the other one for its other data. It now names both routes and says why the row is claiming.
- The self test now says how much of what it harvested about people actually matches your directory. The identity crosswalk line reported how many identifiers the last compile inserted, which says nothing about whether they join to anyone: an install can hold thousands of addresses, compile cleanly, show green, and correlate nobody — the state a customer's install was in on 0.10.1, where four questions about people in a row were refused with every line of this page passing. The line now also reports, per harvesting mechanism, how many members it has looked up, how many (address, handle) pairs it holds, and how many of those carry an address your organizational stream names; the same figures are in the downloaded diagnostic bundle. They are counts of pairs, and not of people or of your organisation. One person who discloses two addresses contributes two pairs, and a sweep is a rolling, resumable pass — so the members looked up are those it has reached so far, and no ratio between these numbers is a coverage figure. What they do tell you: a mechanism holding pairs of which none match is a question about which addresses the two sides carry, not a walk that failed. A mechanism that has looked up thousands of members and holds no pair at all is a different question — whether it was ever given an address to keep — and the self test cannot tell you which of the reasons applies; the sync's own log records those counts.
Promoting a stream onto the engine never destroyed its history — the docs said it did
The chart comment on ingest.engine.mode, and the 0.10.0 notes on this page,
told you that moving GitHub or Jira off its legacy lane means "the first engine
run truncates and re-backfills each table it takes over". It does not, and it
never did. Both lanes record their progress under the same source name, so the
engine's first run after a promotion reads the progress the legacy lane left,
resumes there and keeps every row — including history older than
ingest.backfillDays, which a re-backfill would have dropped. Measured on a Jira
Data Center takeover holding 94,049 rows.
Nothing in the product changed. What changed is that the sentence was pushing
you towards a real cost: an operator protecting rows that were never at risk
would set INGEST_TRUNCATE_ON_FIRST_RUN=0 before promoting, and that variable is
not about promotions at all — it is what stops an appliance flipped from the
synthetic seed to live data serving a mixture of the two. If you set it to 0
on that reading, put it back. Set it only where a table already holds real rows
from some other ingest.
A source that has never completed a run here does still clear the table on its first live run — one still holding seed rows, one that has never run, or one whose first backfill died before finishing and whose recorded ground does not cover what the new run asks for. That is the case the variable was written for and it is unchanged.
INGEST_TRUNCATE_ON_FIRST_RUN is not a chart value, so "put it back" means
removing it where it was set: kubectl set env on the ingest CronJobs, which is
the only way to set it and which survives helm upgrade — so it is still set
unless somebody has taken it off.
What the self test proves about a rotated credential, stated accurately
Rotate a credential sent you to the self test to prove
a rotation had landed, without saying which rotations it can speak for. Step 3
now carries the list, credential by credential, with the line that would go red
for each — including two the page never mentioned and both worth knowing: the
agent runner line catches a bad app-jwt-secret, and each mcp * line
catches an mcp-shared-secret rotation that reached some of the six workloads
and not the others.
What it cannot speak for is a credential you entered on Admin → Context
streams. The per-source SOURCES lines are a fixed set written into the app —
the GitHub mirror, the GitHub issue index, Jira and spend — not a reading of the
streams you have connected, so a stream you added there gets no line that tests
its credential. It is not a silent group, which is what made this easy to
misread: with the stream registry on you also see source registry,
designated route, recipe drift, identity crosswalk and identity attribution, and three of those print your designated stream by name. None of
them opens a connection to it. The page now says what they do report — the
registry rollup and the last compiled crosswalk — so a green group is not read
as a credential check.
The proof that does exist is pointed at instead: the admin surface probes a credential before it stores it, so a value that would have broken the stream is refused as you enter it and the old one stays where it was.
Two more corrections to what step 3 claimed. A spend verdict exists only under
sources.spend=indexed; under live there is no local index, no check job and
no verdict to read. And the GitHub and Jira lines can be up to
ingest.checkSchedule old — 30 minutes by default — because the scheduled check
records them: rotating with helm upgrade re-records before the command
returns, rotating with kubectl patch secret does not. If you have moved one of
those streams onto a credential held on its row in the admin surface, that line
is reporting the Secret value rather than the one the ingest now uses.
Documentation only. Nothing about what the self test checks has changed.
The image list ships inside the chart, so mirroring no longer needs a file we sent you
images.txt — the digest-pinned list of every image in a release — was written
beside the chart tarball and reached you only if somebody remembered to hand it
over. It is now inside the tarball, as prism/images.txt, for the same
reason the docs are: what you receive, verify and keep should contain everything
needed to install from it, with no companion file the two can drift apart from.
Installing Prism step 2 now mirrors from that file, by digest:
helm pull … --untar --untardir "prism-$VERSION", then a loop over
prism-$VERSION/prism/images.txt. The tag-based loop is still there for anyone
who prefers it, with what it costs written next to it. What the digest buys you
is that the bytes landing in your registry are provably the artifact we built
and scanned for this version, whatever happens to a tag afterwards on either
side. What the file buys you is completeness: a hand-written list has no
failure mode, so an image added in a later release is silently absent from it
and the first sign is an ImagePullBackOff part-way through an upgrade.
Use the --untardir "prism-$VERSION" as written, on the first install and
on every upgrade. helm pull --untar will not write into a directory that
already holds a prism/, so pulling a new release beside an old one fails —
and the prism/images.txt still sitting there is the previous release's,
which the loop would then mirror under the new version's tag. One directory per
version cannot do that.
The release now refuses to publish a chart whose prism/images.txt is missing,
or which pins fewer images than the release built.
Pre-flight an Oracle view before you connect it
Registering a view over an Oracle database used to have one way of telling you it was wrong: connect it, run the first ingest pass, and read the failure. The common causes are all on your side — a view the DBA can see and the service account cannot, a column the manifest names that the view does not carry, an email address two people share — and none of them is visible from here.
prism/scripts/prism_preflight.py now ships in the chart tarball beside these
docs, and Pre-flight an Oracle view before you connect it
is the procedure. Your DBA runs it as the account Prism will use, against
the view you intend to give us, taking the manifest you intend to register — so
what it checks is your SELECT and your column list, not a copy of ours. It
does what the first ingest pass does, in the same order, and stops at the first
thing that would have stopped that pass, with the reason named: the connection,
the SELECT, each returned column's Oracle type against the declared one, the
counts that decide whether people resolve (addresses shared by more than one
person is the one that surprises people), the manager ladder's orphans, and the
timings that size your schedule.
Everything it runs is a SELECT, it prints each statement before running it and
waits, and its report — on screen and as JSON — carries counts, column names,
Oracle type names, timings and verdicts, and never a row, a name, an address or
an id. It needs Python 3.9, oracledb and pyyaml on any machine that can
reach the listener; nothing is installed on the database and nothing reaches us
unless you send the report.
Read the timing line against the kind of source you are registering. A
query-through view is read live on every question, inside the budget in
Install parameters — fifteen seconds unless you raise it. So
for a query-through manifest the pre-flight fails a view whose first page
takes longer than that, because at the shipped default every question against
it would time out, and notes one that takes more than half. That first page is
the cheapest statement Prism will ever run against the view; a grouped question
does more work. A snapshot manifest is copied by the ingest instead, so there a
slow page is what to size the schedule against and the note stays at a minute.
The failure names the chart value that can raise the limit. It also says what
the page previously got wrong: a manifest's own
connection.database.statement_timeout cannot raise it, because the engine
takes whichever of the two is smaller.
The release refuses to publish a chart that does not carry the script, the same way it already refuses one missing the re-owning script.
If a database source is too slow to answer, the query ceiling can be raised
You should not need this, and reaching for it first is usually the wrong move. It is documented because the alternative — when you do need it — is editing a Deployment by hand after every upgrade.
The semantic query server gives one statement fifteen seconds and then gives up.
A source manifest can ask for less; nothing could ask for more, so a view slower
than fifteen seconds could not be read at all.
mcps.semanticMcp.statementTimeoutSeconds is that ceiling, and
it still defaults to fifteen seconds.
If a source times out, the first question is why the database is slow, not what the ceiling is. A view reached through a synonym over a stack of dependent views can take twenty seconds to return a single row while the same database answers ordinary queries instantly — that is a view to fix, and raising the ceiling over it makes every question slow instead of failing fast. Pre-flight an Oracle view before you connect it times a count and a first page against your own view, which is what tells the two apart.
Raise it when the source is genuinely, unavoidably slow. The budget is spent per statement and one question can spend it more than once, so the worst case for a question scoped to a population of people is a multiple of this value.
A grouped answer that was cut short now says so
A question grouped by something with many values — by manager, by team, by repository — returns at most a hundred groups unless the question asks for more. The figures in those groups were always right. What was missing was any sentence telling you the list was not the whole list, so a plausible-looking table of a hundred managers could quietly account for fewer people than the total and nothing said why.
Measured on a 4,589-person roster grouped by manager: a hundred groups holding 3,720 people, against an overall of 4,589. 869 people in thirty managers' teams, absent from a list that looked complete.
The answer now names the cut: which fields it grouped by, how many rows it showed, the order it kept them in, and that more exist. The remedy is in the sentence — ask for a higher limit, or narrow the question to one leader's directs, which reconciles exactly.
What it does not tell you is how many groups were dropped, and that is a limit rather than an omission: the engine reads one row past the limit to know there is a next one, and has never seen the rest. The gap between the shown rows and the total is not that number either, because rows belonging to no group at all land in the same gap and are a different fault with a different sentence.
A time series is unaffected. A series cut at the limit is refused outright rather than drawn short, because a chart missing its tail is a wrong picture rather than a partial one.
Upgrading to 0.12.0
-
Press Test once on each context stream after upgrading. The state shown in the Context streams list is the verdict stored the last time that stream was tested, not a live check — so a stream keeps its previous message, including a message this release improves, until it is tested again.
-
Two new chart values, both documented in Install parameters:
ingest.jira.deployment(which Jira edition you run — leave it atcloudunless you run Data Center or Server, in which case you must set it, withingest.jira.emailempty; the chart refuses the two together) andapp.rosterUploadMaxMiB(the staff-list upload ceiling). -
If you run Jira Data Center, two steps in this order. Set
ingest.jira.deployment: data_centerand upgrade — from the next scheduled run your Jira ingest moves onto the engine, continuing from the older lane's progress with your rows kept (no re-copy). Then delete the Jira row on Admin → Context streams: it is re-created on the spot from this release's recipe, already set todata_center; enter the personal access token on the new row and enable it. A row from before this release holds a copy of the recipe from before the edition existed, and until it is deleted the ingest reports it asinvalidwith this remedy rather than walking it. Nothing is lost — the row was never enabled on a Data Center install. -
If you upload a staff list, re-export it before you apply this release's recipe. The recipe now reads two columns it did not before — the seniority band and the active/leaver flag — and a file missing a declared column is refused whole rather than loaded with a column of nulls. So applying the new recipe to a stream whose file predates this release fails, naming the columns:
the uploaded file has no column(s) 'Active Status', 'Management Level'Nothing breaks while you wait: the stream keeps running on the document it already has, and keeps answering. Export the file again with those two columns included, upload it, then apply the recipe. If you would rather not re-export yet, remove those two fields from the document instead — you lose the "who has left" and "individual contributors only" questions and nothing else.
-
No migration, and no re-walk. Nothing in this release changes what is stored or how it is keyed — including the Data Center move above, which continues from the recorded progress rather than re-copying.
Renaming a context stream no longer silences its verification questions
The questions on a stream's Verification tab are stored with the stream, and each one records which stream it asks about. That record is the stream's name written a second time — and until this release it did not move when the name did. A stream you renamed, or an instance of a prebuilt stream you registered under a name of your own, kept working in every other respect: it collected data, it was listed, it answered questions in chat. Only its verification questions stopped running, and each one reported that the stream was not loaded.
Three changes, and you should not have to do anything for the first two:
- Renaming a stream (Admin → Context streams, or
PATCH /api/admin/sources/<id>) now carries its questions across with it. - Registering an instance of a prebuilt stream under your own name does the same. This is the ordinary way to connect two of the same kind — one warehouse account here, another there — and every instance registered this way before this release has the fault.
- Saving a manifest whose questions name a different stream is refused, and
the refusal names the question and says what to set. The refusal you get for
a manifest whose
name:does not match the row now says in the same breath that each question'squery.sourcemoves with it — following that advice half-way is what produced this.
If a stream you already have is in this state, its Verification tab is the
symptom: every question reports that the stream is not loaded while the stream
itself is enabled and answering. Open its Manifest tab, set every
query.source under policy.verification.questions to the stream's own name,
and Save. No data is re-collected and no figure changes; as with any manifest
you save, verdicts already recorded come back as pending. There is a
troubleshooting entry for it too.
0.11.0 (5 September 2026)
Ask about a leader's people — and be told how many of them could be checked
This is what 0.11.0 is for. Until now Prism could tell you how many pull requests were merged, and not which of your people merged them: GitHub knows a login, your HR export knows an email address, and no HR system anywhere carries a GitHub username. Three changes close that.
Questions can now name a population by its leader, at three depths that are
deliberately three different answers — a person's direct reports, those people
and their direct reports, or everyone in their reporting line at any depth.
Ask "of the people who report to <name>, who has merged nothing in the last
quarter" and the join happens inside one query rather than one query per person.
Your people file can now resolve a GitHub login without carrying one. Prism reads each organisation member's published profile address and matches it to the address your roster already holds. That is the join, and it is why this release asks for one more GitHub permission (below).
And every population-scoped answer now says how much of the population it could actually check:
POPULATION COVERAGE: 412 of the 4,589 people this installation's directory knows have a resolvable
github_login. The other 4,177 are matched to NO rows here, so they are counted as having done nothing — they are indistinguishable, in these figures, from people who did nothing.
Read that sentence before you read the number above it. A person Prism cannot resolve looks exactly like a person who did nothing, and on a question of the form "who has not committed" those are opposite findings. The sentence is the denominator, not a caveat, and it disappears once coverage is complete.
If that ratio is low on your install, it is a data question rather than a product one: it means the addresses on your people's GitHub profiles are not the addresses your HR export holds. Tell us the number and we will tell you which half to fix.
"By manager", at any depth
The people recipe gains a manager_name column and two more
management-chain levels. manager_name is the one that answers "by manager"
generally — a chain level answers it only for managers who sit at exactly that
level, and in a large organisation most managers do not.
It is a display name and not an identifier: two managers can share one, so where a figure has to be exactly right, group by the manager's employee id and print the name beside it.
These columns arrive only when you re-apply the recipe — see the upgrade step above. A shipped recipe never reaches a source that is already registered.
A controlled vocabulary can say what its values mean
Asked "how many developers under <leader> have no commits", Prism used to find
seven plausible job families and give you a range rather than choose. It was
right to refuse and it was still the wrong answer to give.
A column holding a controlled vocabulary can now declare, in the recipe, which of its values constitute the thing being asked about — and an answer that uses that declaration says which set it used, so you can disagree with it. Edit it in Admin → Context streams → Manifest; it is your document.
Questions about people, asked of Jira, stop answering zero
A population-scoped Jira question could return a confident 0 where the true figure was non-zero. Jira's assignee column holds a login for some rows and an address for others depending on what was known when each issue was mirrored, and the join picked it anyway. It now prefers a column that holds one kind of identifier, and a manifest can declare that a column is mixed.
If you have asked Jira a question scoped to a team or a manager and been told nobody did anything, ask it again on this release.
Also in this release
- A grouped answer names the rows that are in no group. If some rows have no value for the thing you grouped by, they are counted in the total and appear in no row of the table — the figures are short by whatever those rows hold, and the answer now says so rather than leaving you to find it in the arithmetic.
backfill_daysis the window the walk actually asks for. A source registered with a 30-day backfill could ingest 90. Configured and ingested are the same number now.- The integrity warning above an answer is yours to turn off. The cap on how
much of a tool result Prism keeps for checking is now an install parameter
(
app.runner.tracePayloadMax) and ships high enough that an ordinary page of rows fits. Answers stopped carrying "part of this answer could not be checked" for a reason that was never about your data. - The identity sweep reports its own coverage. The status page says how many members it has looked up so far, how many disclosed an address, and how many of those match somebody in your directory. It is a rolling pass, so for the first hours after an upgrade that count is progress and not the size of your organisation — the sentence says so.
Re-applying a recipe no longer switches the source off
A registered source carries its own copy of the document it was registered
from, so a recipe corrected in a release does not reach a row created before it:
picking the correction up means applying a new document to the existing source.
You do that on Admin → Context streams, on the source's Manifest tab —
edit the document and press Save — or over the API with PUT /api/admin/sources/<id>/manifest, which also takes {"recipe": "<name>"} to
apply this image's copy of a recipe unmodified. Either way the stored credential
and the connection parameters are kept; that is what the route is for. Until
this release, applying always switched the source off, whatever the edit
was, and you had to walk it back through approve and enable.
For a source Prism reads from a file you uploaded — the people roster — that was worse than an inconvenience. Only an enabled source is your organizational directory, and the identity crosswalk is rebuilt from scratch on every ingest run, so the moment the source went off the crosswalk compiled to nothing and every question scoped to a person, a team or a manager's organisation — on every source, not just the roster — refused, until somebody noticed and walked it back. The re-apply reported success.
Now the check runs where it can run. Reading your file is that source's connection test and Prism holds the file, so applying a new document re-reads the stored file under the new column list before it writes anything:
- it reads — the source keeps the state it was in. An enabled directory stays enabled and never stops resolving people. This is the usual case.
- it does not read — the source goes back to
registeredand off, exactly as before, and the response now names the columns that could not be found instead of leaving you to work it out. Upload a file the new document can read, then approve and enable. - it does not read and the source is your organizational directory — the apply is refused and nothing is written, because switching that source off takes every per-person answer on the installation with it. Fix the document, or upload a matching file first.
Sources Prism reads by connecting to them — Snowflake, Oracle, GitLab, a
REST API — still go back to probed and off, whatever you change. Nothing here
can re-establish that a new document's columns are on the far end without asking
it, and a source presented as connected on evidence nobody produced is the
failure this route exists to avoid. What is new for them is that the answer
spells out the calls that put them back (.../propose, .../approve,
.../enable, in that order).
And if such a source is your organizational directory, saving is refused. That is the third case above, and it applies to every kind rather than only to uploaded files: a CDH- or Snowflake-backed directory cannot be edited without going off, and going off stops every per-person answer on every source. So the save is refused with nothing written, and the page offers Save anyway on that refusal — pressing it accepts the outage and leaves you to probe and enable the source again. The order that avoids the outage entirely is to prepare the replacement first: connect and enable the new directory, hand the designation over with Move to…, and edit the old one once it is no longer holding anything.
GitHub now asks for one more permission: Members: Read
Grant it before you upgrade, or per-person answers stop working without saying so. It is the second of the two upgrade steps 0.11.0 asks for, and unlike the first it cannot take Prism down — the failure is quiet, which is why it is here rather than in a footnote.
Prism can already tell you how many pull requests were merged. It could not tell you which team merged them, because GitHub knows a login and every other system — your HR export, Jira, your warehouse — knows a person by email address, and no HR system anywhere carries a GitHub username. 0.11.0 closes that by reading each organisation member's published profile email, which means listing the organisation's members first.
- Fine-grained token: Organization permissions → Members: Read
- Classic token:
read:org, which you very likely already have
What it reads: the member list, then each member's public profile address — the one a user has added to their own profile. No private profile field, no address a member has not published, and nothing about membership beyond the logins. The addresses are join keys and never appear in an answer.
Without the grant, the ingest is unaffected — pull requests, reviews and issues all mirror normally — but any question scoped to a team, a manager's organisation or a named person resolves to nobody, and the refusal is reported only in the ingest tick's log.
If you would rather not grant it, set ingest.github.profileEmailSweep: false. That is a supported configuration, and it is louder than being
refused: the tick then says on every run that no GitHub login will be resolved.
Two related values, both optional: ingest.github.profileSweepBudget (how many
GitHub requests one tick's sweep may make — raise it to finish the first pass
sooner) and ingest.github.profileSweepTtlDays (how often a member is
re-checked for a newly published address). Right after populating a directory,
prism-ingest identity-sweep --all re-reads every member immediately instead of
waiting for the schedule.
The internal query servers now require a credential, and the install needs one new value
This is the upgrade step 0.11.0 asks of every install, and the only one that can take Prism down if you skip it. Generate a secret, put it in your Secret, then upgrade. (If you ingest Jira there is a second, softer one — Jira issues carry their project, the next section. Skipping that one costs you a wrong-looking answer, not an outage.)
kubectl patch secret prism-secrets -n <namespace> --type merge \
-p "{\"stringData\":{\"mcp-shared-secret\":\"$(openssl rand -hex 32)\"}}"
Do it in that order, because on the recommended install the wrong order is an outage. What happens if you skip it depends on who owns your Secret.
On a chart-managed Secret (secrets.* in your values) the render fails,
names secrets.mcpSharedSecret, and changes nothing — Prism keeps running.
On an existingSecret install — the one Installing Prism
recommends — the chart renders no Secret at all, so there is nothing for it to
require. helm upgrade reports success and every new pod fails to start with
CreateContainerConfigError. The five query servers roll gradually and keep
serving from their old pods, but the app runs as a single replica with
strategy: Recreate: Kubernetes stops the running one before starting its
replacement, and the replacement cannot start. You lose the UI and chat until
the Secret has the key.
Patch the Secret and the app comes back on its own; kubectl rollout restart
brings the query servers forward.
What was wrong. Prism answers questions through five internal query servers
that sit beside the app inside your cluster. Until this release they answered
anyone who could open a connection to them. That was documented as a decision
— network isolation was the fence — and it was a sound one where it was written,
on a platform where "internal" is enforced by the platform. In a Kubernetes
namespace it is not: this chart ships no NetworkPolicy, so the fence was the
namespace, and a kubectl port-forward was on the wrong side of it.
What was behind the fence had also grown since the decision was made. It is no longer aggregate statistics: it is your people and their reporting lines, your per-person inference spend, the GitHub token the appliance holds — usable against your repositories from outside the appliance entirely — and, since 0.10.0, the stored credential of every data connection you have configured.
What changed. Every request to every one of the five now presents
secrets.mcpSharedSecret or is refused. The check sits around each server's
whole HTTP surface rather than on individual routes, so it covers the query
endpoint and the small statistics endpoints alike, and an endpoint added in a
later release is covered the day it is written. It fails closed: a server with
no secret configured refuses everything rather than admitting everyone, which is
why there is no setting to turn this off and why the chart refuses to render
without a value.
What you do not have to do. Nothing on the agent side. The app hands the secret to the agent runner on its own, on the first conversation after it restarts, so there is no second value to configure and no ordering to get right between them.
Rotating it later is Rotating a credential, which now carries the procedure and the one wrinkle worth knowing: six workloads hold this value, and while they disagree the questions that route through the disagreeing server come back unanswered rather than wrong.
If you run the self test, a mismatch has its own line now —
refused this app's shared secret (401) — rather than being reported as a
server that is unreachable, which would send you to look at the network.
Jira issues carry their project — and on an upgrade the column starts empty
If you already ingest Jira, do this as part of the upgrade. The new project columns are added empty and Prism does not fill them in for issues it has already mirrored. Until you re-sweep, "how many issues in each project" answers over only the issues Jira has touched since you upgraded — and the answer will look plausible rather than incomplete, because the issues with no project are dropped from the breakdown while staying in the total on the same page. On an established install that is most of your history.
helm upgrade ... --set ingest.jira.rewalkFrom=YYYY-MM-DD # at or before the history you wantOne pass, once per date, then blank the value. Details below.
What you can now ask. Which project work belongs to: "how many issues were
completed in each project in the last 90 days", grouped by the project key or
labelled with the project name. Until now jira_issues held no project at all,
so there was no field to group on — the key's prefix was the only trace of it
and nothing read that.
Why the columns start empty. They are added to the table you already have, and Jira's ingest walks forward from where it left off rather than re-reading history. So an issue already in your mirror gets its project the next time somebody touches that issue in Jira, and holds nothing until then. Nothing is broken; the mirror is simply thinner on this one field than on the others, and it thickens at the rate your team touches old issues — which for closed work is approximately never.
What that looks like if you skip the re-sweep. Ask for issues per project on a mirror where nine issues in ten predate the upgrade, and you get a handful of projects with small numbers beside a total an order of magnitude larger, on the same answer, with nothing saying which issues are missing or why. It reads as a counting error and it is not one. This is the failure mode to watch for: the figures are individually correct and collectively misleading.
The re-sweep. Pick a date at or before the history you care about:
helm upgrade ... --set ingest.jira.rewalkFrom=2026-03-01
It re-walks the ground between that date and where the ingest had got to. It
truncates nothing and gives up no coverage you already have, it costs about what
your original backfill cost in Jira API budget and time, and it happens once
per date — a value left in your values file does not re-sweep on every run.
Blank it once the sweep has finished. See
ingest.jira.rewalkFrom and Jira ingest.
One case where a re-sweep is not enough, and how to tell. If anyone has disabled, re-enabled or re-registered the Jira source on the Admin → Context streams page, that source is running the ingest recipe it was registered with and does not pick up this release's — the same mechanism the 0.10.2 note below describes for GitLab, in the other direction. The symptom is distinctive: the project breakdown is empty on every row, before and after the re-sweep, while the total is intact. A re-sweep that changes nothing is the confirmation. Tell us if you see it — it needs a one-off repair on that source, and removing the need for one is work we have open.
A corrected prebuilt stream now reaches a source you connected before the correction
Every release corrects and extends the recipes Prism ships — the prebuilt GitHub, GitLab and Jira streams. Until this release a correction reached the answers and not the ingest: a source you had connected under the recipe's own name kept walking the copy of the recipe it was registered from, while questions were answered against the recipe in the new image. The two halves disagreed, silently, and the source's Manifest tab refused to re-apply the recipe to exactly those sources.
The clearest case is GitLab. The commit-author harvest described in section 7 of the GitLab page is declared in the GitLab recipe, so on an install that connected GitLab under an earlier release the harvest never ran however the allowlist was set, and a question about a team's merge requests was refused for want of a single GitLab username, with nothing to say why.
What changed. For a source held under a recipe's own name — github, jira
or gitlab — the ingest now reads the recipe this image ships, exactly as the
answers already did. A correction in a release reaches the walk at the next
upgrade, there is nothing to re-apply, and the Manifest tab's refusal now says
so. What you configured on the source — its credential, the parameter values
you typed, its designation and its verification verdicts — is untouched; none
of it ever lived in the recipe. One guard, for a case the form cannot produce:
a source registered under one of those three names from an edited document
whose connection sends the credential somewhere the recipe does not — a
different address, auth or parameter declaration — is not walked at all after
this upgrade. A source registered from the recipe of an earlier release is not
that, whatever prose the recipe has gained since, and walks as before. The tick reports it as invalid with the remedy — rename the
source so it runs from its own document, or register it again from the recipe
— rather than send its stored credential to a connection you did not write.
What did not change. A source you renamed (gitlab_eu, say), and every
stream you registered from a preview recipe — the Oracle resource plan, the
Snowflake directory, a roster upload — still carries its own copy and is walked
from it. For those a corrected recipe still needs re-applying, as the 0.10.0
note on the Oracle recipe describes; an indicator that a recipe has moved since
a source was registered is tracked separately.
Two things to know after upgrading. The ingest tick's log now says which
document each source ran under — "document": "image" — and, where the stored
copy is behind the image, "stored_document": "stale". Stale is not an error
and needs no action; the walk used the image. And the document shown on that
source's Manifest tab is still the copy stored when it was registered, not the
one that runs, until a later release refreshes it.
If you connected GitLab before this release, four things, together: upgrade;
set ingest.github.identityEmailDomains, without which the harvest runs and
keeps nothing (every counter reads zero, indistinguishable from "no addresses
exist"); set ingest.gitlab.rewalkFrom, because the harvest is forward-only and
only a re-sweep reaches the merge requests you already hold; and make sure the
source is actually due — a re-sweep date is claimed by the next walk the
schedule allows, not by setting it, so on a PT6H schedule expect up to six
hours, or move the source's watermark back. Measured on a test install: with the
allowlist empty a re-sweep kept 0 of 12 addresses; with it set, 12 of 12. The
GitLab page carries all four.
0.10.2 (4 September 2026)
A patch release for a GitLab source that cannot complete a run. If your
GitLab sync shows rows up to one date and never advances, and its log repeats
transport error (ReadTimeout) six times per attempt, this release is the
fix. Nothing changes for the other context streams beyond the first and
third items below, which apply to every stream the engine walks over HTTP.
- The sync's HTTP read timeout is 120 seconds and yours to set. It was a
fixed 30 seconds with no chart value, environment variable or parameter
reaching it. GitLab gives its own query about 30 seconds and, when it cannot
finish a page of merge requests, answers at about 32 with the page and an
errorslist — so a 30-second client gave up two seconds before being told what went wrong, six times, and the run ended. The new value isingest.engine.httpTimeoutSeconds; the log line for a timeout now names the endpoint, how long it waited and that value. Connecting keeps its own 30-second ceiling, and a page that does not answer at all is asked for three times rather than six, because each attempt is now paid in full. - A page GitLab could not finish is kept. When GitLab's own deadline cuts
a field off (
Timeout on DiffStatsSummary.additions), the merge requests on that page are mirrored with their diff sizes empty, the walk continues, and the run's log names the field withheld and how many values (ingest_state.detail.graphql_withheld). Before, that page was retried six times identically and the run ended with nothing after it mirrored. The empty sizes stay empty: the merge request GitLab cannot diff in time is slow every time, so a re-walk hits the same deadline. Repairing or removing it in GitLab is the remedy. - An answer that will not change is not retried. A GraphQL answer carrying errors and no data is asked for once more; if the second answer is the same the request is given up on there — two attempts, not six — and the failure names the field, the page (its cursor and window) and the group. A rate limit still gets the full retry loop, because that does clear with time.
- A page whose paging information was withheld ends the run loudly. It used to be read as the last page, which recorded a resume point behind the same merge request every run and reported the run complete.
- One group that never answers no longer stops the others. Every group in
a GitLab source's
groupsis walked before the run reports its failure, the group that did not answer is named, and the rows of the groups that did answer are in the mirror. The run is still reported as failed and no resume point is recorded, so the next run repeats the window until the group answers or is removed. Per-group resume points, which would let the others advance, are 0.11 work.
Nothing else changed: the same images as 0.10.1 with the ingest image rebuilt, no data migration, no change to any other default. Upgrading is enough — the timeout and the retry rules reach a source you have already registered through the image, which is why they are not manifest parameters: a registered source keeps its own stored copy of its manifest, and a change to the shipped document would not have reached it.
After upgrading, a GitLab source that had stopped advances on its next
run when GitLab answers the slow page with the merge requests on it and the
sizes missing — the shape its own timeout normally produces. If GitLab instead
answers that page with errors and no merge requests at all, the run now stops
there by name (the cursor, the window and the field) instead of six times
anonymously, and the remedy is a smaller page in the source's document; send us
the line and we will walk you through it. If you had set groups down to the
ones that answer, you can put the missing one back and read its failure by
name; whether to keep it is the coverage decision in How Prism reads
GitLab.
0.10.1 (3 September 2026)
A patch release, and if you are installing 0.10.0 you want this one instead.
It corrects a single default in the chart: mcps.semanticMcp.enabled now ships
true.
That switch shipped false in 0.10.0, and the effect is quiet rather than
loud. 0.10.0 introduces three new kinds of source — GitLab, an Oracle snapshot
and an uploaded staff list — and none of them has a reader of its own. All
three are answered through the semantic query tools, and since the user
directory was retired those tools are also the only route by which a question
about your organisation is answered. With the switch off, all three sources
connect, probe, enable and ingest correctly — every tick reports success and
every row lands — and nothing can be asked about any of them.
Nothing else changed. No new image to mirror (the image already shipped in 0.10.0 whether or not the switch was on), no data migration, no change to any other default. On a stock install the difference is one extra Deployment.
If you already installed 0.10.0, you do not need to reinstall: upgrading to
0.10.1 is enough, and so is setting mcps.semanticMcp.enabled: true by hand on
0.10.0. Either way your ingested rows are untouched — this changes what can read
them, not what is stored.
false remains a one-upgrade rollback, exactly as before.
0.10.0 (3 September 2026)
The release that makes the ingestion pipeline usable end to end. Until now
a source was a chart value and a hand-written module: you set sources.github
to live, and code we wrote for GitHub read GitHub. From this release a source
is a row you connect — on a new admin page, from a recipe, through a
credential, a probe, an approval, a verification and a switch — and the same
generic engine reads every one of them from a document describing the source.
That is what makes three new kinds of source possible at once: a warehouse
Prism queries in place (Snowflake), a database it copies (Oracle), and a
staff list you upload. GitLab arrives the same way, as a recipe rather than
a module.
It is also the release that retires the user directory, which we said we
would do and which changes an answer you may have relied on. If you read one
section, make it the one below.
If you run ingest.engine.mode: shadow, read
the engine section before you
look at the self test after upgrading.
sources.registry.enabled now ships ON, and that is the single biggest
change to what a default install does. Earlier notes told you to leave it alone
until a release note said otherwise. This is that note: upgrading gives you
Admin → Context streams without setting anything, and the page is writable.
ingest.engine.mode now ships authoritative as well, which is what makes
the new ingest run at all — and which also moves GitHub and Jira off their
legacy lanes, continuing from each lane's recorded progress and keeping the rows
already there. Read
The ingest engine is on by default now
before you upgrade; setting it back to "off" restores the previous behaviour
exactly. One switch still ships off in 0.10.0:
mcps.semanticMcp.enabled — and it should not have. See
0.10.1 immediately below, and prefer 0.10.1 to this
release. What each switch unlocks is in Upgrading at the end.
Each switch is still a switch, and setting any of them back renders nothing at all — a one-upgrade rollback if you would rather not carry the page, the engine or the query tools yet.
The user directory is gone, and one of your sources is the directory now
Prism no longer keeps a list of people of its own. The user_directory table,
the /admin/directory page that edited it, the CSV import, the Router identity
sync and the derived rebuild are all removed — three chart values with them
(sources.directory, ingest.directory.schedule,
app.identityMapping.enabled), and the chart refuses an upgrade whose values
file still carries sources.directory or app.identityMapping, naming the
key, rather than rendering on and ignoring it; the schedule is simply no longer
read. We told you in 0.9.0 that this was coming; it was stated as a commitment
on the calls that shaped this release, and this is it landing.
What replaces it is a source you connect like any other and then designate as your organizational context stream: your HR view in a warehouse, or the uploaded roster below while you wait for one. Exactly one enabled source may hold the designation. It is set on Admin → Context streams and can be withdrawn there; neither step touches a stored credential. Prism will not guess which of your sources is the directory of people — that decision is what every per-person figure is computed against, so it is yours, explicitly. The procedure and the two refusals you may meet are in Telling Prism which stream is your directory.
What you will notice in the answers:
- A quiet quarter now reads as "unresolved", not 0. Prism used to be able to tell "a real person who closed nothing this window" from "not a real name", because it held the roster. It no longer can from its own records, so that person comes back unresolved, and the answer says the only roster it checked was the issue store's assignees. That is the safe direction — a name Prism cannot resolve gets no figure rather than a zero that reads as a measurement — and on a synthetic install the old behaviour was worse, since a made-up name could earn a real-looking 0. Designate a stream and the population question has an answer again. Written up in Limits.
- Team questions need a designated stream. Nothing Prism reads knows which team somebody is on — that was true before, and the column you used to type in is gone with the page. With no stream designated, a team-shaped question is refused with a request to name who is meant. With one designated, questions about people, headcount, departments and reporting lines are answered from that source, and the answer names it.
- Questions about your organisation are routed, not guessed. Every chat
turn is told which source holds the designation (or that none does), and the
old
resolve_user_directorytool is gone, so the agent cannot answer a people question from a list nobody designated. If Prism cannot determine which source is designated — the registry is unreachable — it answers nothing about people rather than falling back to anything.
What this release does and does not do for the join between systems, stated carefully because it is the thing most likely to be assumed:
- Prism now compiles an identity crosswalk at ingest — one table, rebuilt
whole at the end of every ingest tick, from the designated stream's own
declaration of which column is the employee id and which the email
(
declared), plus any email-to-login pairs the GitHub harvest has recorded (observed, kept at lower confidence). An identifier that names more than one person is marked ambiguous and resolves to nobody rather than to one of them. Nothing is matched on names, deliberately, and the table's constraint stops a name match arriving quietly. Because it is rebuilt from scratch, a stream connected late or corrected repairs every answer at the next tick with nothing to re-walk. - Nothing answers a question from it yet. A question that puts one person's GitHub work beside their Jira work is still refused in this release, with the single-source half offered instead; the join that reads the crosswalk is later work. What works today is what the previous release could do per source — figures by GitHub login, by Jira assignee — plus the organisational questions above, answered from the designated stream alone.
- It is not compiled from a warehouse. A query-through source copies
nothing into Prism, so there is no table to compile from; a Snowflake
directory designates fine and routes organisational questions, and the
compile step names that limit in its log rather than failing later. Nor is it
compiled at all with
ingest.engine.mode: off— the tick that runs it does not exist there — so an install that designates a stream on a default chart has designated a routing target and built no crosswalk.prism-ingest crosswalkinside the ingest image builds one on demand. - Coverage is declared, not measured. How many of your GitHub logins actually resolve to a person in your directory is a number this release does not compute or display; the only way to know it is the query the Snowflake page gives you to run in your own session. A measured rate, and a threshold that refuses a cross-source answer below it, are both later work — so a partly-populated join answers about the people it can and warns about nothing.
Two prerequisites are worth reading before you install, because no setting substitutes for them: What your GitHub must expose for people to be correlated — a directory that carries the GitHub login, or a commit-author harvest that your GitHub's privacy settings may make impossible — and One stream must carry your org directory.
Admin → Context streams is where a source is connected now
The page was read-only. It is now the place a source is registered and taken through its life: pick a recipe → enter the credential → probe → review the manifest → approve → verify → enable, with disable and delete beside them. Everything it does is also an API, behind the same super-admin check.
- A recipe is a manifest. The picker offers the source documents this release ships — GitHub, Jira, GitLab, the uploaded people file — and two offered as templates only: a Snowflake worker directory and an Oracle resource plan. GitHub, Jira and the two new shipped sources already exist as rows on every install with the registry on, so the picker says they cannot be registered a second time and points at the row. What you can add is a template, or a manifest of your own pasted into the editor.
- The order is a mechanism. Each verb is legal from named states and a
wrong one is a refusal saying where the row is and where the verb is legal
from — never a silent no-op.
enableadditionally needs a probe that returned and passed against the credential the row holds now. There is no probe exemption, not even for a source with no credential. - A probe says what it proves, and it is less than "it works". For an HTTP source, one request: reachable, credential not rejected. For Snowflake, a session opened and read back — user, role, warehouse, database and schema as the account established them, compared with what was asked for, because Snowflake will hand you a session without a warehouse that does not exist rather than refuse it. Unavailable is a third answer, not a failure, and it never enables anything.
- Credentials are custody, not display. A credential is checked before it
is stored, encrypted at rest under the install's
encryption-keySecret, and never rendered back — the page shows only that one is held and when it was last replaced. Rotating a source's credential is now done on the source, with no Secret to patch and no pod to roll; rotatingencryption-keyitself is a re-entry of every stored credential, which Rotate a credential spells out. Delete requires the source's name typed, because it destroys the credential with the row and there is no undo. - There is no edit verb in this release: a registered row's parameters and manifest are fixed, and the correction path is delete and re-register — which costs the stored credential. The Snowflake page front-loads its column checks for exactly that reason; the apply route below is what removes it.
enabledis notverified. Enabling a source means this installation demonstrably reached it. Whether a figure is right is the verification screen: each source carries stored questions, an administrator presses Run — one query, no model, no tokens — reads the figure and the rows behind it, and marks it Correct, Wrong or Skip. Only a human verdict makes a source verified; a run writes nothing, and the buttons are dead until a run has answered. A verdict survives a restart and a manifest upgrade. Acorrectverdict on an enabled source becomes a catalogue entry badged proven by you — the customer's own badge, distinct from the proven one our golden suite earns — where the semantic query MCP is attached. For a directory you authored, the questions are yours to write: Prism ships none it could not know the answer to.- The semantic query MCP sees a registered source the moment it is enabled, and stops seeing it the moment it is not. No restart, no cache: it reads the registry on every call, so a source switched off on the page is not askable on the next question. The image's shipped manifests still supply the model for GitHub and Jira; the row supplies the verdicts. A registered source also runs on the parameters its administrator typed, not on the pod's environment — the environment is the fallback, not the override.
- The count you are licensed for is on the page. A context stream is a
named group of datasets answering one kind of question — GitHub pull
requests is one, GitHub issues is another, your org directory another — and
it is the unit Prism is sold by.
sources.entitlement.contextStreams(default 10) is the figure in your contract; the top of the page states it beside how many streams this install actually has enabled, and the self test'ssource registryline says the same pair. Nothing enforces it: over the number is stated as a fact and every source keeps working. It is a number an operator types, with no licence artefact behind it. A source that is registered but switched off counts for nothing, and the uploaded people file is marked supporting so your own staff list never spends a stream. Limits has the rest. - Every write leaves a record. Each register, credential, probe, approve,
enable, disable, designate, verdict and delete writes one
source_writerow to the app's own event log (app_events, with adetailcolumn that names the verb, the source, the outcome and what moved) and one line to the app pod's stdout, so an install with nothing reading its datapond still has the trail. A write that lost a race with another tab is recorded as such. What is deliberately not recorded: any credential material or anything derived from it, parameter values (names only — a value is where a hostname lives), and an upstream's own words about a probe. A write refused before it reached the store — a wrong-state 409, a credential the probe rejected — leaves no event yet.
sources.registry.enabled ships on in this release, so the writable page
is what an upgraded install gets. Set it to false and the page is
byte-for-byte the read-only page it was — nothing is rendered, which is what
makes it a clean rollback.
One thing a source you connect here does NOT yet get: a row on the readers'
source panel. That panel — the one that tells a reader what this installation
holds before they ask anything — still lists the sources Prism was built with,
so a warehouse, database or roster you connect will not appear on it in this
release. It is not invisible: the agent knows about it, and questions against it
are answered normally. What is missing is the advertisement, and it arrives in
the next release. The self test gains one line when it
is on, source registry, which also reports whether the registry and the
legacy source lists agree; a source that arrived on the engine and never had a
legacy lane is reported there as registry only rather than as a disagreement.
Two database kinds: one Prism queries in place, one it copies
Snowflake — queried where it lives. A query-through source copies nothing:
every question about it becomes one statement against your warehouse, run as a
service login you control, on a warehouse you name (which is where the spend
lands). It needs the semantic query MCP, which ships on. The account, login,
warehouse, database and optional schema are parameters on the source, the
credential is the private key of a TYPE = SERVICE user's key pair, and
the read-only role is written into the recipe so the ceiling on what a
question can read is the grant you made. Two documents cover it:
A source Prism queries in place
for the switches and the probe, and
Connect your Snowflake as the organizational directory
for the one you are most likely to want — authoring a manifest against a view
we have never seen, with a worked example annotated line by line as
structural or yours, the SQL to run in your own session first, and what a
probe does and does not prove.
Say plainly what has and has not been proved, because a green tick invites the
wrong reading. The compiler now emits a second SQL dialect, and every construct
in it is a construct that in Postgres either does not exist in Snowflake or —
worse — parses and means something else (a bare $1 on Snowflake is the first
column, so a filter silently became true for every row). Every statement in
our golden corpus has been put through a real Snowflake account's parser:
ten of twelve compile, and the two that do not carry an expression written into
the GitHub manifest itself in Postgres terms, which no dialect may rewrite. A
directory-shaped view on our own test account has been queried end to end
and returned the figures we loaded it with, through the same probe, executor
and floor a customer's install uses. What has not been done is a
figure-for-figure comparison of the two dialects over one dataset: no median or
percentile has been run on Snowflake, and nothing has proved that the
arithmetic agrees with Postgres. That is why section 7 of the Snowflake page
asks you to verify with questions whose answers you already know, and why
enabled is not verified.
Oracle — copied on a schedule. A snapshot source is the opposite shape:
Prism opens a read-only session on a schedule, runs one SELECT against one
view, writes the rows into its own database, and answers from the copy. Your
database sees a walk and nothing else. The driver ships inside the ingest image
in thin mode — no Instant Client, no JVM — with two limits your DBA should know
before planning: sqlnet.ora is not read at all, and a cwallet.sso wallet
cannot be used.
An Oracle source can be registered, probed, enabled and walked in this
release. An earlier draft of these notes said it could not be enabled; that
was true when it was written and is no longer true. The whole path has been run
against a real Oracle Database Free 23ai instance over Oracle Net: registered
from the recipe, credential stored, probe returning reachable and
credential_not_rejected with the session it opened, approved, enabled, walked
by the scheduled tick, and answered from — 120 rows into the datapond, the
source reporting live, and the agent answering a question about it with the
connected — nothing verified yet caveat attached.
Two things that walk has not proved, and you should read the figures with them in mind. The walk paged once, so the keyset page boundary is unexercised at scale. And the rows behind it were loaded by us: the shape and the mechanism are proved, nothing about anyone's data is.
What your view must do, because one detail of it fails silently. The recipe
reads a view, not a base table, and the flag columns — Is Active, Is Open For Time Entry — are compared as text. A view that hands them back as a NUMBER
is valid SQL that quietly answers false for every row: the walk completes, the
rows land, the source reports live, and every person in your organisation
reads as inactive with nothing anywhere saying so. This release widened the
comparison, and the recipe now reads Y, YES, T, TRUE or 1 as true,
whatever the column's type. Anything else is false — including -1, which some
estates use for true — so the cheap fix is still to say which spelling you want
while the view is being written. The same is true of the column names: the
recipe reads quoted, mixed-case names with spaces, exactly as they appear in
the extract.
If you already registered an Oracle source, upgrading does not change it. A
registered source carries its own copy of the document it was registered
from, and that copy is what the walk uses — so a recipe corrected in a release
does not reach a row created before it. This matters for exactly the flag
comparison above: an install that registered the Oracle recipe before this
release keeps the narrow = 'Y' test, and will keep reading a numeric flag
column as false for every row after upgrading, with nothing saying so. The
remedy is one pass on Admin → Context streams: re-apply the recipe to the
source, then approve and enable it again. Measured — against a view handing
back a numeric flag, the same source read 0 of 120 people as active before
re-applying the recipe and 120 of 120 after.
The recipe is offered as a template so that the view, the service account, the grant and the network path — the long pole by a wide margin — can be prepared before you connect. The procedure is in A source Prism reads out of an Oracle database.
GitLab merge requests, as a recipe rather than a module
GitLab is the first source that arrived with no code written for it: a
shipped manifest over group.mergeRequests, read by the ingest engine. There
is no SOURCE_GITLAB, no chart credential and no separate job — you add it on
Admin → Context streams, paste a read-only token (read_api, read_user,
read_repository), name the groups, and the ingest tick picks it up. It needs an ingest.engine.mode other than off; the registry it also
needs is on by default now.
Read How Prism reads GitLab before you issue the token; the things it says that are not obvious:
- The group list is a coverage decision. The walk reads exactly the groups
you name, and a group the token cannot see comes back as empty, not as an
error — GitLab answers it with a
200and a null. Prism refuses to read that null as "no merge requests" (it withholds rather than deletes), but nothing in the data says the group was out of scope. - GitLab figures stay in GitLab. A merge request carries a username and nothing else that identifies a person; an ordinary token cannot read email addresses on a self-managed instance. Prism records the username, declares it as exactly that, and reports GitLab activity on its own — it will not join a GitLab author to a Jira assignee or a GitHub login without evidence. An administrator-scoped token retires the distinction, and the page says so.
- The link to Jira is the branch name. No merge request declares a Jira issue, so Prism reads an uppercase project key out of the source branch. Lower-case keys are not read, deliberately. On the instance this recipe was measured against, about 30% of branches carried one; that share is the ceiling on any cross-source question, and a missing key is reported as a branch that did not name the issue, never as untracked work.
- Nine requests a minute by default, measured against a large self-managed instance that publishes no rate-limit headers; lower it if your platform team prefers.
On every install with the registry on, gitlab appears as a registered,
disabled source until an administrator connects it. It is one context stream.
Upload a staff list as an interim directory
For an administrator who has no system of record connected yet but can export
their people to a spreadsheet: a CSV with four columns — employee_id,
email, given_name, family_name — uploaded on Admin → Context streams
under People file (uploaded). Nothing leaves your network; the file goes
from your machine into your own appliance.
- The upload is the probe. A file source has no endpoint and no credential,
so the parse is the evidence
enabledemands. Prism parses before it stores: a file it cannot read is refused, the message names what was wrong (a missing column, a row with the wrong number of cells, a duplicated header, a header-only export, a workbook or UTF-16 file saved by mistake) and the previous file stays exactly where it was. No refusal ever quotes a cell, so it is safe to paste into a ticket. - It reports what it found — rows read, per-column coverage, and keys that
repeat. The
emailcount is the ceiling on everything per-person the roster will ever buy you, and it is cheaper to meet here than in a report. - It is an interim directory, and undoing it is one click. The roster claims no role by itself; an administrator may designate it as the organizational context stream, and when a real directory is connected the handover is withdraw, designate, wait for the next tick — the roster's own links are dropped, not merged, and neither source loses anything. A person who has left stays in the file until you upload a newer one, and Prism will not answer "how many people work here" from it.
- Extra columns are ignored; a manager id or a worker type can be declared in the manifest if your export carries one, at the cost of making that column required. One file per installation.
Upload a staff list as an interim directory is the page.
Like every engine-native source, it needs the ingest tick to run — an
ingest.engine.mode other than off — before the stored file becomes rows.
Test a manifest before you apply it
The draft → test → fix → apply loop the Snowflake page describes now exists as
two routes rather than as advice. Test takes a candidate manifest and the
connection a registered row already holds, and reports three tiers separately:
whether the document validates and its statements can be synthesised
(offline, every kind); whether one coverage statement per dataset — every
field, hidden ones included — and one per metric EXPLAIN cleanly against
your warehouse (free; it needs no running warehouse); and whether the same
statements executed over no rows return the column names and types the
model declares. Every failure comes back as repair material: the error, a
pointer into the manifest, a hint, a reader's sentence, the nearest matching
column and the columns the view actually has. A wrong column name is reported
per field, all at once, rather than one per attempt. Snapshot, mirror and file
sources get the first tier only, and the response says why.
Apply replaces a registered row's manifest without losing its credential or its connection parameters: the document and its hash move together, every verification verdict returns to pending, the source is disabled, and it lands on probed only where the stored probe verdict is about the credential the row holds now. On a file source it discards the upload's parse verdict, because that verdict says these bytes parse as that document describes them and the document has just changed: the source goes back to registered, switched off, and upload the file again — the upload is what checks it — before approving and enabling. It refuses a candidate whose connection block or kind differs from the row's, because "the probe verdict still stands" is only true if the connection did not change. That closes the delete-and-re-enter cost named above.
The ingest engine is on by default now
ingest.engine.mode ships as authoritative. It was off, and if you take
this upgrade without setting it, three things change on your install.
The three new context streams start working. GitLab, an Oracle snapshot and
an uploaded roster are engine-native — they have no legacy lane — so with the
engine off they register, probe, approve and enable and then ingest nothing.
shadow is not a halfway house for them either: with no lane to shadow they
walk into <table>__shadow, which nothing reads. authoritative is the only
mode in which they hold data, which is why it is the default rather than a
recommendation.
GitHub and Jira move off their legacy lanes. The engine writes the real
tables and the legacy lane stands down. The takeover continues from the lane's
recorded progress and keeps every row it already fetched — the engine reads the
same watermark the legacy lane wrote, so the first run after the promotion is an
ordinary incremental run, not a re-copy. The room to budget is an incremental
run's, not a backfill's. It is a first run in the ordinary sense — clearing the
table and backfilling — only where no run of that source has ever completed
here: a table still holding the synthetic seed rows, a source that has never run,
or a first backfill that died before it finished and whose recorded ground does
not cover what the engine asks for. If you would rather move them on your own evidence, set
ingest.github.engineMode or ingest.jira.engineMode to off — a per-source
value wins — and the rest of the install still gets the engine.
Corrected 7 September 2026. These notes said, until now, that the first engine run truncates and re-backfills each table it takes over. That was wrong, and wrong in the direction that costs you something: it describes a promotion as destroying history it does not touch. Measured on a Jira Data Center takeover holding 94,049 rows — every row kept. Nothing about the release changed; the sentence describing it did.
The GitHub identity harvest stops. The harvest is a step on the legacy lane
and the engine's manifest has none, so ingest.github.identityEmailDomains is
inert for GitHub in this mode and no new email-to-login evidence is collected.
Anything already harvested is kept and still folds into the crosswalk. This
matters if you were relying on it to resolve people to GitHub logins: today
that is the only route to a github_login, so on an install with no prior
harvest a question scoped to a group of people cannot match anyone through
GitHub, and Prism refuses such a question rather than answering it about nobody.
Resolving people through a directory you designate is unaffected.
Corrected 10 September 2026. This paragraph describes the commit-author harvest, and about that step it is still right. It is no longer right about
ingest.github.identityEmailDomains, or about what an install in this mode can match. Since 0.11.0 the profile sweep reads each organisation member's published profile address on the ingest tick, runs inshadowandauthoritativealike, and is narrowed by that same setting — so the setting is not inert in this mode, and a question scoped to a group of people can match them through GitHub on an install that never ran the harvest. The"off"below is the one mode with no sweep, because it renders no tick. Nothing about the release changed; these sentences stayed behind when 0.11.0 added the sweep.
To keep the previous behaviour exactly, set ingest.engine.mode: "off" —
quoted, because an unquoted off is YAML's false. The chart then renders no
engine at all, and the install is byte-for-byte what it was.
Flip the ingest engine one source at a time
ingest.engine.mode moves every ported source at once, which meant promoting
GitHub on the evidence GitHub had produced also promoted Jira on evidence nobody
had looked at. Two new values give you one lever per source:
ingest.github.engineMode and ingest.jira.engineMode.
-
Each takes
off,shadoworauthoritative, spelt exactly, and empty — the default — inheritsingest.engine.mode. Nothing changes on an install that does not set them. -
The per-source value wins for that source, in either direction: up, to promote one source ahead of the rest, or down to
off, to hold one back — a rate-limited GitHub Enterprise instance that cannot take a second walk yet — while the others go on shadowing. That is also how an install that turns onshadowfor GitLab or the roster keeps GitHub and Jira from taking the shadow walk. -
ingest.engine.modeis the master switch. While it isoffa per-source value of anything butoffis refused, athelm upgradeand again at ingest start-up, naming both keys. Inoffthe chart renders no engine at all, so such a value would be an instruction nothing could carry out. -
Setting or changing a mode value starts a run of that source. The value is rendered into the source's ingest container, whose text is hashed into the
-ingest-now-Job name, so the upgrade fires a real ingest against your real upstream rather than waiting for the schedule. Forauthoritativethat is the engine's first run against your real tables — which, on a source the legacy lane has already ingested, is an incremental run continuing from that lane's progress, not a backfill. Budget a backfill only for a source that has never completed a run here. -
The evidence differs by source. GitHub is gated on a shadow-parity streak you earn on your own install —
ingest.engine.parityRunsconsecutive passing verdicts, reported on the self test'ssource registryline asshadow parity: github ok 3/3 ready. Jira is not: its port is verified against a seeded Jira before the release ships, and re-running the two lanes side by side against a production Jira could only agree or find a defect in a lane that is being retired. -
Rolling back is a values change, not a downgrade: put the source back to
shadowand the legacy lane serves again from the watermark the engine left. What is proven is that the two lanes keep their bookkeeping in one row, in one shape, field by field. What is not proven is a rollback after a long authoritative run on a populated install; if you want certainty over the ground the engine covered, re-sweep it withingest.github.rewalkFromfrom the date you flipped. The legacy lane ships for one more release after a source flips, as the rollback. -
The GitHub identity harvest does not run while GitHub is
authoritative. The harvest is a step on the legacy lane; the engine's manifest has none, so no email-to-login evidence is collected andingest.github.identityEmailDomainsis inert for that source while the mode is on. This is accepted rather than pending: the people in your organisation come from the source you designate, and the crosswalk folds in whatever evidence the harvest has already left. Keep GitHub onofforshadowif you want the harvest to go on accruing.Corrected 10 September 2026. True of the commit-author harvest only. Since 0.11.0 the profile sweep collects email-to-login evidence on the ingest tick whatever mode GitHub is in, and
ingest.github.identityEmailDomainsnarrows it — it is not inert for GitHub in this mode. Holding GitHub back for the harvest is no longer the trade it was; see Whatauthoritativedoes not do on the flip page.
The operator's page — the modes, the gate, what to check afterwards and how to put it back — is Flip a source to the ingest engine, beside Take a source live in the navigation, and Flip a source to the ingest engine is the procedure.
Three fixes to the streak itself, which matter because it is the number the flip is gated on:
- The streak counts evidence, not recordings. It used to advance on every
engine run and on every
prism-ingest check parity, with no engine run at all — three runs fromreadywas two shell commands. A run now counts only where the comparison window has moved since the last one counted, so several engine ticks between two legacy sweeps are one piece of evidence;check parityre-compares and never advances it; and a manifest edit or an ingest image whose comparator changed restarts the count. At the default six-hourly GitHub schedule,parityRuns: 3is about eighteen hours of evidence rather than three ticks of it, which is the point. - Every way out of the walk breaks it — an ordinary exception, not only the
freshness gate — so a streak reading
readyno longer survives a run that crashed. A killed process still writes nothing; that is named rather than closed. - A stuck verdict names the days and the value that clears them. A day the
engine could not enrich during an upstream outage sat in its bookkeeping for
ever, reset the streak on every run, and nothing said so. The verdict now
names the dates and points at
ingest.github.rewalkFrom— and that value now re-sweeps both lanes, each claiming the date on its own row, which is what makes it the repair. Budget two passes, the engine's at half rate, and blank the value before you first set the mode toshadow: a date left over from an earlier repair is unclaimed by the engine's lane and starts a full re-sweep at the first tick. The whole procedure is When a shadow parity verdict will not clear.
Also fixed on the engine before, rather than after, the flip: a GraphQL
response carrying a null nested connection — GitHub declining to answer,
not a pull request with no links — was read as an authoritative zero and the
child rows already held were deleted, silently, with the run reporting
success. The engine now declines to reconcile a parent that did not answer,
warns naming the parents, and records them on the source's ingest state. In
shadow nothing reads the engine's tables, so this could only ever have
reached a served answer through an authoritative source.
Flip a source to the ingest engine is the procedure — the values, the gate, what to check afterwards, and how to roll back.
A short search page no longer ends the walk
Read this if your GitHub token cannot see the whole organisation. Both the legacy lane and the engine ended a day's walk when a page came back short of 100 results. GitHub's search pages by offset and counts matches before dropping the ones the token cannot see, so a narrow token is served a short page from every slice — and the walk stopped on the first, every run, for ever, on any install whose credential does not see everything. The same rule lost rows under load: measured during a backfill through an upstream outage, 23 days affected, 298 pull requests, 501 reviews, 121 links and 105 issues absent from the mirror and nothing recorded.
Both lanes now page by the count GitHub reports, stop on an empty slice rather than a short one, and subdivide a bucket GitHub marks as incomplete. The legacy lane is otherwise closed to changes while the engine replaces it; this one was taken because it is a live data-loss bug on the lane every install runs today. A quiet day still costs one request. What it does not close: a page that was short and was not the last one still loses the rows missing from its own range, which needs a re-fetch and is tracked separately.
Bug questions count issue types as well as labels
GitHub issue types — the org-level Bug/Task/Feature classification
that sits beside labels — are now ingested, and a bug question is answered as
the union of the label and the type, saying which axis found each issue:
"251 open bugs: 224 typed, 91 labelled, 64 both" rather than a bare number. On
the repository this was found in, the label alone undercounted by 2.75×,
because the organisation had adopted types instead of the label.
Three things to know, all in Issue types, and the one upgrade that needs a re-sweep:
- The union needs the semantic query MCP, which ships on. Without it a bug question keeps being answered on the label alone, however full the type column is.
- The type is not recoverable from what is already stored. Every issue
already mirrored carries an empty type until the lane fetches it again, and
an empty type is indistinguishable from a repository that has none — so set
ingest.github.rewalkFromonce, at or before the issue horizon, and let it go round. The answer carries no warning that the column is part-filled; until the re-sweep has finished, treat the typed half of a bug figure as partial.gh api orgs/<org>/issue-typestells you whether your organisation uses types at all. - Under the engine, issues reach back a year whatever
ingest.backfillDayssays — a floor, so that a year-to-date backlog question has an answer on any date, and so that lowering the value for a fast first pass no longer shortens the issue walk. It is affordable because a day older than the pull-request window is walked by the issue search alone: roughly 9–18 extra minutes per organisation at the default cap. Iningest.engine.mode: offnone of this applies. Where the legacy lane has already run, its horizon wins and the same re-sweep recovers the year.
Beside it: the engine now asks GitHub for 100 closing references per pull
request rather than 50 — GitHub's own ceiling — counts a pull request that
fills the connection and names it in the run log; an already-mirrored
bulk-closing pull request keeps the 50 links it was stored with until it is
touched again. The self test gains a github_issues line that reads an empty
index as a pass once the sweep has run and as a warn before, since an
organisation that files no issues is a normal shape. The catalogue offers a
fifth intent, What is in the backlog, with the bug entry shown only where the
semantic query MCP is attached, because without it the entry would be answered
on the undercount.
The per-person floor is yours to set, and it now ships OFF
This changes what a default install discloses, so read it even if you read nothing else in this section.
The floor used to be a number we chose and you could not lift. It is now
policy.perPersonFloor, an installation setting, and it defaults to 0 —
which discloses. A per-person statistic computed over one person is returned
on a default install of this release, where an earlier release would have
withheld it.
That is deliberate, and it is the same principle as the rest of this page:
disclosure policy is a decision about your organisation, your works council
agreements and your employment law, and it is not one an appliance vendor can
make on your behalf. Set policy.perPersonFloor to the smallest cohort you are
willing to have a statistic computed over — 5 is a common choice — and Prism
will withhold anything computed over fewer and say that it did. Leave it at 0
and it discloses, and says nothing, because you asked it to.
The same setting also decides whether a partial match on a person's name or address is refused, so raising the floor closes the enumeration routes below and lowering it opens them.
Whatever you set it to, two routes past it that were open on every install, on the shipped Jira manifest, are closed:
- A prefix walk. A filter on a hidden identity field with
starts_withengaged no floor at all, so an address could be enumerated one character at a time. A person field now takes five operators —equals,not_equals,in,not_in,is_null— and every other operator on one is refused, including any operator added tomorrow. The cost is stated rather than buried: a legitimate one-shot question like "everyone whose address ends in the contractor domain" is refused along with the walk, and the refusal returns the five operators and what they cost. - A padded list, and an exclusion. The floor engaged for an
inlist only while it was no longer than the floor, so one real address padded with five that match nothing was a "cohort"; andnot_inover everybody-but-one isolated one person exactly and was unfloored. Any query filter on a person field now engages the floor, whatever the operator.
Be clear about what the floor is, once you have set one, because it is not a
disclosure boundary.
It is a control on statistics: it withholds the aggregations a manifest
names in min_n_applies_to — averages, medians and percentiles by default;
counts and sums only where a manifest opts them in, as the warehouse and Oracle
recipes do — from an answer computed over fewer people than the floor, and it
says which
figures it withheld. It does not withhold row keys, observation counts, row
order, or a figure that carries the same information without being one of
those aggregations, and there are known routes of that shape still open. An
empty population is now reported as no data rather than as a figure
withheld, and asking for a companion count such as never_reviewed on its own
now returns a row for everyone in its median's population, zeros included, as
the legacy tool always did.
Provenance says what kind of source a figure came from
An answer over a source Prism queries in place used to say its rows came from "Prism's datapond … not live" and were "ingested by a batch job" — both false. Provenance is now driven by the source's kind: a mirror reads as before, a snapshot says it is a dated whole copy loaded on a schedule and that you are reading the newest load, and a query-through source says it was queried live, not stored here, read from its own system of record when the question was asked. The freshness readings underneath stopped inventing an ingest watermark for a warehouse that has none, and the source list names each source's own plane rather than one for all.
Your own name and logo
branding.productName replaces the word Prism in the browser tab and the
heading of every page — the rest of each heading is the page and stays —
and branding.logoSvg puts your mark before it, on the chat, admin, self-test
and activity pages and on sign-in. Both are optional and an install that
leaves them unset serves the same bytes it did before; nothing else is
themeable, deliberately. The logo travels as SVG source in your values
file, not as a URL, because the appliance may have no route out of your
network; it is rebuilt from an allowlist of drawing elements before it reaches
a page, and a file whose root is not <svg>, is not well-formed, or exceeds
64 KB is refused whole with the reason in the app log.
Put your own name and logo on it has the procedure, including
the one thing to check in the file (viewBox).
Every page behind the sign-in also states the release version beside the name — the artefact the collection ritual photographs — and withholds it from a request the front door has not identified. The chat header stays pinned while you scroll.
Also in this release
- A rejected query hands back what to try next, in two voices: a hint for the model — field names, pointers, the nearest matching names — and one sentence for the reader with none of that in it. A turn the agent repaired itself no longer prints "1 call failed" in the footer; a real timeout still does.
- A partitioned, paged walk records the resume point every partition has passed, not whichever partition wrote last. GitLab is the first source that is both, and an interrupted first load of it re-does the window rather than claiming ground it never walked.
- Every image took the OpenSSL fix for CVE-2026-14456 — this repo's eight by an explicit package floor in each Dockerfile, the runner by moving its pin upstream. It was fixable, so it was never an acceptance, and the section at the top of this page is unchanged by this release.
- We corrected the 0.9.0 note above on whose timezone reads a bare JQL date bound: it is the API account's profile zone, falling back to the site default only when that profile has never set one — measured on Jira Cloud with a count that could only come out one way, and documented rather than measured for Server/Data Center. The A/B check on the Jira page settles it on your instance either way; see What we measured, and where we did not.
- Release and CVE policy is published: weekly through 0.x and monthly from 1.0, a critical within 48 hours of a fix being available, everything else in the next release, and what a finding with no fix to take gets instead.
- Widen a source's scope is the procedure for a
credential that was granted more repositories or projects: widening is
invisible to Prism, the history arrives only when the days are walked again,
and
ingest.jira.projectsis a filter on top of what the credential can see.A fine-grained PAT stops at 50 repositories, which is GitHub's limit.Corrected in 0.11.0: that count applies to picking repositories one at a time under Only select repositories; a token scoped to All repositories carries no such cap. The sentence sent readers to a change of credential when re-scoping the token was the answer. - Registering a database source was a 500 on the first call an administrator makes; several designations of a source that already held the organizational role, arriving together, could wedge the app's connection pool for the life of the pod; a source that arrived on the engine read as a permanent self-test warning. All three fixed.
- The organizational stream's
roleis speltorganizational, with a z, everywhere; the earlier spelling is refused by the schema from this release. No released install stored the old one. - The recipes are named for the connection they make — Snowflake worker
directory, Oracle resource plan — rather than for any one customer's
system, and the shipped Snowflake recipe carries an explicit
sql:on every field so that a reader copying it sees the column mapping on the line they copy. - A prose name resolves to the people it might mean. Asking about someone by name no longer guesses at one match: the name goes through a route that returns the candidates it could mean and cannot express a figure, so a question about the wrong person is a disambiguation rather than a confident wrong number.
containsandnot_containsare available on a string field — the only two operators that fold case, stated so that a filter's behaviour is predictable rather than discovered.- A second instance of a recipe is a second context stream, and is counted
as one against
sources.entitlement.contextStreams. Two GitHub organisations are two streams; the figure on the page says so. Enabling a second instance of a mirror recipe while the first is enabled is refused, naming the holder, rather than the two silently merging into one set of tables. - A GitLab source can actually be enabled: the probe was reading the web root rather than the API, so a correct token failed a correct instance.
- An Oracle source can be probed, which is what stands between registering one and enabling it.
- An emptied identity crosswalk is a red line on the self test, not a silence. A designated organizational stream that stops resolving anybody used to look exactly like one that had nothing to resolve.
- The rewalk levers are tied to the list of sources the engine runs, so a
newly ported source cannot ship without the
rewalkFromvalue an operator needs to re-sweep it. GitLab was missing one; it has one. - Enabling a source tells you what will happen next, and no longer guesses. The message you get on enable now states only what this installation can actually see — whether the engine has a proven walk for that recipe, whether a query-through source has anything attached that could answer it, and where to look for what the ingest tick did. It used to predict, and it was wrong for three of the four kinds of source.
Upgrading
Nothing here requires a re-ingest, and the images you mirror are the same ten as 0.9.0 — the new sources are documents and routes, not containers. Three values changed meaning; three were retired and are refused; and the new function is behind switches you turn on deliberately.
Retired — remove them from your values file before upgrading; the first
and third are refused by name at helm upgrade, the second is no longer read:
| Value | What replaces it |
|---|---|
sources.directory | designating one of your sources as the organizational context stream, on Admin → Context streams |
ingest.directory.schedule | the crosswalk is compiled at the end of every ingest tick |
app.identityMapping.enabled | /admin/directory is gone; the uploaded roster is the replacement for a CSV of people |
Changed meaning:
| Value | What changed |
|---|---|
ingest.engine.parityRuns | counts evidence, not recordings; every existing streak restarts on the first run after upgrading — see the engine section |
ingest.github.rewalkFrom, ingest.jira.rewalkFrom | under an engine mode other than off, re-sweeps both lanes; blank a leftover date before first setting shadow |
ingest.backfillDays | under an engine mode other than off, a floor of 365 days applies to the GitHub issue walk; lowering this moves pull requests alone |
New values, all optional, all defaulted:
| Value | Default | Why you might set it |
|---|---|---|
branding.productName | empty | the appliance wears your product name |
branding.logoSvg | empty | your mark, as SVG source, via --set-file |
sources.entitlement.contextStreams | 10 | the number in your contract; stated, never enforced |
ingest.github.engineMode, ingest.jira.engineMode | empty | flip one source rather than all — see Flip a source to the ingest engine |
The switches, and what each unlocks. sources.registry.enabled ships
on in this release; the other two still ship off, and an install that
leaves those off gets the fixes above and the Context streams page, but none of
the query-through or ingest-engine surface:
| Switch | Ships | Unlocks |
|---|---|---|
sources.registry.enabled | on (was off) | the writable Context streams page and its API, the entitlement figure, every recipe, the audit trail, and encryption-key reaching the app and the semantic MCP so a stored credential can be read |
mcps.semanticMcp.enabled | on (was off) | any query-through source, the bug-question union, the proven by you badge, and the route by which organisational questions are answered from a designated stream — and the only reader GitLab, an Oracle snapshot and an uploaded roster have at all |
ingest.engine.mode | authoritative (was off) | the ingest tick, without which GitLab, the uploaded roster and the crosswalk never run — and GitHub and Jira moving off their legacy lanes, each takeover continuing from that lane's recorded progress with its rows kept (corrected 7 September 2026; this row used to say the first run truncates and re-backfills, which it does not) |
Do not reach for shadow here. It is a measurement mode for a source that
has a legacy lane — GitHub and Jira — and it leaves the lane serving your
users untouched while the engine walks beside it. It is the wrong setting for
this release's three new sources, because they have no legacy lane to shadow:
in shadow they walk into <table>__shadow, which nothing reads, so they
connect, enable, ingest and answer nothing. If you want GitHub or Jira held
back while the new sources run, hold them back by name with
ingest.github.engineMode / ingest.jira.engineMode and leave the install-wide
mode where it ships — Flip a source to the ingest engine is
the procedure, and Take a source live covers what a second walk
against those upstreams costs.
If you have entered a credential on the admin page, note the new row in
Rotate a credential: rotating encryption-key now
means re-entering every stored source credential, and the scheduled ingest of
such a source refuses to fall back to an environment credential in the
meantime.
If you upgrade from 0.9.0 with ingest.engine.mode: shadow already set,
the self test's parity fragment reads 1/3 (or 0/3) after the first engine
run where it read ready before. Nothing is wrong; re-earn it before you flip.
Parameters carries every value with its description. The new pages in the docs are Connect your Snowflake directory, Upload a staff list, How Prism reads GitLab, Widen a source's scope, Your own name and logo and Release and CVE policy, all in the navigation in the order the work happens.
0.9.0 (25 August 2026)
The release that makes Prism answer questions about people, ingests GitHub issues, and closes the class of ingest bug that only ever shows up on a real customer's data: silent loss. Four of the fixes below lost rows without ever reporting a failure, and three of those needed nothing more exotic than a Jira account whose timezone is west of UTC.
If you read one section, make it Two windows that lost updates on any account
west of UTC and
storyPointsField now replaces discovery,
the one behaviour change that can alter numbers you have already seen.
Prism can answer per-person and per-team questions
There is now a user directory: one row per person, carrying what they are called in each system and which team they are on. Prism builds most of it for itself and tells you how much of it it managed.
- A person no longer has to have an Agent Router account to exist. The directory used to be keyed on Router users, which meant anyone who writes code but does not use the gateway was invisible to every question.
sources.directory: derivedbuilds it from the mirrors you already hold — Jira issues carry an assignee address, the GitHub lane knows every author's login — on a schedule (ingest.directory.schedule, six-hourly by default).- On GitHub Enterprise Server it can go further, deriving email ↔ login from
commit authors, but only for the email domains you name in
ingest.github.identityEmailDomains. That list is empty by default and harvests nothing until you set it, which is deliberate. /admin/directoryis served on your install (app.identityMapping.enabled, on by default): an admin corrects a login Prism guessed, or uploads a CSV of email address to GitHub login for the ones it could not.- The coverage is reported, not assumed. Prism says what share of the population a per-person answer can actually be crossed against, so a number computed over two thirds of your engineers is not presented as if it covered all of them.
One thing this release does not do, and will not: fill in which team somebody
is on. Nothing Prism reads knows it — not GitHub, not Jira, not your Router — so
that column is typed in Admin → User directory and by nothing else, and it is
not part of the CSV import in this release. There is a new section in
Limits about what Prism
requires of your organisation's data, and a matching prerequisite in
Install, because it is worth knowing before you install rather
than after.
GitHub issues are ingested, and there is a backlog to ask about
The GitHub lane now mirrors issues alongside pull requests, under the same
sources.github flip and the same credential — so bug-backlog questions work:
what is open, what was closed, what was closed as not planned rather than
completed. Bot-filed issues are excluded from backlog counts, and the answer
says how many the filter removed, so a suspiciously small backlog can be
explained rather than doubted.
Your GitHub credential needs Issues: Read for this. It is in the grant list in Connecting GitHub, and an existing token without it keeps working for pull requests.
Two windows that lost updates on any account west of UTC
Read this if your Jira source is live. Two lanes — Jira and Jira Service Management — sent their incremental window to Jira as a bare UTC wall-clock timestamp. Jira evaluates dates in JQL in the API user's own timezone — the zone on the ingest credential's Jira profile, which falls back to your site's default only when that profile has never set one. On an account west of UTC, the window Prism asked for therefore started in the future, and every issue updated in the gap was silently dropped, on every cycle, for as long as the source had been live. Nothing failed; the run reported success and the mirror quietly fell behind.
Both lanes now ask for the window as an age rather than as our clock, which
is correct in every timezone including yours. Do not assume either way — run
the check. Am I affected? is two commands: the
credential's own timeZone, and an A/B that asks Jira the same window as a date
and as an age and compares the totals. If it says you were affected, set
ingest.jira.rewalkFrom to a date before the source went live and let one cycle
re-walk it — that re-walk costs real API budget, which is why it is worth
measuring rather than guessing.
The ingest also checks once per run that your instance honours the age form.
ingest.jira.absoluteWindowBound is the escape hatch for an instance where that
check fails — and it is a hatch, not a setting: it sends a dated bound, which
reintroduces exactly the timezone risk above. It defaults off and should stay
there.
A resumed backfill no longer starts over
Three separate versions of the same defect, all found by watching a real backfill rather than a test:
- A resumed Jira backfill restarted wholesale. The lane kept no resume point, so an interrupted 180-day backfill began again at day one, every time. It now resumes by page.
- A resumed GitHub run re-backfilled the whole issue window. The run recorded no horizon for the issue edge, so each resume spent about an hour of API budget re-fetching what its predecessor had already swept, and no run ever survived long enough to finish. A resumed run now claims the edge its predecessor reached.
- The resume message overstated what was done. It claimed the buckets before the resume point were "already ingested" when the predecessor had completed exactly one. It now says how much ground was actually covered.
ingest.github.rewalkFrom and ingest.jira.rewalkFrom are the deliberate way
to make a lane go back over ground it has already covered — for the timezone
repair above, or after a day was capped and later recovered.
A half-fetched mirror now says so
At one install, GitHub's GraphQL half of the ingest enriched 909 of 1,161 pull requests and the run still reported success. The counts, authors, dates and merge states were all correct; the sizes and reviews for a fifth of the mirror were null, and nothing anywhere said so — so "average PR size" was an average over the four fifths that happened to enrich.
Now: the ingest counts what GraphQL never enriched and names the repositories the gaps are in, and the answer itself carries the caveat — a figure resting on rows that were only half-fetched says so where you read it, rather than in a log nobody opened.
Search no longer drops rows at a page boundary
GitHub's search API was paginated without asking for a stable sort order. With
no sort, the order between pages is not guaranteed, and an item sitting on a
boundary can be returned on neither page — so a walk could skip rows and finish
clean. Both lanes now ask for a stable order. If you have been comparing Prism's
PR counts against a GitHub search of your own and finding it short by a handful,
this was why.
You can see what is happening while it happens
Two visibility changes, both immediately obvious:
- During ingest, the sources page shows what is being pulled right now and how fast — rows so far, the day the walk has reached, the request rate — so a long first backfill looks like progress instead of a hang. It survives the flip from synthetic to live rather than blanking.
- During a turn, the chat says what it is doing in words, and marks each step done as it finishes. A turn also records where its own wall clock went, which is what makes the timing figures measurements rather than estimates.
On the same subject, Choosing a model now says the thing that was only ever said on calls: once the ingress timeout is ruled out, the model is the main lever you have over how long an answer takes — caching is a cost lever, not a latency one.
There is now a ceiling on your Router bill
Nothing bounded it before: /ask had no rate limit, so a script — or a
determined afternoon — could spend without limit. Now
app.limits.turnsPerHour (default 60) is a per-person hourly cap; over
it, /ask refuses with a 429 saying when to try again, and the refusal is
recorded. Zero or a non-number falls back to 60 rather than meaning
"unlimited", which is the safer reading of a typo. Questions themselves are
capped at 4,000 characters, and a refusal is written to the app's own event log
rather than only returned to the browser.
Be clear about what this is: a ceiling on how many turns a person can start, not on what a turn costs. There is no cap yet on how many tool calls or model turns a single question may take, so sixty of them is still not a spend limit — that half is upstream work in the agent runner and is not in this release. The numbers are in Limits.
storyPointsField now replaces discovery instead of joining it
This can change numbers you have already seen, and it is the one thing in this
release to check before upgrading. ingest.jira.storyPointsField used to be
prepended to the custom fields discovery had found — so if your instance
carries both a story-point field and something that looks like one (a rollup, or
an "Original story points" estimate), a field you did not name could still
supply the value.
Named ids now replace discovery entirely: the fields you list are the only ones that can supply story points, in the order you wrote them. Empty — the default, and the normal case — leaves discovery exactly as it was. If you had set this value, confirm the list is complete rather than a preference on top of discovery. See Story points.
github-live is hardened, and its credential is read-only
The live GitHub drill-down container ran as root, handed its whole environment to the child process it spawns, and was documented as needing a repo-write classic token. All three are fixed: it runs non-root, the child gets an explicit env allowlist plus the proxy and CA settings it actually needs, and the documented credential is a read-only fine-grained token whose grant list is the union its own tools require — no write scope anywhere. The grants are written out in Connecting GitHub.
If you issued a token from an earlier release's instructions, it is broader than it needs to be. Reissuing it read-only is a good hour's work to spend.
The self test says why, not just what
Three changes, all about the same failure mode — a check that reports a label where it holds evidence:
- A failed upstream check now carries the evidence behind it, so a
404reads as a404rather than only asorg_not_visible. - A verdict reports the status of the call that actually failed, not the last one that worked.
- The Jira pre-flight names the project key that was refused, instead of
reporting only "no issues visible", and the documented
curlnow asks the same question the probe asks — they had drifted apart. - The self test also reports the two settings that decide what a turn costs, and it can see the sources this release added.
A tenth image, off by default
semantic-mcp is new: a registry-driven query server, shipped as the ninth
first-party image and attached to the agent only when
mcps.semanticMcp.enabled is true. It is off by default and this release is
not asking you to turn it on — it is phase A of the source pipeline, and it
answers with parity against the existing stats tools rather than adding
anything. Mirror it anyway: step 2
now copies ten images, and an image missing from your registry becomes an
ImagePullBackOff on the day somebody flips the flag.
The same applies to sources.registry.enabled and ingest.engine.mode, both
new and both default-off. They are the manifest-driven ingest engine, which runs
in shadow mode beside the existing lanes and compares results before
anything is allowed to depend on it. Leave them alone unless a release note says
otherwise; this one does not.
Upgrading
Nothing here requires a re-ingest, and no value you already set changes meaning
— with the single exception of ingest.jira.storyPointsField above.
New values, all optional, all defaulted:
| Value | Default | Why you might set it |
|---|---|---|
sources.directory | synthetic | derived builds the user directory from the mirrors you hold |
ingest.directory.schedule | 45 */6 * * * | how often that rebuild runs |
ingest.github.identityEmailDomains | empty | domains the GHES commit-author harvest may record; empty harvests none |
app.identityMapping.enabled | true | serves /admin/directory |
app.limits.turnsPerHour | 60 | the per-person hourly ceiling on /ask |
ingest.github.rewalkFrom, ingest.jira.rewalkFrom | empty | re-walk ground a lane has already covered |
ingest.jira.absoluteWindowBound | false | escape hatch for an instance that refuses the age form — leave off |
mcps.semanticMcp.enabled | false | phase A of the source pipeline; leave off |
sources.registry.enabled, ingest.engine.* | off / off | the shadow ingest engine; leave off |
Parameters carries all of them with their descriptions.
0.8.3 (24 August 2026)
A patch for one thing: taking a GitHub source live against GitHub Enterprise Server. If your GitHub source is already live and healthy on 0.8.2, nothing here is urgent. If you are about to flip one, or you tried and it failed, take this release.
Everything else in 0.8.3 is identical to 0.8.2.
What was wrong
Three faults, found in one afternoon at one install, in the order an installer meets them.
The pre-flight said a healthy instance was not ready. prism-ingest check github proved the credential by calling /rate_limit. Rate limiting is
disabled by default on GitHub Enterprise Server, and an instance with it off
does not serve that endpoint at all — it answers 404. The probe reported
auth_unexpected_status, the self-test page showed upstream NOT ready, and
go-live.md told the installer not to flip. Nothing was wrong: the
same token, on the same host, at the same moment, answered /user, /orgs/<org>
and the PR search with 200. No part of the ingest calls /rate_limit.
The probe now asserts against /user, which exists on every instance and
carries the same scopes header. It also tells a bad token (401) apart from a
wrong URL (404), which /rate_limit could not.
A single unreadable pull request killed the whole ingest. GitHub answers a
per-object authorization failure as a partial response: 200, with the data
it could resolve, and an errors list naming only what it withheld — typically
one timeline entry cross-referencing a repository the token cannot see. The
ingest read the presence of errors as failure, retried the identical response
six times, and then exited. On a first live run that had already evicted the
synthetic rows, this left the source holding two days of pull requests.
Partial responses are now used for what they resolved, and the withheld nodes are logged so you can see whether widening the token's repository scope is worth it. Retries stay for the failures that retrying fixes — secondary rate limits, which come back with no data at all.
A GraphQL hop that is genuinely unavailable no longer ends the run. Some
Enterprise installs serve the REST API and block /graphql. That now degrades
one day bucket instead of the walk: pull request counts, authors, dates and
merge state are unaffected; sizes and reviews stay null for that day. After
three consecutive failed buckets the run stops calling GraphQL altogether and
the REST half proceeds at full speed, rather than spending two minutes of
backoff on every remaining day.
What to look at after upgrading
ingest_state.detail.unenriched_days lists every day whose sizes and reviews
are null, and graphql_abandoned says whether the hop was dropped for the rest
of the run. Both are empty on a healthy ingest. A mirror whose counts are right
and whose sizes are missing is worth having — but it should never be a mirror
nobody was told about.
If you hit this on 0.8.2
The failed run truncated pull_requests and pr_reviews before it walked, so
the source is left holding only the days it reached. Re-running on 0.8.3 fixes
it — the backfill starts again from the window in ingest.backfillDays
and no data is lost that the upstream still holds. To put the synthetic data
back in the meantime, set sources.github=synthetic and helm upgrade; the
seed runs on every upgrade for every source still in synthetic mode.
0.8.2 (19 August 2026)
Install this one. Do not install 0.8.0 or 0.8.1 — on both, a live GitHub source cannot authenticate. If you have already mirrored either, mirror 0.8.2 and use it instead; nothing needs undoing first, and no data is at risk.
What was wrong
0.8.0 added a parameter letting you cap how fast Prism calls your GitHub
(ingest.github.maxRequestsPerMinute). The ingest picks its credential out of
its environment, and the way it did that was to take the first variable whose
name began with GITHUB and was not obviously something else. The new
parameter's variable sorts alphabetically before the one holding the token, so
the ingest sent the rate limit as its credential and GitHub answered 401 Unauthorized to every request.
What that looks like on an install is worth stating, because it does not look like a bug in us:
- the GitHub source stays empty after you take it live, or stops advancing;
- the ingest Job fails with
401 Unauthorizedagainstapi.github.com(or your GitHub Enterprise host), which reads exactly like a revoked or mistyped token; - the self test stays green, because it does not call GitHub;
- and the token in your Secret is fine throughout. Rotating it changes nothing, which is the most expensive part of this defect.
Jira, Router spend and the identity directory are unaffected — the fault is in
the GitHub lane only, and only when that source is live.
What changed
The ingest no longer guesses. GITHUB_TOKEN is read explicitly, and a
differently-named variable is only considered if its name actually says
credential — so no setting added later can be mistaken for one. The pre-flight
probe now calls the same code as the ingest instead of keeping its own copy,
which is why both were wrong at once.
Everything else in 0.8.0 still applies
The rate parameter itself works, and it is the same parameter with the same default. Read the 0.8.0 notes below for it, for the seventeen seconds off every answer, for the Router-defaults change that can refuse your upgrade, and for the prompt-caching behaviour — all of that is in this release too.
0.8.1 (19 August 2026)
Superseded — do not install this release. It carries the GitHub authentication defect described under 0.8.2 above, as does 0.8.0. What follows is kept as the record of what 0.8.1 was for, not as advice to install it.
It also said, when published, that a reader who had already mirrored 0.8.0 did not need to act. That was true of what it was written about and is not true now: both releases need replacing with 0.8.2.
There was no product change in this release. It was 0.8.0 re-cut so that its security scan was one we could stand behind. Everything in the 0.8.0 notes below applied unchanged, and nothing was added.
What happened
Our release script scans every image before pushing it, and refuses to publish a
fixable CRITICAL or HIGH. During the 0.8.0 cut the scanner failed to open one
image — the mirrored pgvector, which is the bundled Postgres — and the script
read "the scan produced no findings" from a scan that had not run. pgvector
was published without us having looked at it.
We then looked. It carries seven HIGH advisories, all denial-of-service issues
in the Go standard library embedded in one binary (gosu), which the image
uses to drop privileges at container start. The Debian layer scans clean. These
sit alongside six of the same kind we had already reviewed and accepted, and
they are accepted on the same reasoning: the fix exists only in a newer Go, no
upstream pgvector image has shipped a rebuilt gosu — we checked the current
pg16, pg16-bookworm and pg16-trixie tags before deciding, and the newest
carries the same seven while trixie carries nine — and the parsers concerned
are never reached by input gosu handles.
That decision is now recorded, with an owner and a review date, in the release's own acceptance file rather than implied by a silence.
What we changed
The gate can no longer confuse a scan that found nothing with a scan that could not run: a scanner error now stops the release and names the report, and a report that is unreadable stops it too. Mirrored images are scanned at their source registry, which is the copy you mirror, and which is what the scanner could not read locally.
pgvector is only ever run when postgres.bundled=true. If you bring your own
Postgres — as the install guide recommends for anything beyond a trial — the
image is mirrored but never started.
0.8.0 (19 August 2026)
Superseded — do not install this release. It carries the GitHub authentication defect described under 0.8.2 above, and one of its nine images was published without a completed vulnerability scan. Everything described below is in 0.8.2, which is the release to use.
Every answer arrives about seventeen seconds sooner
Turns had got slow enough that people stopped demonstrating Prism live. The measurement found something better than a slow model: a dead wait of 16.7 seconds at the end of every single turn, after the answer was complete and before Prism was told it was.
The agent runtime holds the stream open after an answer finishes, so that an automatically generated conversation title can be flushed before the stream closes. It breaks out the moment the title arrives — which makes the wait nearly free for the client it was written for. Prism names its own conversations, so no title was ever generated, the early exit could never fire, and every turn paid the full window. It was 29.8% of a median turn, on 100% of turns, in every release from 0.4.0 to 0.7.0.
It is gone. Nothing else about how a question is answered has changed, so what you should see is the same answer, about seventeen seconds earlier, on every question you ask.
Two things that measurement is worth saying out loud, because they contradict the usual suspicions:
- Your MCP servers are not the problem. Actually executing the tools was 0.9% of a median turn. Where the rest goes is the model deciding what to do next between tool calls (44.5%) and writing the answer out (19.0%).
- The answers people described as "two minutes" were the tail, not the middle. The median August turn was 59 seconds; the slowest tenth ran 129 to 145. Removing a fixed 16.7 seconds helps both, and shortens neither a long question nor a long answer by itself.
The chart no longer ships a Router of ours for you to install against
This release will refuse an upgrade that relied on the chart's Router defaults. That is the change, and it is deliberate.
router.platformUrl and router.proxyUrl used to ship pointing at Tetrate's
own Agent Router, with a comment above them saying to replace them. If you did
not, nothing said so: the render succeeded, every pod came up healthy, and the
install sat quietly pointed at an address on our network that yours has no
route to. The only symptom was an amber management api unreachable on the
self test, and nothing ever forced it, because minting per-user keys is the
only thing that calls that address.
Both now ship empty, and helm upgrade stops with a message naming the value:
router.platformUrl is empty — the agent runner will not start. It reads this
as TARE_PLATFORM_URL and throws `TARE_PLATFORM_URL is not set` on boot...
If your values file already sets both — as it should, and as every customer overlay we ship already does — nothing changes for you. If it does not, the upgrade stops rather than completing, and the message tells you what to put where. An install that was relying on those defaults was not working; it was failing quietly.
mcps.management.valetServiceUrl, the Agent Router management plane that
Router spend and the user directory read from, shipped the same way and is now
empty too. It follows a different rule, because a synthetic pilot never calls
it: it is required only once sources.spend is indexed or live, or
sources.directory is live. An all-synthetic install needs nothing. If you
are taking spend live, this is a new value in that step — see
Going live, where the flip command now carries it.
Your values file is yours, and lives outside the chart
The install and upgrade pages now say plainly what was only implied: keep your
values in your own file, in your own directory, and pass it with -f on every
command. Do not edit the chart's own values.yaml — the next release is a
new tarball, that file in it is ours again, and every value you set there
reverts on upgrade with nothing reported.
This is not a style preference. It cost the first customer a release: a setting they had been given was written into a file they did not install with, and every question failed until it was applied by hand a second time.
You choose how fast Prism calls your GitHub and Jira
Prism's pacing against your data sources used to be fixed in the image and tuned to github.com's published rate limits. That is the wrong number for a self-hosted GitHub Enterprise Server or Jira Data Center — and the number nobody can tell you is the one your own instance enforces.
Two new parameters, in the unit your platform team thinks in:
| Parameter | Default |
|---|---|
ingest.github.maxRequestsPerMinute | 30 |
ingest.jira.maxRequestsPerMinute | 30 |
Thirty a minute is one request every two seconds — deliberately well under what
a healthy instance allows, because the default has to suit an instance whose
limits have never been checked. 0 removes the cap.
- It is a ceiling, not a target. Each ingest is serial, so the rate cannot be exceeded by concurrency, and it applies to retries as well as first attempts: an ingest being rate-limited by your instance cannot respond by calling it faster.
- For GitHub it covers the whole run — both the search walk and the GraphQL enrichment pass, which share a rate limit on your side.
- The only cost of lowering it is time. A run takes proportionally longer, nothing is skipped, and nothing needs re-ingesting when you change it.
Each ingest now prints the rate it is running at as the first line of its log, so the question "what is calling us, and how hard?" has an answer somebody can read without a code inspection. Limits has the detail, and Take a source live says where in the go-live sequence to decide it.
A Router that rejects the caching hint no longer takes the whole install down
0.7.0's release notes carried a warning: some Bedrock-fronting gateways
mistranslate Prism's prompt-caching hint, Bedrock rejects every turn with a
400, and chat returns no answer while every other line on the self test stays
green. The remedy was to set agentRunner.promptCaching: false and redeploy.
The agent runtime now handles it. When a request comes back with the 400 that says in so many words that the cache point is what it choked on, that request is re-issued once without the hint, and the install runs uncached from then on. It costs one wasted round trip on the first turn after a deploy, instead of an outage lasting until somebody diagnoses it. A restart tries the hint again, which is the right cadence — a gateway fix arrives with a deploy.
It is a price, not a fix. An install in this state pays full input price on
every turn. agentRunner.promptCaching keeps its meaning and its precedence: set
to false, nothing is ever decorated and there is nothing to fall back from.
The self test now names this specific failure too, rather than relaying the gateway's words. Where it used to say no answer from the agent beside thirteen green lines, it now identifies the prompt-caching rejection, names the value to set, and links the troubleshooting entry.
And there is a second setting behind it, new in this release. Prompt caching
works on opposite Router paths depending on what your gateway fronts —
measured, on two Routers, same key, same model: a Bedrock-backed Router caches
on the OpenAI-compatible endpoint and not on the native Anthropic one, and a
Router fronting the Anthropic API directly does the exact reverse. It fails
silently either way: no error, no failing check, just a larger bill.
agentRunner.anthropicApi chooses, and stays on completions, which is right
for Bedrock, AWS Anthropic and GCP Anthropic — nearly every install. Set it to
messages only if your Router fronts the Anthropic API directly.
A table of people stops at twenty, and says how many it left out
Ask for a per-person breakdown without naming who, and Prism used to answer for as many people as it happened to find — the same question produced 16 rows one day and 50 another, and the long ones took minutes, because writing the answer is the largest variable cost in a turn.
An unscoped per-person table now stops at twenty rows and states, above the table, how many people it covers and how many it leaves out. Naming your own cohort overrides it: ask about a team, a list of people, or "everyone who shipped last month" and you get all of them. What you will not get is a silent cutoff — a table that stops without saying it stopped is the defect this rule exists to prevent.
Also in this release
- A Router key name in a table can no longer be read as a repository. A
per-person row ended with a bare
agent-runner, which is an Agent Router API key label — and also a component of this product. At least one engineer read his own row and concluded Prism had misattributed his work. Every figure in the row was right. The name now arrives already qualified, askey: agent-runner, so it cannot be quoted into the mistake. - Fewer tokens on every question. One tool's description was 36,249 characters — 48% of every prompt Prism sends, whether or not that tool was called — and 70% of it explained fields that only exist once there is a response. Those rules now travel with the response, and only when they apply. A default install sends about 3,900 fewer tokens per turn.
- "Sync router users" no longer fails with
Internal Server Erroron a large Router. The management call it makes was measured at 41.5 seconds against a 30-second budget on a Router with a year of history; the timeout escaped as a plain 500 with nothing in it to act on. The budget is now 90 seconds for every install, and a Router that still does not answer gets a 504 that names the Router, the budget, the window, and the fact that nothing was written. - Answers no longer arrive with blank lines in front of them. Invisible in a browser, which is why it went unreported for so long: markdown collapses a run of blank lines. It was not invisible to anything else reading the stream — every character offset measured over an answer was shifted by it. Both halves are fixed: the agent runtime no longer writes a paragraph separator for a message that says nothing, and Prism strips any that arrive before storing.
- Security fixes. Seven fixable HIGH advisories in the Go standard library used by the live GitHub drill-down image, and the agent runtime's own dependency advisories, all taking the upstream fix. Nothing in Prism's behaviour changes.
0.7.0 (13 August 2026)
Your own identity provider in front of Prism, written down
Putting an IdP — Entra ID, Okta, Keycloak — in front of Prism has always
worked, and has never been documented. The mode that does it is easyauth,
which is named after the Azure Container Apps feature it was first used with
and reads, wrongly, as Azure-only. It is not: it means something in front of
me asserts the user in X-MS-CLIENT-PRINCIPAL-* headers, and any OIDC proxy
can be configured to do that.
Sign in with Entra ID is the walkthrough — the app registration, a proxy configuration verified end to end rather than reconstructed, and the two mappings in it that are counter-intuitive enough that guessing produces a working-looking install with the user's id and email swapped.
Read the Close the bypass section even if you skim the rest. Under this mode Prism believes those headers from anything that can reach it, which on Azure Container Apps the platform guarantees and on your cluster you do. The chart ships no NetworkPolicy, so that guarantee is yours to add, and a request straight to the Service without one is full super admin from two headers and no credential.
One value now fails the render instead of installing. An unrecognised
app.identity.mode — entra being the one people reach for — used to fall
through to static: a clean install, healthy pods, every visitor sharing one
operator identity, and /admin and /selftest open to all of them. It now
stops helm upgrade with a message naming the four real modes. If your values
file carries a mode outside static, easyauth, iap and local, this
release will refuse it, and refusing it is the point.
Every answer has a link, and you can find it again
Until now an answer survived exactly as long as its tab. If somebody acted on one and was asked about it a month later, there was no copy to produce unless they had exported a PDF at the time — which is the moment nobody thinks to.
Your answers in the header opens your own history, newest first, with the question, when you asked it, and any marks worth seeing at a glance: an answer that looked nothing up, one that was cut off, one that failed, one whose record was never written. Opening an entry shows the answer as it was given — tables, charts, and the provenance footer already unfolded — at an address that keeps working after the tab, the browser and the next upgrade. Copy link is on every answer in the chat too, so you can take the link at the moment you decide the answer matters.
An archived answer is replayed, not re-asked. It is what Prism said at the time, not a fresh answer to the same question, and it is not re-checked against today's data. The page says so, because in the conversation this feature exists for — "Prism told me x, and I decided y" — the difference between those two things is the whole conversation.
Nothing new is collected. Every turn was already written to your database; what changed is that it can be read back. Who may read what does matter, and it is worth a minute before somebody asks: a reader sees their own answers and no others, a link to somebody else's reads as "no such answer" rather than as a refusal, and a super admin sees every answer on the installation. There is no retention control in this release, so the history goes back to the day you installed Prism. Finding an answer again in the docs sets all of that out, and Limits says what it means for the disk.
A tab left open across an upgrade now says so
The Prism page runs in your browser, and some of what you see is decided there rather than on the server — whether a chart may be drawn, for one. A tab left open across an upgrade kept applying the rules it was loaded with, so it could leave out a chart the new version draws, and the copy you exported could differ from the copy Prism stored. Nothing on screen said the page was out of date; the only fix was reloading for your own reasons.
Prism now tells you. Each answer states which version of Prism produced it, the page compares that with its own, and on a difference you get a short amber note above the answer — this tab is out of date, reload to get the current version — with a Reload button, and a strip below the header that stays for as long as the tab is out of date. It only appears when the page itself changed: upgrading, not restarting or changing a setting, which is what stops it becoming noise.
Nothing is blocked. An out-of-date tab can still ask questions and still shows its answers. A demo that died on an upgrade would be worse than one that says it is behind, and you now hold both the answer and the fact.
Where a database is configured, Prism also writes one row per turn on which the page and the server disagreed — the two version ids, and the heading of a chart the page withheld. Rarely written, never read back as the record of an answer, and it is what makes "the answer we stored is not the answer they read" a question somebody can look up instead of reconstruct.
One tab is one conversation
Two browser tabs of Prism used to be the same conversation, with each tab showing only its own half of it. Nothing on screen said so, and the symptom was a follow-up question quietly resolving against a question asked somewhere else: ask about review latency in one window, type "now break that down by team" in another, and "that" meant the first window's question. The answer was correct — for a conversation you could not see.
Each tab now has its own conversation. A refresh stays in it, and duplicating a tab (Chrome, Firefox and Safari all copy what this uses) continues the thread in the new window; a brand-new tab starts a blank one, which is the point. Nothing is shared between tabs any more, including the "looked this up earlier in the conversation" note in an answer's provenance — that used to count both tabs' lookups.
Once, when you upgrade to this version, every conversation open at that moment starts fresh: what a conversation is has changed, so there is no honest way to carry the old ones over. Everyone in the middle of one gets the "this is a new conversation" note described below rather than silence. Nothing is lost, and it happens once.
Programmatic callers are unaffected: a caller that does not name a conversation still gets one shared conversation per credential, exactly as before.
Restarting Prism no longer ends the conversation you are in
Prism used to keep its note of which chat session a conversation belongs to in memory only. So restarting the app — an upgrade, a rolled pod, a node moving — started everyone a new conversation, silently: the questions and answers stayed on screen, and Prism was reading none of them. What that looked like was a follow-up question ("give me a scatter plot of those five people") answered with "this looks like the start of our conversation", above five turns that were plainly still there.
Nothing was ever lost — the transcripts have always been kept — but the pointer to them was not, and now it is. Where a database is configured, a conversation survives a restart and a follow-up question still resolves against what you asked before it.
And where it cannot, it says so. If Prism does start a new conversation under you, a short amber note appears above the answer it affects: this is a new conversation, the questions above are not part of what Prism can see now, repeat any detail it needs. The earlier answers stay on screen and stay in the record, which the note also says. There are two cases it covers: an installation with no datapond configured, where the pointer has nowhere to live, and a conversation the agent runtime no longer holds — that second one names the cause, because it is the only one Prism can be sure of.
The admin link says "admin", and leads to a list of admin pages
The header link used to read directory admin, and /admin was a single
page with four unrelated things stacked on it. The label named the second of
the four — a user directory that is Tetrate's own operational surface and is
not served on your installation at all — while the panel most installs actually
need, who may sign in and who holds super admin, was at the bottom of a page
whose link said "directory".
/admin is now a short list of the admin pages your installation has, each
on its own page: Data sources, Answer feedback, and App users. An
entry that does not apply is not listed and its page is not served — there are
no greyed-out rows inviting you to ask what a setting you cannot use is for. On
an installation with none of them, the page says so in one line.
Nothing moved that you have to relearn: each page is the panel you already knew,
with its own address. /admin/feedback is unchanged, and the feedback count
still appears on /admin — the only notification this product has that a reader
has reported an answer.
App users is now shown under every identity mode rather than only with email sign-in, and says which one you are in. Under email (magic-link) sign-in it is the allowlist: a row here is what lets somebody sign in. Behind an identity provider — IAP or Easy Auth — that provider decides who signs in, so the page is about roles: who holds super admin, promoted and demoted here.
A source mode outside its vocabulary fails the install
Another value now fails the render instead of installing. The source modes
are matched exactly, in lower case: sources.github, sources.jira and
sources.directory take synthetic or live, sources.spend takes
synthetic, indexed or live, and indexed means nothing on the other
three. Anything outside that vocabulary used to install, and Live with a
capital L was the worst of it because nothing anywhere objected: the chart
compares the mode case-sensitively and rendered no ingest at all for that
source — no backfill, no schedule, not even the pre-flight check — while the
app lowercases the same string, read it as live, and served the mirror those
workloads were meant to fill. A clean render, healthy pods, and a source empty
on the day you installed and empty after it. The only thing that ever said so
was the self test's red live but 0 rows line, which arrives after the
install, on a page somebody has to go and look at, and describes a data problem
when the cause was one capital letter. helm upgrade now stops on it, naming
the key and the value as you typed it.
An empty sources.* value is refused too. This is the one change here that
takes a values file which renders today and stops it rendering, and it is
deliberate: empty is not a mode, and nothing downstream ever agreed on what to
do with it. It was not silently accepted before either — the synthetic seed job
refused it — but that refusal came out of a hook container's log after
everything had been applied, rather than from the command that applied it. If
any of the four keys is blank in your values file, this release will refuse it
until you say which mode you meant, and refusing it is the point.
If your Agent Router is backed by AWS Bedrock, read this before upgrading
Nothing in this release changes it, and it is here because it is the one setting that can make a healthy-looking install answer nothing at all.
Prism asks the model to cache the stable part of each turn — a large, silent
discount on every question. Some Bedrock-fronting gateways mistranslate that
hint, folding a tool's cache point into the same toolConfig.tools[] element,
and Bedrock then rejects every turn with a 400. What you see is chat that
returns no answer while every other line on the self test stays
green, because each of those checks a different hop.
If that is your gateway, set agentRunner.promptCaching: false in your
values file — not as a --set on one upgrade, which the next upgrade silently
drops, restoring the default and breaking every question again.
It is a price, so do not leave it off by default. The gateway-side fix is
envoyproxy/ai-gateway#2476;
once your gateway carries it, set the value back to true and ask a question
that produces a table. Prism's own request shape is correct — it was verified
against a patched Bedrock gateway before this release shipped — so if a turn
fails after you turn caching on, the gateway in front of Prism is what to look
at, not Prism.
Gateways fronting the Anthropic API directly are unaffected and should leave caching on.
0.6.0 (6 August 2026)
The first screen is a dashboard beside the chat
Opening Prism used to mean meeting a blank composer and a screenful of prose about what it could do. It now opens as a split view: the question box on the left, and a rail of live panels on the right.
The first panel ranks pull-request authors over the last 30 days. Where Router
spend is indexed (sources.spend=indexed), a second panel ranks AI spend per
person. Each panel carries its own freshness caption — how current the data
behind it is — and clicking a bar, or "Ask about this", prefills the composer
with the matching question. It is prefilled, never sent, and nothing from
the panel is smuggled into the question: the agent re-derives every figure the
way it would if you had typed the question yourself.
Panels degrade one at a time. If the data behind one is unavailable the panel says why, in place, and the other still draws. On a deployment with no Router management-plane link the spend panel is absent rather than empty, because absent is the truth.
There is nothing to configure. Both panels use endpoints your deployment already has.
Readers can tell you an answer is wrong
Prism joins several incomplete sources and infers across them, so the person best placed to notice that an answer is wrong is usually the person reading it, holding something Prism does not have. Until now they had nowhere to say so.
Every answer now carries a thumbs up and a thumbs down. Thumbs up is one click. Thumbs down opens a short box — what's wrong with this? — and takes up to three attachments of 5 MB: a screenshot of the thing that contradicts the answer, a PDF, an extract from a system of record.
The thumbs are on every answer, including one that failed or was cut off part way. Those are the ones most worth hearing about.
The report carries the answer with it. Nobody is asked to copy anything back: each report is stored with the question, the answer, and that answer's provenance record — which tools ran, against which sources, how fresh each was, what window the figures covered. If the reader's browser saw the answer cut off in delivery, which the server cannot see, the report carries that too.
Nothing leaves your deployment. Reports are written to your own database and read on an admin page inside your own Prism, at Admin → Answer feedback. There is no outbound connection and no Tetrate endpoint involved. If you want us to see a report, you export it and send it — deliberately, as a file, after you have read it.
On that page the reader's comment, the question and the answer are editable before you export, and what you download is what those boxes contain. The stored report is unchanged, so removing a name or a figure prepares a copy to send without destroying the original. An internal note stays on the page and is in no export.
There is no notification of any kind, because nothing in Prism reaches outside your network — the count on the admin page is how you find out reports have arrived. During an evaluation it is worth a look.
Two things to know before you turn people loose on it:
- It needs the database. On an install with no datapond configured the thumbs report that feedback cannot be recorded rather than failing quietly.
- Nothing prunes reports, as with everything else Prism stores. A report is about five kilobytes; attachments are the part with a size worth knowing, and Limits has the arithmetic. Reading and forwarding is described in Feedback on an answer.
It requires no configuration and no new values. Upgrading creates two tables and changes nothing else.
Cost per merged pull request is answered again
Earlier releases refused any answer pairing a cost with a unit of delivered work — the cost-per-PR ratio, in tables and in charts alike. That guard was drawn too wide. It was written to refuse a ranked shortlist for an employment decision, could not tell that apart from the return-on-investment question this product exists to answer, and so refused both.
The ratio is now served, at every grain including a single named person. What replaces the refusal is a requirement rather than silence: an answer carrying the ratio has to lead with what it is not — an uncontrolled comparison, with an unequal denominator, attributed by API key, and confounded by everything else that happened in the window — and it may not claim cause. Where a reader has said they are asking for a redundancy or performance-management purpose, the same answer has to say that this data cannot rank individuals for that decision.
One thing did not change: a judgement word is never a label over a column of numbers. "Productivity", "efficiency" and "performance" over a count are still refused in a chart and still flagged in a table, whatever the question was.
This is a directive, not a mechanical control, and it is worth knowing which is which. The refusal it replaced was enforced in code. This is enforced by the model following its instructions, which we assert are present and cannot assert are obeyed.
Stacked bar charts
Asking for a stacked bar chart produced one bar per category per bucket, side by side, with the total nowhere on the chart. Stacking now works: one bucket is one bar, its height is the total, its segments are the categories. Asking for a 100% stacked chart — or "normalised", or "share of" — rescales each bar to its own total, so every bar is full height and only the mix moves. Tooltips give both the share and the count it came from, because a percentage of an unstated total is not something a reader can act on.
A stack makes one thing newly dangerous: the height of the bar is the number being read, so a segment that silently goes missing shortens it. Prism now says so under the chart when it happens — a series that could not be coerced to numbers, a share containing a negative, more segments than the palette can distinguish.
Waiting, and how long an answer took
The static … between a question and the first words of its answer was
indistinguishable from a hung page — and the longest silences are model time,
where no tool activity arrives to fill them. There is now a pulsing indicator
with a ticking elapsed count, which claims only what the page can actually
observe: the question is out, and how long ago. It survives the tool lines
instead of being wiped by the first one.
When the turn ends, a muted line under the answer says "Answered in 34s" — or "Stopped after 34s" where the answer was cut short, so the timing line never vouches for a turn the caveat above it does not. On a connection that dies mid-answer the elapsed figure is the useful half: the same number recurring is the sign of an idle timeout in front of Prism, which is exactly what that caveat asks you to look for.
Caveats are shorter, with the reasoning one click away
Prism's warnings had grown to between 50 and 130 words each, and up to four could be on screen at once. Every clause in them was there because of a real defect — but the sentence that changes what a reader does next was rarely the first one, so the message was skimmed.
Each caveat is now a heading, one or two sentences, and a "Why this happened" toggle inside the band holding the rest. Nothing was deleted: the detail moved behind the toggle, and the toggle is inside the caveat rather than in the footer, so the warning itself is never behind a click — only its explanation. An exported PDF opens every toggle, because a PDF cannot be clicked and the circulated copy has to carry every word.
Two changes to when Prism warns you about an answer
A red warning no longer fires on a turn that made no lookups for a good reason. A reader who asked for a log axis on a chart Prism had already drawn was told, in red, that the figures had nothing behind them and no data source had been contacted — directly above Prism's own list of the ten earlier answers in that conversation that had fetched plenty. A drawing limitation was reported as a data failure, which is the expensive kind of wrong: it sends a reader, and then a support case, after data that was never the problem.
The red band now fires only where Prism can prove the claim — nothing looked up on this turn, and nothing looked up anywhere in a conversation it watched from the start. Where earlier turns did fetch, the warning is amber and says only what is certain: no fresh lookup was made for this answer. Where Prism has no record of the conversation it says that instead of asserting either. An answer containing no figures at all is not banded, because there is no number that could be unverified.
And a new check compares figures against the data they came from. Where a tool result published a value for a particular row and field, and the answer prints a different value in the same row and column, the answer says so. This is deliberately narrow: a total, a share or a median the agent worked out itself is not judged, because absence from a payload is the normal case and flagging it would warn on most ordinary answers. Replayed over 267 real turns it flagged three, and all three were genuine — including one table stating a person had merged 8 pull requests when its own tool result had said 7.
Where the check could not run — a result too long to keep a copy of, or nothing published to compare against — the footer says that, rather than reporting agreement it did not establish.
The self test stops failing a check some installs never use
On a healthy bring-your-own-key deployment the self test reported
management api FAIL unreachable from this network
on the one page whose entire job is to be trusted. Exactly one thing in the product calls that endpoint — the runner, minting a per-user inference key on first use — and a deployment that supplies its own inference key never takes that path. The check could not have been anything but noise there.
The chart now tells the application which kind of install it is, and the check
has three verdicts instead of one: skipped where the runner brings its own
key, unchanged where keys are minted (an unreachable management API there
is a real fault, and every new user's first question fails on it), and a
warning rather than a failure where the mode cannot be determined. Setting
router.keyMode explicitly gets you a definite verdict.
It also stops reporting the install documentation's own example URL as a network problem, which sent at least one reader to their network team to debug a value nobody had filled in.
You can prove a data source works before the flip that could empty it
Taking a source live has always had one sharp edge. The first live ingest truncates that source's synthetic rows before writing real ones, so a credential that authenticates cleanly and sees nothing — a token scoped slightly too narrowly, an org it cannot search — replaced a working demonstration with an empty source. From the chat, an empty source and a broken product look identical.
Prism has always shipped the check that catches this: prism-ingest check
asserts on substance rather than on a 200, asking whether the org's pull
requests are actually findable rather than whether GitHub said hello. But the
chart only created it for sources already live, so the first verdict arrived in
the same upgrade that did the truncating — one flip too late to be useful.
It now runs beforehand. Configure a source's target and credential and
helm upgrade without touching sources.<name>, and the self test carries
a verdict for an upstream you are only preparing:
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
So the go-live sequence is now two upgrades rather than one: configure and read the verdict, then flip. Amber is amber rather than red on purpose — nothing is broken, the source is serving exactly the synthetic data it should on day one, and the finding is about a step you have not taken yet.
Nothing changes for a source already live, and nothing new runs on a schedule:
a source being prepared is re-probed by each upgrade, not every half hour, so
this costs your upstream nothing between upgrades. Router spend's signal is
secrets.valetApiKey rather than a target, because its URL has a working
default; on an existingSecret install the chart cannot see that key, so spend
gets no pre-flight there while GitHub and Jira are unaffected.
The procedure for it is Take a source live, which walks the whole flip end to end.
These docs are organised by what you are doing
The pages used to sit in one long list called "Install and operate", which is two jobs and a third — troubleshooting — filed under neither. They are now in four sections: Install, Operate, Troubleshoot and Reference.
Operate is the new part, and most of it is new writing: the tasks with a right order and a check at the end, one page each.
| Prove the egress before you install | The four destinations, from a pod in the target namespace, and how to tell a closed route from a wrong credential |
| Which Agent Router credential buys what | Three credentials, not interchangeable, and only one of the first two may be set |
| Take a source live | The two-upgrade flip, from credential to backfill |
| Confirm story points are being read | The field ids discover themselves; this is how to check that they did |
| Turn on email sign-in | Local identity end to end, including the recovery path for locking yourself out |
| Rotate a credential | Which Secret key, and what has to restart before the new value is actually in use |
Nothing has been deleted, and every page that existed before keeps its address. The one thing that moved is the deep links into Procedures: what were anchors on a single long page are now pages of their own. Since Procedures itself is new in this release, no link anyone has ever been given is affected. The overview is the map.
A missing credential is reported instead of failing the upgrade
If you build your own Secret (existingSecret) and one of the three source
credential keys was not in it, helm upgrade used to hang for five minutes and
then report UPGRADE FAILED — rolling the release back under --atomic.
Nothing was wrong with the install. The container simply could not be created
without a Secret key that was missing, so the upstream check Job never started,
and because Helm waits on that Job the whole release waited with it.
The three source credentials — github-token, jira-token, valet-api-key —
are now optional to the containers that read them, so an absent one is
diagnosed rather than fatal: the upgrade succeeds and the self test carries
an amber line naming what is missing. That is what the check was always meant to
do; it just could not run.
The verdicts also name the Secret key they looked for, which is the case worth
having: a key spelled github_token instead of github-token produced a
verdict indistinguishable from no credential at all, in front of an operator
looking at a Secret that visibly contained a token.
The four credentials nothing can run without — app-jwt-secret,
encryption-key, postgres-password, runner-database-url — are unchanged and
still required. A component that cannot work without one refuses to start, which
is the honest behaviour there.
Also in this release
-
The save star stops duplicating a prompt. Clicking it five times produced five identical saved prompts: the button looked the same before and after the click, and the only sign it had worked was a new row in a list that is usually below the fold, so clicking again was the reasonable thing to do. It now shows the state of the question rather than of the click — filled amber when this question is already saved, whether you typed it, just asked it, or saved it last week — and a second click unsaves rather than saving again. That is the toggle a favourite button already is everywhere else. Upgrading needs nothing, and existing saved prompts are untouched.
-
How far back your Router will answer for spend is now a measured number, not a shrug. Limits used to say spend history was "whatever your Router retains" and leave it there. It now carries a command you can run against your own Router to find its earliest usable date, and what the answer means: a boundary that moves week to week is retention trimming your history, one that stays put is simply the day usage started. Measured against our own Router, the earliest request it will answer for is 232 days and 1.5 million requests back, with nothing purged behind it.
-
The data-freshness table had Router spend wrong. It said spend was read live and never ingested, which stopped being true when the spend index shipped in 0.5.0 — on an indexed install the nightly sweep keeps its own copy and nothing prunes it. Both rows say so now.
-
An empty Router URL is refused at install rather than discovered later.
router.platformUrlandrouter.proxyUrlare both required, and both used to install happily when left blank — with the consequence arriving somewhere else entirely. Blankrouter.platformUrlcrash-looped the agent runner onTARE_PLATFORM_URL is not set, a variable name that appears in no values file, and it is required even underrouter.keyMode: byok, where no management key is needed. Blankrouter.proxyUrlwas the worse of the two: it does not fall back torouter.platformUrl, so the install came up healthy and then failed every question against the address/v1, while the self test called the inference endpoint not configured rather than broken. Both now failhelm install/helm upgradeat render, naming the value and where to get it. -
The one failure an upgrade cannot fix now has a procedure and a script. An install created by an early version of Prism, and upgraded since, can find every chat turn failing with "it is owned by another identity": the shared agent belongs to somebody the app can no longer act as, and its name is unique, so the app can neither use it nor replace it. The message used to name the fault and stop there. Re-own the shared agent is the repair — a script that ships in the chart tarball, reports before it writes, and touches only the agent's owner. New installs are unaffected, and there is nothing to do if chat works.
-
An empty
app.projectIdno longer becomesdefault. It never stayed empty: the chart substituteddefaultfor the app'sPROJECT_ID, and the runner'sallowedProjectIdswas rendered the same way, so the two agreed — on a project nobody had chosen. On a Router where each project has its own gateway,defaultis the one project with no data plane behind it, so the install rendered clean, every pod came up healthy, and every question came back403. A403reads as a credential fault, which sends you to the key, the tenant and the egress policy while the wrong value sits on a line that renders green. Empty is now refused at render, naming the value and the symptom.defaultitself is unchanged and still the chart default — an install whose Router serves a data plane on thedefaultproject needs to do nothing, and the app/runner coupling that keeps the two from drifting into a403is untouched. -
Security fixes. Nine published CVEs across the images and the build, every one taking the upstream fix rather than a documented exception:
cryptographyin the four MCP images,fast-uri,undici,brace-expansionandip-addressin the agent runner and the docs build. Nothing in Prism's own behaviour changes.
0.5.0 (3 August 2026)
These docs are now inside Prism
Prism serves its own documentation at /docs, linked from the header. It is
this set of pages, built into the application image, so what you read always
matches the version you are running — no checking which release a website is
describing.
It sits behind the same sign-in as the rest of Prism: whoever can use the chat can read the docs, and nobody else. Nothing is fetched from the internet, so it works exactly the same on a disconnected network, search included.
The copies you already had are unchanged: the Markdown files in your chart tarball, and the Tetrate docs site for anyone evaluating Prism without a deployment. Environment-specific pages supplied with your release stay in the tarball only — they are not in the application image and not on the website.
One thing was removed alongside this. Earlier releases served an interactive API
explorer at /docs, and its schema at /openapi.json, without asking anyone
to sign in. Both are gone. Nothing in the product used them.
Router spend can be indexed instead of asked live
sources.spend takes a third mode, indexed, and on a real Router it is
the one to want. Both indexed and live read the same Agent Router and
produce the same figures; the difference is where the work happens.
- Why it exists. On the
livepath, attributing cost to named people costs one Router call per person, inside the chat turn — the Router's aggregated usage rows carry no owner, so there is no bulk call that can be attributed, and the fan-out is what attribution costs against that API. Measured on our own demo: a "merged PRs and AI cost per engineer" table over 60 days made 15 such calls at a median of 16.8 seconds each. The larger the organisation, the worse it gets, which is the wrong direction for a product whose questions are organisation-shaped. - What
indexeddoes. A scheduled job (ingest.spend.schedule, nightly by default) sweeps the Router's request logs into the datapond, and the same questions become queries against a local index. Per-key detail — the split that stops a service key's spend being reported as a person's — comes with it, so it is available on every indexed install rather than only where the Router is reachable and fast. Key names arrive too: the index carries the Router's own name for each key. - What you trade. Currency, and the answers say so. Every indexed figure carries how fresh the index is and whether it reaches the period asked about; where it does not, the answer states that the figures are a floor for that period rather than quietly reporting a smaller number.
Upgrading changes nothing unless you ask it to. An install that leaves
sources.spend at live or synthetic behaves exactly as it did — no new
job, no new schedule, no new Router traffic. The one visible difference is an
empty router_spend_key_daily table in the datapond, created by the shared
schema; nothing reads it until the mode is indexed.
To turn it on, on an install that already has secrets.valetApiKey:
helm upgrade prism <chart> -n <namespace> -f <your values file> \
--set sources.spend=indexed
Then read these before you do:
- The first sweep is the longest ingest in the product. It walks
ingest.spend.backfillDays(default 90) of the Router's raw request log, which on a busy install is tens of thousands of rows per day. Expect tens of minutes; a run that is interrupted resumes at the day it reached rather than starting over. Until it finishes, the self test's spend line reads0 rowsand spend answers say the index is empty rather than reporting a zero. ingest.spend.backfillDaysis deliberately separate fromingest.backfillDays. The shared value is raised to reach further back through pull requests; inheriting it here would multiply a first Router sweep by an order of magnitude nobody asked for.- Check the Router first.
prism-ingest check spend(rendered as a job in this mode, and run on every install/upgrade) asks whether request logs are actually visible and whether the rows carry a user id. On a split deployment the failure that matters is not a blocked call — it is a management plane that answers correctly and holds nothing, because the data plane never exported its logs. Where rows carry no user id, spend is still indexed and still correct per key, but it cannot be attributed to people, and the self test says so rather than leaving you to infer it from empty tables. - Going back is safe. Roll the chart back with
sources.spend=indexedstill set and the older management server does not recognise the mode: it falls through to the live Router path, which is the behaviour you had before. Answers stay correct and get slow again. The index and its job are left alone, so rolling forward again needs no re-backfill.
A table of people is one question, not one per person
Per-person spend used to cost one call per person inside the turn, and the index alone did not fix that: on our own demo a twenty-person table went from 16.8 seconds a call to 1.36, and the answer took the same three minutes. The calls were never the wait — twenty trips through the model were, each one re-reading a longer conversation.
An indexed install now answers all of them in one query. The same per-key split, the same caveats, the same per-person identity on every row. It is also the safer shape: results come back keyed by person and each entry carries its own name, so there is no list order for a table to line names up against — which is how one engineer's spend once appeared under another's name.
Installs without the index are unaffected and keep the per-person route; the tool says so and names it, rather than substituting a total that would look like an answer.
A fresh install can register its own MCP servers
Read this if you are on 0.4.0 and chat has never worked. The agent runner
carries a guard against fetching URLs that resolve to private addresses. Every
MCP server address the chart generates is one — they are in-cluster services by
design — so on 0.4.0 the runner refused the addresses the chart handed it, the
first chat turn failed during set-up, and the reader saw only "no answer from
the agent". A helm upgrade to 0.5.0 fixes it with no values to change; the
next chat turn registers the MCP servers and succeeds.
Only an install that has never registered its MCP servers is affected, which is every install made from 0.4.0 or 0.4.0-rc0. If chat works today, this changes nothing for you.
Also in this release
agentRunner.extraEnvsets extra environment on the runner container from your values file. If Tetrate support have ever asked you to runkubectl set envagainst<release>-agent-runner, move that setting here. Akubectl set envvariable does survive a routinehelm upgrade, but it is invisible: it is in no values file, so nobody reviewing your configuration can see it, and it is lost if the release is ever uninstalled and reinstalled or upgraded with--force. In your values file it is neither.agentRunner.promptCachingturns off the prompt-caching hints Prism attaches to its requests. Leave it on: it is a large, stable discount on every question. Turn it off only if your Agent Router mistranslates those hints — the symptom is that every question fails while the self test's Router lines stay green, and Troubleshooting has the error to match against.- The spend index reconciles against the Router, and the check is written down rather than asserted: over a full closed day, 26,604 requests and $717.7104 both ways. Stored figures round to four decimal places per row, so a total read back out of the index agrees with the Router to the cent and not beyond it.
- An unreported latency is no longer reported as zero. A missing percentile
used to be filled in as
0 ms, which is not a measurement of a request that happened — and it reads as the signature of a batch or service key, which is how a key gets characterised on evidence nobody supplied. - The empty state leads with a faster question. "Top PR authors across the
org in the last 30 days" is now the first suggestion; the merged-PRs-against-
AI-cost table is second. Both still sit one click from a blank window, and no
suggestion was withdrawn — but on an install still reading spend
livethe cost table is the slowest question in the product, and it was the one a new reader was most likely to click first.
0.4.0 (30 July 2026)
0.3.2 was the first release, nine days before this one. 0.4.0 answers the two
questions a reader asks before trusting a chat window over their own data: who
is allowed in, and where did this number come from. Prism can now run its own
front door, so an install with no IdP in front of it can have real users
instead of one shared operator login. Every answer carries a record of what it
touched, counted by the app off the tool traffic rather than described by the
model, and an answer built from zero tool calls says so in red. The stats
behind the answers know more, and they admit more about what they do not cover.
Three things about the install itself changed enough to read before you
upgrade: there are eight images now, sslmode means one thing to both database
clients, and helm upgrade no longer waits for the first backfill to finish.
Signing in
- Local sign-in makes the app its own front door (
app.identity.mode: local). Only the emails an admin has allowlisted can sign in, each sign-in is a single-use link sent by email, and the session is a cookie. This is the multi-user path for an install with no IdP integration yet. It needsapp.auth.superAdmins,app.auth.baseUrland the newsmtpblock, and the chart refuses to render without them. - Roles are now user and super admin. Everyone the front door admits is a
user.
/adminand/selftestrequire super admin undereasyauth,iapandlocal, because the self-test report names image references, internal URLs and configuration.staticmode is unchanged: no front door means one operator identity that reaches everything. - The self test gained an SMTP relay check under local mode. It asks the relay, address by address, whether it would deliver to each bootstrap super admin. It never sends a message, so running the self test repeatedly cannot spam anyone.
- The self test now also runs from inside the pod, with
kubectl exec -n <namespace> deploy/<release>-app -- python -m prism_app.selftest. That is the escape hatch for the case the role gate cannot help with: something is broken mid-install and no super admin can sign in yet.
Answers you can check
- Every answer carries a provenance record, generated by the app rather than by the model. It names the backends contacted, the calls each one took with their arguments and row counts, and what each source published about its own currency. An answer that contacted nothing at all carries a red banner above the text. The record is flattened into the exported PDF, where a collapsed footer would have been lost.
- Answers state the population they counted and the window they cover. A narrowed population says so, and the scope line leads with the filter rather than burying it.
- A truncated answer is treated as a failure. An answer that stops mid-word is reported as one, and a turn that dies part-way keeps the work it had done and says why it stopped.
- A person named in prose is resolved through the directory. A name nobody answers to comes back as unresolved, rather than as zero.
- The local store is now called the index. It was called the mirror, in answers, in the Source column beside a figure, and on the self test. Nothing about it changed — same data, same ingest, same freshness — but "mirror" claimed to be a faithful duplicate of GitHub or Jira, which it is not and is not trying to be: it holds what the ingest swept, it is stale between runs, and it says so. "Index" carries derived-and-partial in the word. The other sense of mirror, the one where you mirror our images into your registry, is unaffected and keeps the word.
What Prism can answer
- Review latency and the reviewer dimension, computed from the index. PR reviews are ingested now, so time to first review and time in review are medians over the whole population instead of a live sample of a handful of PRs per person. Each figure ships with the definition it was computed under, because "time in review" names several quantities that differ by a factor of several on the same data.
- Closed-without-merge is a metric, rather than something a reader derives from two other numbers.
- The directory carries team, location, manager and cost centre, typed by a
human through
/admin. Prism refuses to infer team membership, because the only material available for inferring it is people's names. - Live GitHub drill-down arrives as a new image,
github-live-mcp, off by default. It answers the narrow, fresh, per-repo questions the index cannot cover between ingest runs. It is read-only: the write tools are dropped from the image, so a chat prompt can never mutate a repository. Turn it on withmcps.githubLive.enabledoncesecrets.githubTokenis set. - Spend figures name the window they cover, and a person's spend disaggregates by API key.
- Three internal tools no longer ship.
get_support_ticket_stats,get_ta_impact_summaryandget_ta_spendmeasured a Tetrate-internal support agent, and a starter prompt in the UI invited you to ask about them. On any install but ours they answered zero off an empty table. Both the tools and the prompt are gone.
Charts, export and the empty state
- Box, histogram and heatmap charts joined the renderer. Prism draws the distribution where the question is about one, instead of a bar chart of medians that hides the tail.
- Stacked bar charts. Where the answer is how one total splits across categories — pull requests per week broken down by repository, say — there is now one bar per week whose height is that week's total and whose segments are the repositories. Ask for the normalised variant ("as percentages", "a 100% stacked bar", "a marimekko") and every bar is drawn full height so only the mix moves; the tooltip still gives the count each percentage was made from. Previously a question like this drew one bar per repository per week, side by side, with the total nowhere on the chart.
- Starter prompts can be saved. Star a prompt beside the composer, and give it a daily or weekly cadence to have it re-run. Every suggestion in the catalogue is a question the backends can actually compute, checked by a test against the known-answer corpus.
- PDF export keeps its charts and columns. Scatter joined the renderer, and
export filenames lead with a
YYYYMMDDHHMMstamp so a folder of them sorts.
Installing and operating
- Eight images to mirror, up from seven.
github-live-mcpjoins the set, and it is Trivy-scanned at release time like the rest. postgres.external.sslmodemeans the same thing to every client. The agent runner is the one component that is not a libpq client, and it readrequireas full certificate verification where everything else read it as "encrypt, do not verify". The chart now appendsuselibpqcompat=trueto the runner's DSN. Against a Postgres with a private-CA certificate, the old behaviour crash-looped the runner with a failing health probe and nothing in the logs. Use standard libpq values only:no-verifyis a node-postgres spelling that every other component rejects.helm upgradereturns once the first backfill has started, rather than blocking on it. The first run walksingest.backfillDaysof history and takes tens of minutes, which outran Helm's timeout and reported UPGRADE FAILED over a healthy ingest. See step 7 of install.md for how to watch it finish. A GitHub backfill that dies part-way now resumes from the last completed day instead of starting over.- The
vectorextension is a per-provider matter. Azure Database for PostgreSQL flexible server needsVECTORadded to theazure.extensionsserver parameter. Cloud SQL needs nothing. RunCREATE EXTENSION IF NOT EXISTS vectoryourself before installing and the question is settled for any provider. - parameters.md is new, and it is generated from the chart
rather than written by hand. It lists every value an installer can set. CI
fails if it drifts from
values.yaml. - The self test reports what the indexes hold, not only when a job last ran. Freshness is stated in the units the source actually keeps, so a day-grained source such as Router spend names a date where a row-stamped source gives an age.
0.3.2 (21 July 2026)
0.3.2 was the first release of Prism.