Skip to main content

Limits

Prism has ceilings. Some are ours, some are the APIs we read, and some are arithmetic — a walk through ten years of pull requests takes as long as it takes. This page states them as numbers, so you can size an install against them instead of discovering them in production.

It also states the one thing Prism requires of your organisation's data, because an operational envelope has a floor as well as a ceiling.

Every figure here is a real enforced value in the release you have, not a guideline — with one stated exception, the context-stream entitlement below, which is a licence figure the product reports and never enforces. Where a ceiling can be crossed without the product noticing, this page says so — that matters more than the number itself. As of this release there are none: the last three — the 1,000-a-day search cap, an ingest that stops advancing, and the disk — all gained detection, and the summary below says what reports each.

One stream must carry your org directory​

Prism needs exactly one source of organizational directory data: the people in the population you are analysing, the identifiers they go by in each system, and which team each of them is on. One — not several to be reconciled.

If that data lives in more than one system in your organisation, joining it is your work, upstream of Prism. Produce one clean directory and connect that. Prism resolves one identifier against one directory; it does not merge four partial views of your organisation into a single picture, and it will not try — an answer assembled that way is a guess wearing a number.

What Prism can build for itself in this release, and what it cannot:

Where it comes from
Who exists, per sourceDerived from the data already mirrored — Jira issues carry an assignee address, the GitHub lane knows every author's login
The same person across both sourcesJira discloses an address on the record, so a Jira account resolves through any directory that holds addresses. No GitHub record carries one, so a GitHub login resolves either because your directory names the username outright, or because the member published a profile address your directory also holds — the routes, and what each needs from you
Which team somebody is onYours, always. Nothing Prism reads knows it — not GitHub, not Jira, not your Agent Router — so it comes from the source you designate as your organizational context stream, and from nothing else. Prism holds no team column of its own

So the practical shape of the requirement today is: Prism derives the identities, you supply the teams. When your organisation's directory is available as something Prism can connect to — an HR-sourced table in a warehouse, typically — it is connected as one source like any other, and it counts as a context stream in its own right, which is the same rule seen from the other side.

What happens if you have no directory. Nothing fails to install, and nothing about one source at a time is affected: counts, spend, throughput and every per-person answer work exactly as documented. What you lose is every team-shaped question — those are refused with a request to name who is meant, or grouped as unknown, which is No inferring who is on a team reaching its logical end rather than a separate limitation.

Telling Prism which stream is your directory​

Connecting the source is not the same as designating it. Prism will not guess which of your connected sources is the directory of people — that choice decides which numbers every person-shaped answer is computed from, so it is yours to make explicitly. Registering a source never designates it, even when its manifest carries a role: organizational line: that line is not stored, and the source runs as an ordinary one beside your directory until you designate it.

On Admin → Context streams, the organizational entry is pinned at the top of the list. While nothing holds it, that entry is red and offers Designate on each registered source that can carry it: one that lists your people, one row per person keyed by an employee id or an email — a staff list or a directory view, never GitHub, Jira or GitLab, whose rows are activity. One act per instance, so an installation with two Snowflakes can designate either of them. A source you have already designated but not yet enabled is named there as well, and its button opens that source's own row rather than offering a designation that would write nothing: the entry stays red until that source is switched on, because a designation is a claim about which source holds the stream and not a connection. Which button switches it on is on that row — Approve first if it has never been approved, then Enable. Once a source holds it, hand it to another with Move to… on the holder's own pane. Exactly one enabled source may hold it at a time, and the move is one act rather than a withdrawal followed by a designation — neither touches a stored credential, and neither disables anything.

Three refusals you may meet, and what each means:

  • "<name> already holds this installation's organizational context stream." Another enabled source holds it. The page names the current holder before you click, so this is a refusal you meet through the API rather than on screen: withdraw its designation, or disable it, and designate this one — or use Move to…, which does both halves in one transaction.
  • "<name> declares no identity kind." The source's manifest does not say which of its fields holds each person's identifier, so there is nothing for your other sources' rows to be resolved through. Designating it would leave every person question exactly as unanswerable as before. Add identity: <kind> to that field and register the source again.
  • "<name> cannot be this installation's organizational context stream: its primary key has no employee_id or email field." The directory is a list of people, one row each, and this source's rows are something else — pull requests, issues, tickets, a resource plan. GitHub, Jira and GitLab always meet this. Designate the source that lists your people instead: a staff list, or a directory view keyed by employee id or email. A key of upn alone is not accepted yet, because none of the sources Prism reads records a UPN for another source's rows to be matched against. A source that already holds your directory from an earlier release keeps it, with a warning on its pane and in the self test, until you move it.

What changes the moment you designate one. The designation is not a label: it changes where every person-shaped answer comes from.

  • Questions about people, teams, headcount and reporting lines are answered from the designated source, and the answer names it.
  • The older User directory page is gone, and so is the table behind it. It was a hand-filled list of people that Prism kept for itself; the source you designate is the record now.
  • If Prism cannot determine which source is designated — the registry is unreachable — it says so and answers nothing about people, rather than falling back to the older directory. A figure from the wrong directory is worse than no figure, because nothing about it looks wrong.

One change you will see in the answers: a quiet quarter now reads as "unresolved"​

This is the most visible behaviour change in this release, and it is worth understanding before somebody reports it as a fault.

Prism used to keep its own list of people. If you asked about somebody who was on that list but had closed no issues in the window, Prism could tell "a real person with a quiet quarter" from "not a real name", and answered 0. It no longer keeps that list, so it can no longer tell those two apart from its own records: that person comes back as unresolved, and the answer says the roster it checked was "this issue store's assignees only".

That is the safe direction, and it is deliberate. Prism's standing rule is that a name it cannot resolve gets no figure at all, never a zero — because a zero beside somebody's name reads as a measurement of that person, and stays wrong long after the reader has moved on. What this release costs is the ability to give the true zero; what it keeps is the refusal to give a false one.

Two things follow:

  • Designate an organizational context stream and the population question has an answer again — that source knows who exists, whether or not they closed anything.
  • If you were running with synthetic data, the old behaviour was worse than what replaces it, not better: the list included the seed's invented people, so a made-up name could come back with a real-looking 0 beside it. That cannot happen now.

Designating nothing is a valid state, and it is what a fresh install is in. Person-scoped answers are then reported as connected but not correlatable rather than being quietly computed from something else.

How many context streams you are licensed for​

A context stream is a named group of datasets that answers one kind of question — GitHub pull requests is one, GitHub issues is another, your org directory is another. It is the unit Prism is sold by, and it is what sources.entitlement.contextStreams (default 10) records.

Every connection counts, including a second one to the same system. One source can carry several streams, and each is one: GitHub is two. Two sources are counted separately even where they answer the same kind of question — a second warehouse view connected as its own source is a second context stream whether or not you name it the same thing as the first. That is the usual shape for a warehouse, where each golden view you put in scope is a stream of its own.

Nothing enforces it. Set it to the number in your contract and the admin page states it beside the number of context streams this installation actually has enabled; the self test says the same pair on its source registry line. If the install goes over, both say so as a plain fact and every source keeps working — there is no block, no grace period and no degraded mode in this release. It is a licence figure on a screen, so that what you are paying for is something you can see rather than something you have to ask for.

Two things it is not. It is not a count of connected systems: one system can carry several streams, several sources can read one system, and a source that is registered but switched off counts for nothing. And it is not evidence that anything is reachable — a stream counts because this install is configured to run it, which is a different claim from "Prism has read it today". The source registry self-test line is where reachability is reported.

Read only where sources.registry.enabled is on, because the count it is stated against comes from the registry.

How far back the data goes​

ingest.backfillDays (default 90) is how far back the first ingest of each live source reaches. Everything after that first run is incremental.

The walk is one calendar day at a time, per organisation, and it prints a line per day so you can watch it. Measured: 77 minutes for one GitHub organisation over 90 days (8,965 pull requests and 14,386 issues) — about 0.86 minutes per organisation per day of history. A busier organisation is slower, because a day with more pull requests in it costs more requests.

That rate is the whole sizing story. The table below is that one measurement scaled, not five measurements:

backfillDaysOne organisationTwo
90 (default)~77 min~2.5 h
365~5 h~10.5 h
1,095 (3 years)~16 h~31 h
1,825 (5 years)~26 h~52 h
3,650 (10 years)~52 h~105 h

An earlier version of this table was scaled from a 40-minute figure and read about half of these. 77 minutes is the measured one; if you sized against the old table, size again.

In ingest.engine.mode: authoritative, GitHub issues have a floor of their own — on a source the engine has not ingested before. That lane reaches back at least 365 days whatever backfillDays says (on an install whose legacy GitHub lane has already run, the engine reads that lane's recorded horizon instead and ingest.github.rewalkFrom is what recovers the year), and the days it walks alone — those older than the pull-request window — cost one issue-search request per 100 issues updated that day rather than a whole bucket. At the measured density above (14,386 issues over 90 days, about 160 a day) that is one or two requests per day: roughly 9–18 minutes per organisation on the default 90-day window, on top of the figures above, with the measured density at the top of the range.

At backfillDays of 365 or more there are no such days, and nothing to add. 365 is a floor on the issue walk and not a cap, so the issue lane walks the same window as the pull-request lane and the 365, 1,095, 1,825 and 3,650 rows above already cover it — they are scaled from a measurement that walked both searches together. Size those rows as they stand. See Issues reach back a year.

Any one ingest run is killed at 24 hours (ingest.runDeadlineSeconds) — the first-run Job and every firing of the incremental CronJob alike. That is deliberate: a wedged run should eventually become a failed Job the self test can see, rather than a pod sitting there for weeks holding the slot every later firing needs. At the rate above it means roughly four and a half years of history for a single organisation fits inside one run, and proportionally less for each additional organisation you name.

Asking for more than that is not refused, and it does not lose data. The walk records each completed day, so the six-hourly incremental CronJob resumes from where the killed run stopped and chips away at the remainder over the following days. What you get in the meantime is a partial index that says it is partial — every answer over a window reaching past what has actually been ingested is labelled, and until the sweep records where coverage begins, answers carry an explicit note that the leading edge is unverified.

If you want deep history, the supported route is to raise backfillDays and expect the first fill to take days, not to expect one job to do it.

"History" means touched, not created​

The GitHub walk selects pull requests by when they were last updated. A pull request merged before your backfill horizon and never touched since is not in the index; an eight-year-old one commented on last week is. This is why the oldest row in the index is not the same thing as where coverage begins, and why the product reports the two separately.

The practical reading: Prism answers "what happened in the last N days" accurately. It does not hold the complete history of a repository, and a completeness question about one is not something it can answer from the index.

The 1,000-a-day ceiling — Prism detects this and says so​

GitHub's search API returns at most 1,000 results for any one query. The ingest works around it by querying one calendar day at a time, which is enough for every organisation we have measured.

An organisation with more than 1,000 pull requests touched in a single calendar day is past it, and the excess is not fetched. There is no way around that from inside the appliance — the same ceiling applies to any query Prism could make, including the live GitHub drill-down.

What Prism does about it is notice. When a day's walk runs out of pages — ten of them, which is all the search API will serve — and GitHub's own count for that query is higher than what came back, the ingest records the organisation and the date. Both halves are required, so a day is only ever recorded when the ceiling is what stopped it; a query that simply returned fewer records than GitHub counted (which happens routinely, because search counts matches before it drops the ones your token cannot see) is not one. From then on:

  • every answer whose window contains that date says so, before any figure, and reports the numbers as a floor rather than a total — the same treatment a window that runs past the ingest gets;
  • the self test's line for the GitHub source shows how many such days there are and the most recent one;
  • the downloadable diagnostic bundle lists every one of them.

This is the reading to act on rather than watch, because nothing else on the page moves: the pull requests that were fetched are complete and current, so the row count, the age and the "data through" date all look healthy over a day that lost hundreds of records.

You can still measure it yourself, and it is worth doing before a rollout so the first you hear of it is not an answer carrying a caveat. Any day over about 800 is worth a conversation with us:

gh api -X GET search/issues \
-f q='org:<your-org> type:pr updated:<yyyy-mm-dd>' -f per_page=1 \
--jq '.total_count'

Sample your busiest weekdays, not an average one — a few days of the busiest recent week is enough to see where you sit. If you would rather have the whole distribution than a handful of spot checks, ask us: we run the same query over a range of days and can do it with you.

For scale, measured rather than estimated. Over the 120 days to 29 July 2026, tetrateio — an active organisation of around fifty engineers — looked like this:

PRs touched in a day
busiest day226 (2026-07-28)
p95193
median86
days over 8000
days at or over 1,0000

So on the busiest day in four months the cap sat 4.4× above the traffic, and nothing in that window came close enough to be worth a conversation. That is one organisation of one size, which is the point of measuring yours: the figure that matters is not ours, and an organisation several times larger, or one that merges a long-running branch series in a burst, is a different question.

Two limits on the detection, so you know what it does not claim:

  • the buckets are keyed on when a pull request was last updated, so a capped day can also cost records whose merge or creation date falls outside it. A window with no capped day of its own is "no capped day here", not a guarantee that nothing was lost;
  • an index filled before this release, or by the synthetic seed, carries no such record. Answers over it report the ceiling as unknown rather than as not hit. The record starts filling on the next ingest run.

In both cases the answer says cannot confirm rather than complete: once any capped day exists on an install, or where no record is kept at all, Prism will not describe a window as fully covered. That is deliberately the cautious direction — a caveat where there may have been no loss, never a clean bill over a window that lost records.

The data itself is not recovered — a finer bucket than a calendar day would be needed for that, and it is not in this release. A recorded capped day is also permanent: nothing removes it, because a re-run hits the same ceiling on the same day. If a capped day shows up on your install, tell us: it is the signal we would size that work against.

How fresh the data is​

SourceSchedule (default)Worst case behind
GitHubevery 6 hours, on the hour~7 hours
Jiraevery 6 hours, at 15 past~7 hours
Agent Router spendnightly at 02:40 when sources.spend=indexed; read live from the Router otherwisea day behind indexed, current day incomplete either way

Worst case is the interval, plus however long a run takes, plus the one-hour overlap each run re-reads so nothing falls between windows. Raise the frequency by setting ingest.github.schedule / ingest.jira.schedule if you need tighter — they are ordinary cron expressions, and the ingest is incremental, so a shorter interval costs proportionally less per run rather than more in total. How long a run itself takes is set by the request rate you allow it — see "How fast Prism calls your GitHub and Jira" below.

Two things follow that are worth knowing before someone asks:

A question about today is a question about a stale index, and Prism says so. Ask about a window that runs past what has been ingested and the answer is labelled — the figure comes with the date coverage actually reaches. Where the optional live GitHub drill-down is enabled, Prism may answer the narrow, fresh part of such a question directly from GitHub instead, and it labels those figures as live.

There are two sizes of that, and they read differently on purpose. A shortfall big enough to change the figures — more than a tenth of the window, or more than a day — is a warning above the numbers, and the figures are described as a floor. A shortfall inside the schedule above is not a warning: it is the normal state of a periodic ingest, and a red band on every answer is one nobody reads. It is still stated, in the provenance block, as which end of your window the data did not reach. What Prism will not do is call such a window fully covered, at either end — including the case where the window opens before the earliest row a source holds, which is what a question reaching further back than the first ingest looks like.

An ingest that stops advancing fails the self test by itself. You are not asked to watch an age and judge it. Each source's line is measured against that source's own schedule (ingest.github.schedule / ingest.jira.schedule): once ingest.stallAfterIntervals of its intervals — four by default, so 24 hours on the six-hourly default — have passed with no ingest completing, the line reads

FAIL github ingest stalled — last completed 2d ago, past the 24h budget
(schedule is every 6h) · 26,029 rows

That is a verdict rather than a reading, and it is the one thing the appliance knows that a row count does not: what was supposed to have happened. A quiet organisation still passes — the budget is on the ingest completing, not on the data changing.

Two paths that used to reach that state silently are now closed:

  • the GitHub ingest now fails its own Job when a run ends with the PR mirror empty — a revoked credential, the wrong organisation name, an upstream returning nothing. Read that as "the table has nothing to serve", not as "this run fetched nothing": a quiet weekend in which no PR changed fetches nothing, leaves the existing rows alone and is correctly a success. Only GitHub's very first run is still allowed to end empty and succeed, because a first run against a genuinely empty window looks the same from inside; the second firing of the CronJob catches it, six hours later on the default schedule. The Jira ingest has no equivalent gate — jira_issues carries no per-row ingest stamp for one to read — so for Jira this case is caught by the self test rather than by the Job: an empty live source already fails its SOURCES line outright (live but 0 rows), and a Jira ingest that stops completing at all is caught by the stall verdict above, which covers both sources;
  • the recurring CronJob now carries a 24-hour deadline (ingest.runDeadlineSeconds), for both sources. Only one run may hold the data at a time, so before this a single wedged run kept every later firing from doing anything, indefinitely. It is now a failed Job that the next firing is free to follow.

Alerting on Job failures in the namespace is still worth doing, and it catches the wedged case as well as the empty one. It is no longer something you have to correlate with a page somebody remembered to open.

What it stopped catching in 0.15.0: a source that simply fails. The ingest tick used to exit non-zero whenever any source failed, which restarted it and re-read every other due source seconds later; it now finishes green when it has recorded why each failure happened. So this alert still fires for the empty case above, a wedged run, a run killed at its deadline, a tick that cannot reach its own database, and a failure Prism could not write down — but not for an ordinary source failure. For a source you registered from a recipe, nothing at cluster level catches that today: the diagnostic bundle's ingest block is where its last_failure lives.

How fast Prism calls your GitHub and Jira​

You set the rate, in requests per minute, per source:

ParameterDefault
ingest.github.maxRequestsPerMinute30
ingest.jira.maxRequestsPerMinute30

Thirty a minute is one request every two seconds. It is deliberately well under what a healthy instance allows, because the default has to suit an instance whose limits nobody has checked yet — including a self-hosted GitHub Enterprise Server or Jira Data Center whose API rate limiting may never have been configured for a client like this one. Set either to 0 to remove the cap.

Three properties worth knowing before you pick a number:

  • It is a ceiling, not a target. Each ingest is serial — one request in flight at a time — so the rate cannot be exceeded by concurrency, and the cap applies to retries as well as first attempts. An ingest being rate-limited by your instance cannot respond by calling it faster.
  • It covers everything that run does. For GitHub that is 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, which pushes the worst case in the table above out; nothing is skipped and no data is lost. A first backfill is the run that feels it — measured at ~40 minutes for one organisation over 90 days at the default rate.

The rate in force is the first line of each ingest's log, so you can confirm what is running rather than infer it:

github ingest: request rate 30/min (GITHUB_MAX_REQUESTS_PER_MINUTE) against https://github.example.com/api/v3

If your platform team wants to start lower and raise it once they have watched a run, that is the intended use: lower it, take a backfill, then raise it in values.yaml and helm upgrade. Nothing needs to be re-ingested when it changes.

The prism-ingest check probe is separate and much smaller — a handful of calls against each source, every ingest.checkSchedule (30 minutes by default). It is not covered by these caps; lengthen the schedule if even that is unwelcome.

The spend window is the Router's, not ours​

GitHub and Jira history is indexed locally, and how far back it goes is your setting. Agent Router spend is different: it originates in your Router's request log, and no setting of ours reaches further back than that log does. We do not set its retention and cannot extend it.

This is the ceiling that bounds the others, and it is the one most likely to disappoint:

Cost-versus-output questions are answerable only over the window your Agent Router still holds usage for. Pull request and issue history older than that is context, not correlation.

So a five-year PR backfill does not buy five years of "what did we get for what we spent".

What we measured, since this used to be a blank. Walking Tetrate's own Router backwards a month at a time on 5 August 2026, asking its usage API how many requests it holds for each window:

earliest request it will answer for2025-12-16, 13:12 UTC
that is232 days of history, 1.5 million requests
what stops therethe traffic, not a retention policy

That last row is the finding, and it is worth more than the date. Nothing had been purged: the oldest surviving month runs at 4% of the rate of a median month, with three empty months behind it and volume climbing steadily after it — the shape of a Router being adopted, not one being trimmed. The boundary day itself holds 57 requests where a recent day holds around fifteen thousand, which is what the first day of use looks like and not what the far edge of a retention window looks like. So that Router's retention is at least 232 days and has never yet been the binding limit on anything. What bounds correlation on it is the day it was switched on.

Do not read that as your number. It is one Router, one deployment, one configuration, and the useful thing it demonstrates is the check rather than the result. To run the same check against yours, ask its usage API for a window and read the count back:

curl -s -X POST \
'<your-router-api>/tars.insights.v1.RequestLogsService/QueryRequestLogs' \
-H 'Content-Type: application/json' -H 'Connect-Protocol-Version: 1' \
-H 'X-API-Key: <your-router-key>' \
-d '{"startTime":"<yyyy-mm-dd>T00:00:00Z","endTime":"<yyyy-mm-dd>T00:00:00Z","pageSize":1}' \
| head -c 400

The totalCount in the reply is how many requests that window holds. Halve your way backwards until it reads zero and you have your Router's horizon; the shape of the counts on the way tells you which of the two things you found, exactly as above. If you would rather not do that by hand, ask us — we run this and can do it with you.

Indexing spend changes what the horizon costs you. With sources.spend=indexed, Prism sweeps the Router's request log into its own database nightly and nothing prunes what it has swept. The Router's window then bounds the first sweep — ingest.spend.backfillDays, 90 by default — and not the record from that day on: an install that has been running for a year holds a year of spend whatever the Router has since dropped. That is an argument for turning it on early rather than a reason to relax about retention, because the history you never swept is the history you cannot recover.

Spend is also aggregated per day, so it knows nothing finer than a day, and the current day is always incomplete.

How many people can use it at once​

Four questions run at the same time. One replica of each component runs, and the agent runtime processes four turns concurrently; a fifth question queues and starts when a slot frees. Nothing is dropped and nobody sees an error — they wait.

Prism is sized for a handful of people asking considered questions, not for a department. If you need more, that is a conversation with us rather than a value to change: the replica count is fixed in this release.

How many database connections Prism uses​

The app keeps up to 10 connections open to the datapond (app.datapondPoolSize), and everything else in the appliance takes roughly 30 more between them. On the bundled Postgres, which allows 100, that leaves ample room. On your own managed instance it is a number to check before you install, because a small tier can allow fewer connections than Prism wants.

Every route that reads or writes Prism's own tables — the chat archive, feedback, sign-in, the admin pages, the self test — takes one connection for the length of its query and gives it back. Ten is how many of those can be in flight at once.

Over the ten, a request waits up to ten seconds and is then refused with a 503 rather than hanging. Before this release it waited for ever: a page that would never load, and no message.

Prism distinguishes two reasons in that refusal, and they want opposite responses:

  • "too many requests are using the database at once" — every connection really was in use. If you see this in normal use, app.datapondPoolSize is too small for you: raise it. If you see it when nobody is using Prism, a query is stuck; restart the app and tell us, because a bigger pool would only delay your noticing.
  • "Prism cannot reach its database right now" — the pool was not full; the database did not give Prism a connection in time. Raising app.datapondPoolSize does nothing for this. Look at the database itself and at the network between it and the cluster.

The app log records which of the two it was on every refusal, with the number of connections that were open at the time.

Working out whether your server is big enough​

Add up what the appliance opens, at its busiest:

ComponentReplicasConnectionsTotal
App (a pool)1app.datapondPoolSize — 10 by default10
Agent runtime (a pool, its own database on the same server)11010
Query engine (opens one per question, closes it after)1~5 at peak5
The three built-in stream servers (same, one per call)1 each~3 each at peak9
Data refresh jobs and the schedulernot always running1–2 each~4
Reserved by Postgres for administrators—33
Total with the default pool~41

So Prism wants a server allowing at least 55 connections to run the default comfortably — the total above plus room for a rolling restart, when the outgoing and incoming pods both hold connections for a few seconds.

The bundled Postgres allows 100 and needs no thought. If you brought your own, check its limit (SHOW max_connections;) and compare. Managed tiers vary far more than people expect — the smallest burstable tiers on the major clouds allow as few as 35 in total, which is below what Prism needs before you have raised anything. On a server that small, lower app.datapondPoolSize and expect the 503s above under concurrent use, or move up a tier.

Note that the agent runtime's database is a second logical database on the same server, so its connections count against the same limit even though the data is separate.

If you run out​

Connection poolers (PgBouncer, and the one built into some managed Postgres offerings) exist for exactly this, and are the standard answer at larger scale. Prism does not currently support running behind one in transaction mode, which is the mode that gives the saving: its components use prepared statements, which transaction pooling does not preserve. Session mode works and saves nothing here. So for this release the answer is a server with enough connections, not a pooler in front of a small one.

How many questions one person may ask​

60 questions an hour per person (app.limits.turnsPerHour), and 4,000 characters per question. Past either, the question is refused — the hourly one with an HTTP 429 and a sentence saying how long to wait — and the refusal is recorded, so an administrator can see how often it fires.

This exists because every question spends real money on your Agent Router account, and it is the only ceiling on that spend the appliance has. It is deliberately far above what anybody asking considered questions will reach: what it stops is a loop — a stuck client, a browser tab reopening the connection on every error, an automated caller in a retry storm.

Three things it does not do, each worth knowing before you rely on it:

  • It is not a spend cap. One question has no ceiling of its own: the agent keeps calling tools and reasoning until it is finished, so 60 questions can cost anything at all. A per-question ceiling is coming from the agent runtime, not from this value.
  • It counts inside the app process, which is one replica by design, and a restart forgives the hour. That is the right way round for a bill ceiling — the alternative is a reader locked out by a rolled pod — but it means the count is not durable and not shared.
  • Under identity.mode: static there is one identity for everybody, so the 60 is the whole installation's rather than each person's. That mode has no front door and no second person to be fair between; the other three modes count per signed-in user.

And one thing it deliberately does not count: a question the agent runtime never accepted — because it was unreachable, say — spends nothing on your Router account, so it is given back rather than counted against the hour. A question that failed after the runtime had accepted it may still be counted, because by then a run exists and may have spent money whether or not an answer came back. So an outage that keeps Prism from reaching the runtime at all cannot leave somebody locked out after it ends; one that breaks mid-answer can still cost them the turn.

One rough edge: in the browser a refused question shows the same "the connection to Prism closed before an answer arrived. That can be a refusal rather than a fault, so asking again may not help." as any other interrupted request, because a browser's event stream cannot read the refusal text. The page cannot tell a refusal from a dropped connection, so it names both and recommends neither — what it deliberately does not say is that asking again is safe, which would be advice to retry the one refusal that is certain to fail for up to an hour. The sentence saying which limit was hit and when it lifts is in the response itself and in the recorded event, so it reaches anything calling Prism programmatically and reaches your administrator either way.

If a person reports that message repeatedly, the recorded events tell you which of the two it was: a refusal is an ask_refused row naming the limit and the identity, and nothing else. A dropped or broken connection is recorded too, but as an ordinary turn row — every stream that starts is, including one the reader's browser walked away from — so the question is whether an ask_refused row is there beside it, not whether anything was written.

How long one answer takes​

Prism sets no time limit of its own on an answer. A question that makes several tool calls and reasons over the results can legitimately run for minutes, and the stream stays open throughout.

That makes the timeout on whatever sits in front of Prism the real ceiling, and a too-short one is the single most likely first support ticket, because it presents as "the chat hangs and then dies" with nothing wrong anywhere in the self test.

When it happens, the answer says so: an amber band above it explains that the connection closed before the answer was finished, that asking again is safe, and that a cut arriving at about the same elapsed time every time is the timeout rather than bad luck. The part that did arrive stays on screen and can still be exported, carrying the same band into the PDF. Nothing server-side reports this — the cut is between Prism and the browser, so the stored record holds a complete answer — which is why the band is the signal and a repeated one is worth acting on rather than retrying.

Set the idle timeout on your ingress, load balancer, gateway and any proxy in the path to at least 10 minutes. The default on several common ingress controllers and cloud gateways is 60 seconds, which is not enough. Prism does not send keepalive traffic between events, so an intermediary counting quiet time will cut a thinking turn.

The duration itself has one lever over it, and it is not in this chart: the model. Time-to-first-token differs materially between models and between providers behind the same gateway, and once the timeout above is ruled out that difference is most of what is left to move. See The model is also how fast an answer arrives — it applies on the next question, so it is cheap to try and cheap to undo, and the catalogue it can be set to is your Router team's to enable.

Where the ten minutes comes from, since it is the number an infrastructure team is most likely to want justified. Across 16 consecutive turns of two real multi-turn sessions on our own deployment (30 July 2026):

Turn duration
longest6 min 11 s
p953 min 0 s
median1 min 39 s

So a 60-second timeout would have cut eleven of those sixteen turns, and ten minutes clears the longest one measured with about 60% to spare. The long turns are the ones worth having — the 6-minute turn made 25 tool calls across three sources — so the margin is deliberate rather than defensive. Treat the table as a floor for sizing: these are conversational turns on one deployment, not a guarantee about the hardest question your organisation will ask.

How much of a conversation Prism remembers​

A long chat is summarised as it grows: once the conversation passes roughly 48,000 tokens, the older part is replaced by a generated summary and about 16,000 tokens of recent turns are kept verbatim.

The consequence to expect: earlier turns in a long conversation are a summary, not a record. Figures quoted twenty turns ago are not reliably available later in the same chat, and asking Prism to compare against them is asking it to re-derive, not to recall. Start a new chat for a new line of enquiry, and ask for the figure again rather than asking about "the number from earlier".

Prism records the tool calls behind each answer, and flags an answer that quotes figures while claiming to have fetched them earlier in the conversation. That flag exists because of this ceiling.

What one answer will and will not include​

CeilingWhat happens at it
Aggregate tables20 rows by defaultAsk for more and it returns more; day-by-day series are uncapped
Live GitHub drill-down3 pages per query (~300 records)Refused, naming the aggregate tool to use instead
Individual pull requests read in full12 per questionRefused, same
Repositories the live GitHub lane may answer aboutWhatever mcps.githubLive.allowedRepos lists; unset means every repository the credential can readRefused, naming the repositories the installation allows
Model context200,000 tokens in, 8,192 out (unless your gateway reports otherwise)Long answers are cut at the output ceiling
Saved prompts50 per person, 2,000 characters eachRefused with a message saying so
One question's length4,000 charactersRefused with a message saying so
Questions asked60 per person per hour (app.limits.turnsPerHour)Refused with a 429 saying when to try again, and recorded
Sign-in emails (local sign-in only)3 per address per 15 minutesRefused until the window rolls

The two GitHub page/detail refusals are deliberate and will not be raised on request. Paging further through a live API gives a longer sample, never a more accurate total — population figures come from the index, which has counted everything it holds. An answer that assembled a total by walking pages would look more thorough and be less correct.

Which repositories an answer may draw on​

The third row is different in kind: it is yours to set, and by default it is not set at all.

Prism's live GitHub lane holds one credential and can read whatever that credential can read. That is a decision your platform team makes and not one this appliance can see: a token re-scoped from forty repositories to every repository in an organisation produces no error here, no log line and no change in behaviour — the lane simply becomes able to answer about all of them. It matters most because one of the three live tools takes a free-text GitHub search query written by the model, and pull-request titles and bodies are text the model reads and can be steered by.

So mcps.githubLive.allowedRepos is where you write down the repositories this appliance may answer about:

mcps:
githubLive:
allowedRepos: "acme/payments,acme/ledger,acme/settlement"

With it set, a live search is confined to those repositories, a query naming any other — or naming an organisation — is refused with the allowed set in the message, and the two tools that name a repository outright refuse anything else. Every answer also carries the scope, so a figure from a bounded install says which repositories it covers.

A long list costs you nothing here. Prism adds one search term per allowed repository, and GitHub's published limit on query length — 256 characters — applies to the free-text part of a query rather than to its search terms, so a list of forty or more is scoped the same way a list of two is. What Prism will not do is run the search unscoped and drop the unwanted rows afterwards: by then it has already read the repositories you excluded, on your rate limit.

Left empty, the lane answers about every repository the credential can read, and says exactly that in each response rather than leaving it unsaid. That is the honest default for an installation that has not made this decision, and on an organisation-wide credential it is a decision worth making.

Two things it does not do. It does not narrow the index — get_github_pr_stats answers from the mirror, which holds whatever the ingest already walked, and setting this removes nothing already ingested. And a value that is set and cannot be read as owner/repo entries stops the live lane from starting, naming the setting, rather than falling back to the empty meaning: a typo must not quietly mean org-wide.

The default scope, and where it does not reach​

Prism lets you name one population as the installation's default: a leader and a depth, in your own directory. A question that names no population of its own runs under it, and the answer says which — a default that is not stated is a hidden filter, and a denominator that moves silently is worse than one that is wrong out loud.

It does not reach everything, and this is the list. Some answers are built from the pull-request, Jira and spend indexes directly, or from a live GitHub query, without the crosswalk that turns a leader's reporting line into a set of GitHub logins, Jira assignees or Router account ids — so they cannot apply the default scope. Every one of them says so, and they are named here rather than left for you to discover.

Where you meet itWhat it covers instead
An answer built from the GitHub index — pull requests merged, reviews, issues, by author or repository or dayEvery author in every repository your ingest has walked
An answer built from the Jira index — story points or issues completed, by assignee, project, sprint or dayEvery assignee in the Jira issues your ingest has walked
A live GitHub drill-down — the individual pull requests behind an answer, and one pull request read in fullWhatever your query fetched, inside your repository allowlist if you set one
The two leaderboard endpoints the appliance still serves internally (/stats/pr-leaderboard, /stats/spend-leaderboard)The same, and every Agent Router account in the spend index

All of them carry the line "org-wide: this installation's default scope does not apply on this path" beside their figures. It is not a fault and there is nothing to configure: those lanes are the org-wide view, deliberately, and they stay available for the questions that want one. What the sentence buys you is that a reader who has just seen a scoped answer cannot mistake an unscoped one for another of the same kind.

One limit, on all four. A question that FAILS carries none of these fields, 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). Those are reported by the server framework rather than by the tool, and you see them as a failed question rather than as an answer, so there is nothing to mistake for a scoped figure. It is written down because "every answer carries it" should mean what it says.

On a Jira or live-GitHub answer the line sits beside that answer's own scope line rather than replacing it, and the two mean different things: the Jira one says which issues were counted (and names the assignee filter where a question asked for one), the live-GitHub one says which repositories this installation allows. Both can narrow an answer; neither is the default scope.

Withholding figures about small groups: a floor you set​

disclosure.perPersonFloor is the one control here that is yours rather than ours, and it ships off (0). Prism does not decide what your installation may say about your own staff; it gives you one number and applies it everywhere.

disclosure:
perPersonFloor: 5

What it withholds. On any answer whose rows are named people, a figure of one of these three kinds computed over fewer than N observations is withheld — returned as no value at all, and named as withheld so the answer says a figure was withheld and why:

KindExamples
An averageaverage lines changed per merged PR; average request latency; cost per request
A medianmedian hours to first review, median hours in review, median days an issue has been open, median days to close
A percentilethe p50 and p95 request latencies on a person's API keys

The observation count stays, so an answer can say "no median is given for her — it would have been over three reviewed pull requests" without giving the median.

"Whose rows are named people" includes an answer you scoped to people by name: ask for one engineer's figures broken down by repository or by day and every row in it is that engineer's, so the floor applies to all of them and to the across-everybody figure beside them. A breakdown by something that is not a person and not a group of people — every repository in the org, every day in the window — is not about anybody and is not floored.

Two triggers for the figures, and they count different things. One setting, one number — because "is this figure about too few people?" has two different answers depending on whether you chose the people. From 0.15.0 the participation counts have a third test of their own, which compares the counts themselves and reaches fewer sources than you might expect; it is described with the names it also governs, under "What the min-N floor does and does not withhold" below.

The answerWhat the floor compares against NWhy that one
Rows that are people, or people you namedthe observations behind the figure (n_<figure>)You already know who the row is about — you grouped by them or named them. What is left to ask is whether the statistic rests on enough evidence.
A breakdown by a field of your organizational stream — job family, business unit, a manager's reportsthe distinct people behind the figure in that group (people_behind_<figure>)You named nobody. Your org chart chose each group's membership, so the group's size is not something you decided and not something you can see from the question.

The second exists because a small group is a per-person figure wearing a group's name. Ask for median review time per job family and one family may have one person in it; that row is that person's median, and it does not stop being so because the column heading says a job family. The observation count would not catch it — that person may have hundreds of pull requests — which is exactly why this trigger counts people instead.

Each answer's per_person_floor block says which of the two governed it, and every row carries the number that was compared, under the name in the table above — except where that number IS the withheld figure, which is the case for a count and a count_distinct. If a figure is withheld and the n_<figure> beside it looks comfortably over your floor, look at people_behind_<figure>: on a breakdown that is the one the floor read.

A withheld group is still a row. The figure goes; the group stays, with people — how many people are in it — and resolved — how many of those this data source can identify — both published in full. That is deliberate: a table that quietly dropped its smallest units would read as a smaller organization rather than a protected one, and you could not tell a complete breakdown from a partial one.

The unplaced rows are covered too. A breakdown ends with a block for the facts whose people your stream holds but left blank for that field — people with no job family recorded, say. That block has no label, which makes it look anonymous and is not the same thing: your org chart still says who the one person with no job family is. It takes the same floor on the same count.

Two rows in a breakdown are not groups of people and are treated accordingly. (unresolved) holds facts whose author Prism cannot identify at all — there is nobody behind them, so there is no disclosure to prevent and the floor does not apply; withholding there would take away the very number that tells you how much of the answer is missing. (not in the directory) is different and is floored: Prism did identify those people, your organizational stream simply does not list them, so they are as identifiable as anybody else in the table.

Two of these it cannot really protect, and the answer says so. On per-person Router spend, cost per request is cost ÷ requests and error rate is errors ÷ requests, and this floor publishes cost, requests and errors (they are sums and counts — the row below). Both are withheld, so nothing quotes them, but anybody holding the row can do the division. Protecting them would mean withholding the money, which is a different decision and not one this setting makes. The per_person_floor block on that payload states it rather than leaving you to work it out.

A withheld figure still orders the list. A ranking comes back in the order the real figures put it in — Prism sorts first and withholds afterwards, because that order is the answer to "who is slowest to get a review" and re-sorting it would make the ranking wrong rather than the withholding stronger. So a floored ranking gives you no number and still gives you the comparison. If that matters for your installation, it is a different control from this one and not something this setting does; the answer's own per_person_floor note says when it applies.

What it does not withhold by default, and this is deliberate. Per-person counts and sums are published unless the source declares otherwise: merged pull requests, issues closed, story points, lines changed, and Router cost. A count of somebody's merged pull requests is a description of what happened, not a statistic about a small group, and ranking named people by one is a supported answer. If you need a figure of that kind withheld as well, it is declared per source in the source's own manifest (policy.min_n_applies_to) rather than by this setting. The shipped github source already declares one: a count of distinct people is a statistic about the size of a group rather than a description of one person's work, so figures of that kind are withheld by this floor.

Where it applies. Every backend that returns a statistic about a named person — or, in Semantic Query, about a group your organizational stream defines — as of 0.14.0: Semantic Query, GitHub Org Stats (pull requests, review pairs and the issue backlog), the Jira lane, and per-person Router spend. Before 0.14.0 it reached Semantic Query alone, so the same question could be answered under a different policy depending on which route Prism took to it. Each of those answers carries a per_person_floor block naming the floor that governed it — present even when the floor is 0, so you can see two routes to one question were governed alike.

Two things that block is not on, and both are cases where there is no statistic for the floor to reach. The Jira lane returns a sum of story points and a count of issues, so it withholds nothing at any setting and its block says as much on its face. And Semantic Query's drill-down — the request for the underlying records behind a figure, rather than the figure — returns those records; it is how a withheld statistic is looked into, and it is governed by what the source's manifest allows a drill to show rather than by this number.

A source may raise the floor for itself with policy.min_n in its manifest; it cannot lower it below what you set here. That per-source override is read where a manifest is read — Semantic Query. The three stats backends have one floor, the installation's, because a question they answer is not addressed to a registered source.

Not affected by this setting, because they are correctness rather than disclosure and they are ours: an ambiguous name still resolves to nobody, and there is still no fuzzy matching. Neither of those moves at any value you set here.

The five-observation rule for a median is correctness too, and it is a weaker thing than those two, so read it separately. In the review-pair matrix Prism withholds a median computed over fewer than five reviews outright. Everywhere else it hands the figure to the model with the observation count beside it and instructs it not to report the figure as a median — an instruction, not a withholding. Setting this floor to five or more is what turns it into a withholding on the other per-person answers as well, and the review-pair matrix follows the higher of the two so the same answer never carries two thresholds.

The four participation counts, and what each one means​

Ask "how many of this group have not done X" and the answer carries four counts over the group: observed activity, observed zero, unknown identity and unknown observation. Every person the classification reached lands in exactly one of them.

Read the names as shorthand, not as definitions. Each name describes one of the ways its count is reached, and each count is reached more than one way. The two that mislead are worth stating outright:

  • unknown_identity does not mean "Prism could not match this person." It means Prism could not attribute this work. Of its five routes, three always leave the person holding a clean, unambiguous identifier, one may, and one never does.
  • unknown_observation does not mean "this source could not have shown their work." That is true of one of its three routes. One says the source could have shown it and never has, and one says only that an absence of rows here is not evidence that nothing happened.

Everything below is per identifier kind. The group is matched to a source through one of its columns, and that column is declared as one kind of identifier — a GitHub login, an email address; query.population.matched_on names the column. Every per-person test here is about identifiers of that kind. Somebody with no usable GitHub login may be perfectly well identified in Jira, and a count from a GitHub source says nothing about that.

Observed activity​

One route: an identifier of theirs that Prism resolves to them alone appears in the rows that qualify for this measure and window.

Observed zero​

Everyone the other three counts do not take. Each of them holds such an identifier, has appeared under it in the matched column before, and this source shows no qualifying work under any identifier of theirs over this window. This is the only count that can mean "they did not do it."

Unknown identity — five routes​

Reached whenIs the person matched?What repairs it
An ambiguous identifier of theirs carries qualifying work in the window — the work may be theirs, and nothing here can sayMaybe. It is checked before whether they hold a clean identifier, so it applies to someone who holds one too — unless work under that clean identifier already makes them observed activityResolve the contested identifier
They hold identifiers of this kind and every one of them is ambiguousNo — not for this kindResolve the contested identifiers
The answer came back empty, and not one value in the joined column — over all time, with no window or filter — matches an identifier of this kind that Prism holds for anybody at all — including a column that is entirely empty or NULL, so no one's absence was observed. A column with even one value matching an identifier of this kind for anybody in the installation does not reach this routeYes, always — everyone moved this way holds a clean, unambiguous identifierStart at the column: compare what it holds with what it is declared as
The joined column mostly holds something other than the kind of identifier it is declared as. Prism compares each distinct value in the column, over all time, with what that kind looks like. An email has an @; its domain does not need a dot. A github_login starts with a letter or digit, followed by letters, digits, hyphens and underscores, with [bot] allowed at the end. A value with a dot in it does not fit a github_login, because a GitHub login cannot contain a dot, so it counts against the column; it is not counted as another kind. A gitlab_username may also carry dots. An employee_id is only checked for being something else: an address, or a login ending [bot]. Every other value fits it, including ids made only of letters, hyphenated ids such as uk-eng12, and ids with /, :, + or spaces. A value that matches an identifier Prism holds for somebody fits, whatever it looks like. The route is taken when the values that do not fit are at least as many as the ones that do, or when at most half the values match anybody and at least half of the rest look like a different kind. A tie takes the route. It is checked on every answer that asks for participation counts and has somebody who would otherwise be an observed zero, including one where somebody else was observed active: one active person proves that one value matched, not that the column holds the right kind. If the check does not answer, nobody is an observed zero. When somebody was observed active, the would-be zeros are unknown identity. When nobody was, the counts are withheld. identity.fact.shape gives the counts, including what the values that do not fit look likeYes, always, for the same reason as the route aboveStart at the column: compare what it holds with what it is declared as, and correct the declaration or join on another column
Any row that qualifies for this measure and window has a blank joined column — NULL, empty or only spaces. (A value made only of tabs, line breaks or other invisible characters is not counted as blank.) That work belongs to somebody Prism cannot name, and it could belong to anyone in the group, so no one's absence was observed. One such row is enough, and blank rows outside the window do not count. identity.fact.null_rows gives how many there areYes, always, for the same reason as the route aboveFill in who did the work at the source, or exclude those rows from the measure if they belong to nobody

The last three are the ones that surprise operators. None of them is a fact about any one person: every member they move holds a clean identifier. They move somebody who has never appeared in the column as well as somebody who would be an observed zero, because when the column itself matches nobody, that is the reason given. Where one of them moved anybody, moved_to_identity on the measure says how many. On the last two, a column that mostly holds another kind and a blank joined column, the explanation also states that number, and it does not claim that reason for anybody else in the count. On the third, none of those identifiers appears anywhere in the column, and Prism cannot say why. The fourth is the same situation with a stray match: a column of Jira account ids read as GitHub logins, one of which happens to be a login. identity.fact.distinct_values and matched_values show how much of the column was compared, and identity.fact.shape shows what it holds.

The fourth route is not a match ratio, and a low ratio on its own does not reach it. A healthy column often matches only a minority of its values, because most of a forge's authors are outside your directory, and every one of them still looks like a login.

What the fourth route's check costs. It reads every distinct value in the column over the whole table, not only the window. It runs on an answer that asks for participation counts only when somebody would be an observed zero or has never appeared in the column, and makes that answer 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 the check did not answer, until mcps.semanticMcp.statementTimeoutSeconds is raised.

Unknown observation — three routes​

Reached whenScopeWhat repairs it
They hold no identifier of this kind at all, so this source could never have shown their work whatever they didOne person at a timeAdd them to the crosswalk for this kind, if they belong there at all
They hold an identifier of this kind that Prism resolves to them alone, and it has never appeared in the matched column — in any window, as far back as this installation has read the source. A person who never appears in a source may work where its credential does not reach. The answer names the column and how far back the installation has read the source (never_appeared on the measure). A person who appears in the source only in another column is counted here too: somebody who reviews pull requests but has never authored one is not an observed zero for "authored no pull requests". For a stream whose rows come from a join, such as pull request reviews, the check looks in the joined tables whose stream writes that column, and where none does, in the stream's own rows: a pull request author whose pull requests nobody reviewed has appeared, as an authorOne person at a timeNothing, if they genuinely never use the source. Otherwise check that the credential reaches the repositories they work in, and that their identifier of this kind is the one the source records
Coverage does not support stating a zero over this window, or the group was read after it ended — the window runs past what the index holds, part of the last refresh did not finish, days hit the fetch ceiling, what the source could see changed in a way that reaches the window (a narrowing or a replaced credential inside it, or a widening whose unwalked span overlaps it), Prism's record of such changes does not reach back to the window's start, nothing establishes the refresh was complete, or nothing verifies how far back the index is complete for the start of this window — because no horizon has been verified at all, or because the window opens before it. Or the people in the group are who is in it now, and the window ended before today (membership_after_window): every group is read from the directory as it stands when the question is asked — a reporting line, a directory field, a classification, and an uploaded ownership table or activated definition too, whose date is the definition's and not its people's — so any window that ended before 00:00 UTC today is withheld. Somebody who joined since would otherwise read as having done nothing in a window they were not here forEveryone who would otherwise be an observed zero, at onceMove the window into what the index covers, or repair the ingest the reason names. For a widening, re-walk the source from the window's start (ingest.github.rewalkFrom) and let that refresh complete: a re-walk that dies releases nothing. A narrowing or a replaced credential is not repaired by a re-walk: ask about a window that starts after it. For the group being newer than the window there is no repair: Prism does not hold dated membership. Quote the figure, not the zeros, or ask about a window that reaches today

What the never-appeared check costs. It runs only on an answer that asks for participation counts and has somebody who would otherwise be an observed zero, and it looks over the whole table, not only the window. Measured on PostgreSQL 16 with ten million rows per table, it took between a few milliseconds and about half a second for the columns of the prebuilt GitHub, Jira and GitLab streams, depending on how many people it had to check. For pull request reviews, checking five thousand people took about two seconds. Each of those columns has an index for this. A column of a context stream you wrote yourself has none unless your document declares one, and then the check reads the whole column once: two to three seconds at ten million rows, growing with the number of rows. If it does not finish within the statement timeout (mcps.semanticMcp.statementTimeoutSeconds), the answer publishes no participation counts and says why in states_unavailable_reason.

observation.supported: true does not mean nobody is in this count. That field answers a question about the group — may a zero be stated for this source, measure and window at all? — and only the last route above can make it false. The first two are facts about one person and are reported separately, in observation.granularity, which reads person when either applies. An answer can carry supported: true and still hold people this source could never have shown; that is the ordinary shape whenever one member has no identifier of the kind.

What does NOT withhold an observed zero​

The routes above are every way Prism knows to take a person out of observed zero. These are cases where the zero still stands and can be wrong:

  • Somebody who appeared only in repositories that left the credential's reach before the window. An observed zero requires the person to have appeared in the matched column before, and Prism keeps the rows of a repository after the credential stops reaching it. So an appearance there still counts, and somebody whose work since then is only in repositories the credential cannot see is an observed zero with supported: true. Somebody who has never appeared at all is not (the second unknown observation route). Prism can only see what its credential sees. So an answer with an observed zero from GitHub or GitLab says so. zero_reach on the measure gives the credential's reach now, measured on the date it names. That may differ from the reach the window was walked at, and it can be far larger: a widening before Prism first measured the reach is in no record. Beside a zero matched through reviews, the number is left out, because reviews are read only from repositories the credential can open, which can be fewer than it can list. What a GitLab credential can see is not measured, and the answer says that instead of a number. Check what you are told against the repositories your developers work in.
  • A column of another kind of identifier that looks like the declared kind. The fourth unknown identity route judges values by what they look like, so it cannot tell apart kinds that look alike. In a github_login column, Jira Server and Data Center usernames and user keys, older Jira Cloud account ids (24 hexadecimal characters) and GitLab usernames all look like GitHub logins. In an employee_id column, a plain login such as jdoe looks like an id.
  • A kind Prism has no shape for. Any kind other than email, github_login, gitlab_username and employee_id is never judged this way. Those columns still reach the third route when nothing in them matches.
  • Somebody who joined during a window that reaches today. The group is today's, so a person who joined part way through "the last 90 days" is in it for all 90, and one who joined too recently to have done anything yet is an observed zero. Only a window that ended before today is withheld.
  • A change of reach from before you upgraded to 0.15.0. Prism has kept a permanent record of those changes only since 0.15.0. A narrowing or replaced credential whose notice was still showing at the first 0.15.0 refresh is recorded; one whose notice had already cleared is not, and a window it reaches can publish zeros.
  • A change Prism does not detect. What a credential can see is measured for GitHub only. A change smaller than ordinary repository churn is not treated as a change. A refresh that finds your installation's encryption key rotated cannot tell whether the credential is the same one, so it is treated as a replacement: a window straddling that refresh publishes no observed zeros. A change in how many repositories it reaches is still seen. A change during refreshes whose measurement failed is dated when a later refresh detects it. A refresh that is killed while it records a change (a pod eviction or a dropped database connection at the end of the run) records none of it, not even the new reach it measured. So the next refresh compares against the old reach and detects the change again. Before 0.16.0 that refresh could keep the new reach and lose the change, and a change lost that way is not in the record.
  • A re-walk that died before you upgraded to 0.16.0. Before 0.16.0, Prism counted a widening's days as re-walked when a re-walk from that date was started. A re-walk that started and died on an earlier release may already have released those days, and a window over them can publish zeros. If you know of one, run the re-walk again (it runs once per date, so name a day earlier) and let it finish. Since 0.16.0 a re-walk releases a widening's days only when the refresh that carried it out completes. One that dies, or finishes with part of the source failed, leaves them withheld until a re-walk from a new date completes.

Two figures that will disagree with these counts, on purpose​

  • identity.cohort.resolved and unknown_identity will not reconcile, and should not be made to. resolved counts people this source can identify at all; unknown_identity counts people whose work could not be attributed for this measure and window. Somebody can be resolved and still land in unknown_identity — the first, third, fourth and fifth routes above.
  • identity.cohort.ambiguous is not "how many of these counts are ambiguity." It counts everyone holding any ambiguous identifier of the kind, including people who also hold a clean one and were classified on that. Read as a share of the group it overstates contested identity, sometimes badly.

When the counts are there and the percentages are not​

The four counts and the size of the group are measured by two different statements, and Prism compares them rather than assuming they agree: states_reconcile says whether the four add up to the number of people evaluated. Where they do not, every percentage is withheld and the counts are still published.

That is not a fault to report. The statements behind one answer are read without a shared snapshot, and on a running appliance the ingest tick is writing throughout — so a person can be classified who was not in the group when the group was counted, or the other way round. The counts remain true of the people they describe; what cannot be stated is a share, because the two halves of the fraction were measured a moment apart. Ask again and it will normally reconcile.

Percentages are also withheld where nobody was evaluated, and where the size of the group could not be measured at all — in which case the counts are published beside a null size, and population.size_unmeasured_reason says why.

When the counts are not there at all​

When a question about a group is answered and the counts it asked for are absent — not zero — the answer says why in states_unavailable_reason: either the classification did not run, or it ran and its counts were withheld — by your per-person floor, or because a check that tells a real zero from work Prism cannot attribute did not answer: whether the column matches anybody, or how many of the window's rows have a blank column. Asked about no group at all, there is nothing to classify and nothing carries that field. Zeros would be a measurement; their absence is not.

That field's values are not a closed list, and cannot be made into one. Some are fixed: the question named no measure, it named more than one, your per-person floor withheld the counts, or one of those two checks did not answer. But where the classification failed outright the reason is the class of the failure — whatever went wrong, named as your appliance saw it — so no list here could be complete and no pattern could match them all. Treat any value you do not recognise as "the classification did not run, and this is what stopped it", and send it to us with the question that produced it.

Naming the people behind a participation count: a setting you turn on​

Ask about a group and Prism can answer "how many of them have had no pull request activity in the last ninety days" as four counts — observed activity, observed zero, unknown identity, unknown observation. The obvious next question is which people, and on a new installation Prism will not answer it: it gives you the counts and says the names are not available.

Turn it on with disclosure.participationDetail: true, or the environment variable PRISM_PARTICIPATION_DETAIL. There is no control for it on the admin page in this release, so it is set at install or by a helm upgrade, and nowhere else — if you go looking for a switch in the interface you will not find one.

With it on, an answer can list the people in one state alongside the count. The list is capped at 200 people to a page and always carries the total for that state, which is computed over everybody rather than over the page. A page never changes a count: if the list is short, the answer says so and says how many there are altogether, rather than quietly presenting a page as the whole group. The list is ordered by the columns your directory shows, ignoring case first and then by exact spelling, so paging through it one person at a time does not reveal where anyone falls in a hidden identifier such as an employee id. The one exception is people whose shown columns are identical character for character: nothing on the page tells them apart, and they are ordered among themselves by the hidden identifier.

What it does not do — two things, and both are easy to assume it does.

It does not decide what a name is made of. That is the hidden flag on the columns of your own directory, and the two directory shapes Prism ships take opposite views: the roster upload publishes staff names and hides the employee id, while the Snowflake directory recipe publishes the person id and hides the full name. So the same setting lists names on one installation and identifiers on another, because in each case it is listing what your directory says may be shown. The answer also lists, under withheld, every column your directory marks as a name or an identifier of a person and hides — on the roster upload that is the employee id and the email address, among others — so "Prism will not print a name here" and "your directory carries no name" are distinguishable rather than both arriving as silence. It lists the column names only, never their values.

It does not close your directory off. A question that groups by a name column returns those names today and returns them whichever way this is set — that is an ordinary grouped answer and this setting has no bearing on it. What this governs is narrower, and is the part worth a decision: whether an answer may attach a person's name to a statement about what they did or did not do. That is a different act from listing who is in a department, and it is the one your people are likely to have a view about.

What the min-N floor does and does not withhold​

The floor (disclosure.perPersonFloor) reaches the four participation counts, and the names behind them, in fewer cases than its name suggests. Read this before you rely on it to keep a small group from being named.

  • Only on a source that declares distinct-people counts. The floor applies to participation counts only where the source's manifest lists count_distinct in policy.min_n_applies_to. Of the shipped sources a participation question can reach, that is github alone. On the Jira and GitLab sources no participation count is ever floored, so with names turned on a group of one is counted and named at any floor.
  • Only when an observed count is small. Where it applies, it fires when observed activity or observed zero is above zero and below your floor. It then withholds all four counts and every name, and the answer says min_n.
  • Never on the two unknown counts. Unknown identity and unknown observation are not compared with the floor, and with names turned on the people in them are listed at any size.

A wider window can name a group a narrower one withheld. When a window reaches back past what the index holds, nobody's absence can be observed, so everyone who would have been an observed zero is counted as unknown observation instead (see the four participation counts above). Observed zero drops to nothing, and unless observed activity is itself above zero and under the floor, the floor has nothing to fire on and the people are listed under unknown observation. So a floor of 3 that withholds a two-person observed zero over the last ninety days can name the same two people when the question asks about the last year.

The same holds wherever a zero cannot be established, not only for a wide window. Observed zero also drops to nothing when what the source could see changed in a way that reaches the window, when the column the group is matched on holds nothing that matches anybody — including a column that is entirely empty or NULL — or when any of the window's qualifying rows has that column blank. Those people are counted as unknown instead, and with names turned on they are listed on the same terms as above. Where either check of that column does not answer at all, nothing is listed: all four counts and every name are withheld.

The floor is not what keeps names out of answers. An ordinary question that groups by a person column returns those people as row keys at any floor, and a drill-down returns the underlying records. The floor withholds figures, not people. Whether an answer may attach names to a participation state at all is disclosure.participationDetail, and if names beside these counts are not acceptable for your installation, leave it false.

Things Prism refuses on purpose​

These are not faults, and they are the answers most likely to be escalated as faults:

  • No "productivity", "efficiency" or "performance" label over a count. A count of merged pull requests is a count of merged pull requests. Prism will not render a chart headed that way, and flags a table column that is.
  • No claim that spend caused delivery. Cost per merged PR, cost per ticket and spend against output in a chart are all answered — they are what most readers are here for — but always with what they cannot settle stated above them: the comparison is not controlled, one merged PR is not one unit of work, and spend is attributed to the API key that made the call rather than to everyone whose work it took. An answer that gives you the ratio without the limits is a fault worth reporting; so is one that refuses the ratio, which earlier releases did.
  • No inferring who is on a team. Team membership is read from the directory column your administrators maintain, or the question is refused with a request to say who is meant. Prism will not guess a cohort from names, email domains, time zones or repository activity — and that stays true after the directory is populated.
  • No estimating a missing figure. A Jira project that does not fill story points produces no velocity figures for that project, and Prism says the field is absent rather than substituting a proxy.
  • Every team answer says how old the roster is, and says so loudly until somebody confirms it. The Team column is the only record of who belongs to a team, nothing else in the appliance can check it, and it goes stale in silence — so Prism quotes its age the way it quotes index staleness, and flags a roster older than 90 days. On an upgrade this flag starts on for everybody: the date is only recorded from this release forward, so every team already typed reads "this roster's age cannot be established and its membership may be out of date" until it is confirmed once. Clearing it is a one-off pass through the source itself: each row shows when it was last set, and confirm beside it records today's date without changing the value. Editing a team stamps the date too, so after that first pass the flag only returns for rosters nobody has looked at in three months. The 90 days is not settable from the chart in this release.

Compute, and the namespace quota you need​

If your namespace has a ResourceQuota — and the regulated clusters this appliance is usually installed into do — it has to clear more than Prism's steady state. The number that catches people is not the pod count. A quota caps several things independently, so raising pods after a rejection can leave you refused again the next minute on limits.cpu, with a message that looks like the same failure and is not.

The figures below come from the chart itself and are regenerated whenever it changes, so they match the release you are installing rather than the release this page was last edited in.

Steady state — what runs all the time, one pod each unless the count says otherwise.

ComponentPodsCPU requestCPU limitMemory requestMemory limit
App1100m500m256Mi512Mithe chat UI and API
Agent runner1250m1000m512Mi1.5Githe largest single pod
Query servers550m250m128Mi256Mimanagement, GitHub, Jira, semantic, discovery (on by default since 0.16.0) — one block sizes all five
Bundled Postgres1100m2000m256Mi4Gionly when postgres.bundled=true; sized by YOUR data
Total8700m4750m1.6Gi7.2Gi

Jobs, which are extra and can overlap each other. Each of these is one more pod and one more slice of the quota for as long as it runs.

JobCPU requestCPU limitMemory requestMemory limit
Ingest100m500m256Mi512Mithe tick CronJob, one “ingest now” Job, and one incremental CronJob per live source
Synthetic seed100m500m256Mi512Mipost-install/post-upgrade hook, plus an optional CronJob

Three numbers, and only the largest one sizes a quota​

The table above gives two totals, and a running install shows a third. They span a factor of thirty, and choosing between them is the whole of this section:

NumberCPUMemoryWhere you meet it
What it uses — idle, synthetic sources, measured on a 0.13.0 install~150mfar under the ceilings; Postgres sat at 61Mi of its 4Gikubectl top pods
What it reserves — the request columns above650m1.5Githe scheduler, and requests.cpu / requests.memory in a quota
What it may not exceed — the limit columns above4500m7Giadmission, and limits.cpu / limits.memory in a quota

A ResourceQuota on limits.cpu and limits.memory has to permit the third row: 4,500m and 7Gi, before anything you add for the Jobs below. A quota on limits counts every container's ceiling whether or not the container ever approaches it, so an install that will sit at a thirtieth of that figure is refused without the whole of it.

Nothing you can watch tells you this. Usage says 150m and the scheduler says 650m; both are the wrong number to put in the quota, and the right one is the only one that never appears while the install runs.

The largest single line is the bundled Postgres, at 2000m and 4Gi — on its own, 44% of the CPU ceiling and 57% of the memory ceiling. It is also the one whose limits exist to contain a runaway rather than to describe a size, so read The bundled Postgres below before sizing around it.

If you run Postgres outside the cluster (postgres.bundled=false) that row does not apply — the chart renders no such pod. The steady-state totals become 550m / 1.25Gi of requests and 2,500m / 3Gi of limits, which is the shape of a Prism install against a managed Postgres server.

Where this section and the table disagree, the table is right. Its totals are generated from the chart on every change; the figures repeated in the prose here — 4,500m, 7Gi, and everything derived from them — are typed, so a release that moves a limit moves the table and leaves these behind. Add the columns up yourself if the two ever differ.

All three figures above are steady state. An install needs one Job slot on top — the synthetic-seed hook described below runs on every install, not only on upgrades, so the working floor for a fresh default install is 5,000m / 7.5Gi, or 3,000m / 3.5Gi against an external Postgres. An upgrade needs more again.

Size for the upgrade, not for the steady state​

An upgrade is the peak, and it is roughly double. The synthetic-seed hook runs on every install and every upgrade, and it fires while the ingest jobs are still going — so the jobs above overlap each other and the steady state at the same time. A quota sized to the steady total installs cleanly and then refuses pods halfway through your next upgrade, which is the worst moment to discover it.

Add up the steady total, then add one slot for the seed hook and one for each ingest job that can be running: the tick, an "ingest now" you may have started, and one incremental job per live context stream. A quota with room for four concurrent jobs on top of the steady state will not need revisiting for a while, which is the point — you want to size this once, not once per release.

Set the pod count with the same slack. Pods and CPU are separate limits and either one refuses on its own.

What a rejection actually looks like​

At the helm command line it looks like nothing in particular:

Error: INSTALLATION FAILED: failed post-install

That is the whole message. It names no quota, no resource and no pod, and it is the same sentence any failed post-install hook produces — so a sizing problem arrives looking like a broken cluster. If you have that line and you are reading this page, this is where to look next; the cause is in the namespace, not in the helm output:

kubectl get pods -n NAMESPACE # Pending pods, or a Job that never completes
kubectl get events -n NAMESPACE --sort-by=.lastTimestamp | tail -30

Two quota failures show up there, and only the first is about pods:

Error creating: pods "prism-app-..." is forbidden: exceeded quota:
requested: pods=1, used: pods=10, limited: pods=10

Error creating: pods "prism-synth-seed-..." is forbidden: exceeded quota:
requested: limits.cpu=500m, used: limits.cpu=3750m, limited: limits.cpu=4

The second is the one to watch for. It retries, so you see it repeatedly for the same Job, and the pod count is nowhere in it.

A third message says the opposite of what it appears to say. Where there is no quota but the node pool is full, the pods sit Pending with:

0/2 nodes are available: 2 Insufficient cpu.
NotTriggerScaleUp: Pod didn't trigger scale-up

— while kubectl top nodes shows those same nodes at around 10% CPU. Both are true at once: the scheduler counts requests, never usage, so a node whose pods have reserved their CPU and are not using it is full. Insufficient cpu on an idle cluster is not a bug and not a lie.

The two causes are told apart by which of these messages you find, never by the helm line, which is identical for both. A quota shortfall is refused at admission and names exceeded quota; a full node pool is refused at scheduling and names Insufficient cpu. Neither is caused by the release you are installing, and the fix is different in each case: raise the quota, or free the requests other workloads are holding.

The bundled Postgres: a ceiling to contain a runaway, not a size​

Every other component's appetite is set by our code. Postgres's is set by yours — how much you ingest, how many people ask questions, how large the answers are. So its two numbers mean different things and should be read differently:

  • The requests are a floor. They say what the scheduler must find before the pod starts, and they are small because a datapond at rest is small.
  • The limits are a ceiling, and they are deliberately far above any size we would recommend. A memory limit is what the kernel kills the pod for exceeding, and this is the one container whose working set we cannot know from here. The figure is there to stop a runaway taking the node with it, not to express a view about how large your database should be.

Two things make that ceiling safe whatever your data volume, which is the point — neither depends on how much you have ingested. Postgres here runs stock tuning (shared_buffers 128MB, maintenance_work_mem 64MB, work_mem 4MB), so the server's own allocations are bounded well below the limit and a sort too large for work_mem spills to disk rather than to memory. And page cache counts toward the limit but is reclaimed before the kernel's OOM killer runs, so a datapond busy enough to fill it is not killed for caching.

New in 0.13.0, and worth checking before you upgrade. Before this release the bundled Postgres declared no limits at all, so on a namespace with no LimitRange it ran unbounded. It now takes the chart's. If your datapond is large, or your namespace supplied a higher default, raise postgres.resources.limits in your values file — or remove the ceiling entirely:

--set postgres.resources.limits=null

That exact spelling. Overriding postgres.resources wholesale does not work: Helm merges maps, so the chart's limits merge back in underneath your block.

If you go the other way and cap the datapond hard to fit a quota, know that the pod's readiness probe runs pg_isready inside that cap: at 50m CPU a measured install needed the probe's postgres.readinessProbe.timeoutSeconds (5 by default since 0.14.0) to become Ready at all, and a tighter cap may need more. The troubleshooting page describes what a server that is up behind a probe that is not looks like.

If queries slow down as the volume grows, raise postgres.resources before you conclude anything about the query servers. The Disk section sizes the volume; this sizes the process that reads it.

If you run Postgres outside the cluster (postgres.bundled=false), drop its row from every total above — the chart renders no such pod.

Memory: what raising the roster upload costs​

app.rosterUploadMaxMiB is the one installer-facing value here that moves a memory requirement rather than following one. The upload is held in memory while it is parsed, so raising it and leaving app.resources.limits.memory alone converts a refused upload into a killed pod — which reads as the app restarting for no reason. parameters.md carries both keys; move them together.

Disk​

The bundled Postgres gets a single 10 GiB volume (postgres.storage), and it holds everything: the ingested pull requests, issues and reviews, the agent runtime's own state, and the record of every question asked and answer given.

Nothing is pruned. There is no retention policy in this release — the volume only grows, and a volume that fills is a database that stops accepting writes, which surfaces as whatever the next write happens to be rather than as a disk fault.

Two exceptions, and both are deliberately tiny. runner_conversations — the app's note of which chat session each person's conversation is, so that restarting Prism does not end a conversation somebody is in the middle of — keeps at most 64 conversations per person and forgets one nobody has returned to for 30 days. Each row is a session id and two timestamps: no questions, no answers. So what a forgotten row costs is the continuity of a conversation nobody came back to, and nothing anybody said in it — the transcript lives with the agent runtime and is not touched by this.

client_notes is the second, and it is smaller still: one row on the rare turn where a browser tab reports that what it showed differed from what Prism served — because the tab had been left open across an upgrade, for instance. It keeps the 200 most recent per person, which is what diagnosing such an episode needs and several times more than one ever produces; each row is two version ids and the heading of a chart that was not drawn. Both are bounded on the write, so neither needs an operator to do anything, and every figure above still describes every other table.

That record of questions and answers is now readable in the product, not just present in the table: every answer has a permalink, a reader can browse their own history, and a super admin can browse everyone's. Nothing extra is collected — what changed is who can reach it, which is worth knowing before somebody asks. Finding an answer again states the access rules and what deleting an answer would take.

The self test now watches it. On a bundled install the datapond volume line sums pg_database_size() over every database in the instance — the datapond and the agent runtime's own database sit on the same PVC — and reports it against postgres.storage:

UsedVerdict
under 75% of usablepass — 2.1 GiB of 10.0 GiB · 26% of usable space
75% or morewarn — 6.2 GiB of 10.0 GiB · 77% of usable space · nothing prunes it
90% or morefail

"Usable" is postgres.storage minus 2 GiB, and the two numbers on the line are the raw ones you can check against kubectl get pvc. The subtraction is there because pg_database_size() counts database files and the volume carries more than those: the write-ahead log (1–1.5 GiB after a checkpoint on the bundled image's stock settings), temporary files, and the filesystem's own metadata and reserved blocks. On the 10 GiB default that leaves about 8 GiB of room for database files — so a percentage of the raw 10 GiB could never get past about 80%, and a failure verdict would never once fire on the install the default ships. It is the same 2 GiB the sizing formula below adds on top of the per-row costs: one piece of arithmetic, so the question counts this page quotes are the counts at which the line actually changes colour.

Two further things about the reading:

  • The capacity is what postgres.storage says, not what the PVC actually is. Nothing inside the appliance can measure the volume: it is mounted in the Postgres pod rather than the app's, Postgres exposes no free-space function to SQL, and Prism has no Kubernetes API access by design. So the number that provisioned the PVC is passed in as a declaration. If you expand the volume, raise postgres.storage to match or the warning goes on firing against the old number.
  • The warning is early on purpose. Expanding a PVC is a scheduled change, not something anyone does at 3 a.m., so the useful signal arrives with weeks in hand rather than hours.

If you pointed Prism at your own Postgres instead (postgres.bundled=false), the disk is yours and so is the instance: the line reports the size of Prism's own database only and says the volume size is not known here, because it isn't. It deliberately does not sum the instance there — on a shared managed server that would report your other databases' bytes as Prism's, and put their names into the diagnostic bundle. The same absence of pruning applies, and the sizing below is the same arithmetic.

How big should the volume be?​

data GiB ≈ pull requests indexed × 0.6 KiB ← reviews and indexes included
+ Jira issues indexed × 0.4 KiB
+ questions asked × 95 KiB ← the one that does not stop growing

volume GiB ≳ 2 + data ÷ 0.75

The 2 is the reserve the self test subtracts — WAL, temporary files, filesystem overhead, system databases — and the ÷ 0.75 is what keeps the reading below the warning threshold. Round up from what it gives you: that is the size at which the warning is about to arrive, not the size at which it is comfortable.

The shape of that is the useful part: history is cheap and conversation is not. A question costs about 150 times what a pull request costs, because almost all of it is the agent runtime's own record of the run — the reasoning steps and tool results behind the answer — rather than the answer itself.

Per-row costs, measured (see provenance below), including that row's share of the indexes:

What one row costsBytes
A pull request~410 B
A review on one~360 B (measured 0.62 reviews per pull request)
A Jira issue~390 B
A support ticket~510 B
A day of Router spend, per user and model~600 B — negligible at any scale
A question asked and answered~95 KiB, of which ~3 KiB is the app's own record and ~92 KiB is agent-runtime state
A feedback report, text only~5 KiB — it copies the question, the answer and the answer's provenance record
An attachment on oneits own size, up to 5 MiB — capped at 3 files per report

Worked through:

InstallIndexChatVolume to provision
5,000 pull requests, 5,000 questions3 MiB0.45 GiB~2.6 GiB
50,000 pull requests, 20,000 questions30 MiB1.8 GiB~4.5 GiB
100,000 pull requests, 100,000 questions60 MiB9.1 GiB~14 GiB — too big for the default

Feedback (Feedback on an answer) is the one row above that a reader controls the size of, so it is worth knowing the ceiling: an attachment is capped at 5 MiB and a report at three of them, so the worst case is 15 MiB per report and the realistic case is a screenshot or two. Six hundred reports each carrying a full-size screenshot would be about a gigabyte; a normal evaluation is a few dozen reports and a few tens of megabytes. Nothing prunes them either — a report is evidence about an answer, and deleting it silently would be worse than the space.

Read the other way: on the 10 GiB default the warning arrives at roughly 66,000 questions asked and the failure at roughly 79,000, whatever backfillDays is set to. (Those are 75% and 90% of the 8 GiB usable under a 10 GiB volume, at 95 KiB a question.) Deep history is not what fills this volume: ten years of an active organisation's pull requests is tens of megabytes. Sustained chat is.

At twenty questions a working day that is a decade; at two hundred a day it is about a year. Size for the second case if the whole team will be asking, and plan on expanding the volume rather than on the product reclaiming space — nothing in this release does.

Re-running the measurement on your own data​

The figures above come from one deployment (below). Yours will differ — longer answers and more tool calls per question move the per-question number most — so measure rather than assume once you have a few weeks of real use:

# bytes per row, per table, in each database on the volume
kubectl exec -n <namespace> statefulset/<release>-postgres -- \
psql -U postgres -d prism -c "
SELECT relname, n_live_tup AS rows,
pg_total_relation_size(relid) AS bytes,
pg_total_relation_size(relid) / NULLIF(n_live_tup, 0) AS bytes_per_row
FROM pg_stat_user_tables ORDER BY bytes DESC;"

# what one question costs end to end: the runtime's state divided by runs
kubectl exec -n <namespace> statefulset/<release>-postgres -- \
psql -U postgres -d runner -c "
SELECT (pg_database_size(current_database()) - 8*1024*1024)
/ NULLIF((SELECT count(*) FROM runs), 0) AS bytes_per_question;"

The 8 MiB subtracted is what an empty database costs before anything is in it. Add the app's own row cost from the first query (app_events) for the total.

What the trace cap does to the per-question figure. ~92 KiB of the ~95 KiB above is the agent runtime's record of the run, and how much of each tool result that record keeps is agentRunner.tracePayloadMax — 100,000 characters by default. The deployment the figure was measured on had already been raised to that same 100,000 two days before the measurement, so the number above is the one that applies to a stock install, not a number taken under a smaller cap that this default would inflate. It is if anything conservative: some of the 287 questions were asked before that raise, so the mean is a blend and the true figure at 100,000 is a little higher than 95 KiB — comfortably inside the factor-of-two caveat below.

Lowering the cap lowers this cost roughly in proportion for tool-heavy questions, at the price of the provenance footer reporting a clip on most answers. That is a real trade and it is the reason the parameter exists; see Finding an answer again for what the reader sees when it is set too low.

Provenance. Measured 2026-07-30 on the Prism development deployment, Postgres 16: one GitHub organisation's index of 26,268 pull requests and 16,244 reviews, 737 Jira issues, and 287 questions asked through the chat. The per-question figure is a mean over those 287 — the right statistic for sizing, since what you accumulate is a total — but the spread is wide: the median question recorded 48 runtime events and the busiest recorded 594, so a tool-heavy question costs several times a simple one. The index figures come from tens of thousands of rows and are firm; treat the per-question figure as good to about a factor of two until you have measured your own.

Summary​

CeilingValueDoes the product tell you?
Any one ingest runkilled at 24 h, ingest.runDeadlineSeconds (~4.5 years, one organisation)Yes — failed Job, and partial coverage is labelled
History reachableingest.backfillDays, by last-updated; GitHub issues at least 365 days under ingest.engine.mode: authoritative, and ingest.backfillDays where that is deeperYes — coverage start is reported per lane, unverified when unknown
PRs per organisation per calendar day1,000Yes — the day is recorded, answers over it are labelled a floor, and the self test names it
Data freshness~7 h worst case (6-hourly default)Yes — answers past coverage are labelled
An ingest that stops advancing4 × its own schedule (ingest.stallAfterIntervals)Yes — a failed source line on the self test (both sources), and a failed Job (GitHub)
Spend historyyour Router's retention (ours: 232 days and no purge yet, measured 5 Aug 2026)Partly — the window queried is always stated
Concurrent questions4No — the fifth waits
Datapond connections the app holds10 (app.datapondPoolSize)Yes — over it, a request waits 10 s and is then refused with a 503; the message distinguishes a full pool from a database that did not answer
Questions per person per hour60 (app.limits.turnsPerHour)Yes — refused with a 429 and recorded; in the browser it is one "connection closed" message for every refusal and cut, which says asking again may not help
What one question costsnoneNo — the agent runs until it is finished; the hourly count is the only bound on the bill
Answer durationnone from Prism; your ingress decides (longest measured turn 6 min 11 s)No — set ingress idle timeout ≥ 10 min
Conversation memorysummarised past ~48k tokensPartly — ungrounded recall is flagged
Live pages / PR details per question3 / 12Yes — refused, naming the alternative
Disk10 GiB (postgres.storage), never prunedYes — the self test warns at 75% of usable space and fails at 90%
Context streams licensedsources.entitlement.contextStreams (default 10)Yes — stated beside the count enabled, on the admin page and the self test. Never enforced: over the number, everything keeps working

Three rows used to be bold — the 1,000-a-day cap, an ingest that stops advancing, and the disk — and all three are detected as of this release. Detection is not prevention: a capped day is now recorded and every answer over it labelled a floor, and the pull requests it dropped are still lost.

One row is bold, and it is the cost of a single question. There is no ceiling on it in this release: the agent calls tools and reasons until it is finished, and nothing counts or caps what that costs. The hourly limit above bounds how many such questions one person can start, which bounds a runaway loop and not a bill — 60 × unbounded is still unbounded. The per-question ceiling belongs to the agent runtime and is being added there; until it lands, your Router account's own budgeting is the backstop, and it is worth having one.