Skip to main content

Take a source live

Each source — GitHub, Jira, Router spend — flips from synthetic to live independently, as its access lands. This is that flip, end to end.

It is two helm upgrades, not one, and the order is the whole point of the procedure. The first live ingest for a source truncates that source's synthetic rows before writing real ones, so a credential that authenticates cleanly but sees nothing leaves you with an empty source where you previously had a working demonstration — and from the chat, an empty source is indistinguishable from a broken product. The first upgrade proves the credential; the second one flips.

The worked example below is GitHub. Jira and Router spend differ only in which values they set, and both are called out at each step.

1. Put the credential where the chart will read it​

SourceChart valueSecret key
GitHubsecrets.githubTokengithub-token
Jirasecrets.jiraTokenjira-token
Router spendsecrets.valetApiKeyvalet-api-key

With a chart-managed Secret that is a --set on the upgrade below. With existingSecret the chart renders no Secret and cannot add a key to yours, so patch it yourself first:

kubectl patch secret <your secret> -n <namespace> \
-p '{"stringData":{"github-token":"<the token>"}}'

Do this before step 2 on an existingSecret install. The probe reads that key, and a key that is absent when the pod is created is reported as no_token rather than found later.

2. Name the target, and do not flip yet​

helm upgrade <release> <chart> -n <namespace> -f <your values file> \
--set ingest.github.orgs=<orgs>

ingest.github.orgs is a comma list, and one credential is read for all of it — which is a constraint on the token rather than on the chart. A fine-grained personal access token can only reach resources owned by a single organisation, so an install naming two of them needs a classic PAT that is a member of both, or one Prism per organisation. What the GitHub credential needs is the rest of that list, and is worth reading before the token is minted rather than after the pre-flight says it saw nothing.

Jira sets ingest.jira.url instead. Router spend names its target in mcps.management.valetServiceUrl — the Agent Router management plane, a different address from the inference gateway in router.proxyUrl, and the one your admin key belongs to. Set it in step 1 alongside secrets.valetApiKey: until 0.8.0 it carried a default that was Tetrate's own Router, which meant a flip appeared to work and mirrored nothing. It ships empty now, and the flip below refuses to render without it.

Note what is not in that command: sources.github=live. The source keeps serving synthetic data through this upgrade.

Decide the request rate here too, if your instance is one you would rather be careful with. Prism calls each source at a ceiling you set, in requests per minute — ingest.github.maxRequestsPerMinute and ingest.jira.maxRequestsPerMinute, both 30 by default, one request every two seconds. That default is chosen for an upstream nobody has measured yet, which is the normal case for a self-hosted GitHub Enterprise Server or Jira Data Center. Lower it if your platform team wants to start smaller:

--set ingest.github.maxRequestsPerMinute=10

The cap covers retries as well as first attempts, so an ingest being rate-limited by your instance cannot answer by calling it faster, and the only cost of a low number is a longer run — nothing is skipped. Raise it later with an ordinary helm upgrade; nothing needs re-ingesting. The rate in force is the first line of every ingest log (step 5), and limits.md has the detail.

The rate Prism is actually calling at — as opposed to the ceiling you just set — is on What Prism is pulling, right now, live, with no shell needed. Open it before step 5 and leave it open; it is the page to have up while the first backfill runs, and the page to send anyone who asks what is calling their instance.

3. Read the verdict​

That upgrade runs prism-ingest check github as a hook, against the credential and target the ingest itself would use, and the verdict lands on the self test beside a source still serving synthetic data:

PASS github synthetic · 26,029 rows · seeded 6d ago · upstream ready to go live (1m ago)
WARN github synthetic · 26,029 rows · seeded 6d ago · upstream NOT ready:
org_visible_but_no_prs_found (1m ago) — going live now would
evict the rows above and ingest nothing

Read the amber line as "do not flip yet". Nothing is broken — the source is serving what it is meant to serve on day one — and the flip is the step that would turn a wrong credential into an empty source. Fix the credential, upgrade again to re-run the probe, and only go on when the line reads green.

On an existingSecret install there is no pre-flight for Router spend: the chart reads that signal from secrets.valetApiKey and cannot see inside a Secret it does not own. Nothing is at risk in flipping spend without it — the spend index is its own table, so a sweep that ingests nothing evicts nothing. GitHub and Jira are unaffected, because their targets are values.

4. Flip​

helm upgrade <release> <chart> -n <namespace> -f <your values file> \
--set ingest.github.orgs=<orgs> --set sources.github=live

The other two:

--set ingest.jira.url=<your Jira> --set sources.jira=live
--set mcps.management.valetServiceUrl=<your Router management URL> \
--set sources.spend=indexed

There is no sources.directory any more. Prism kept its own table of people — who exists, which GitHub login belongs to which Jira assignee, which team somebody is on — and it is retired. What replaces it is a data source you connect like any other and then designate as your organizational context stream, on Admin → Context streams. See One stream must carry your org directory for what that changes in the answers; the short version is that until you designate one, Prism will say a person cannot be resolved rather than give you a figure it cannot stand behind.

sources.spend takes indexed or live, and indexed is almost always the one to want; step 7 of the install has the reasoning.

Spell the mode exactly, in lower case. Anything else — Live, LIVE, a typo — fails the helm upgrade immediately, naming the key and what you typed. That is deliberate: an unrecognised mode used to install cleanly and start no ingest at all, while the app served the mirror the ingest was meant to fill, so the source went empty and stayed empty.

There is a second, unrelated flip, and it is worth not confusing the two. This one takes a source from synthetic data to your real system. The other moves a live source from its legacy lane onto the manifest ingest engine, and is Flip a source to the ingest engine.

5. Watch the first backfill, and do not read the prompt as "done"​

helm upgrade returns as soon as the backfill has started. The first run walks ingest.backfillDays (90 by default) of history for every org or project you named — over an hour, measured at 77 minutes for one GitHub org (8,965 pull requests and 14,386 issues, walked together) — so expect it to still be running long after the command comes back clean. A larger org takes longer; a backfill still going after an hour is normal, not stuck.

In ingest.engine.mode: authoritative, add roughly 9–18 minutes per GitHub organisation at the default 90 days: issues reach back at least 365 days there whatever ingest.backfillDays says, and each day past the pull-request window costs one issue-search request per 100 issues updated that day — one or two at the density the figure above was measured at — and nothing else. At ingest.backfillDays of 365 or more there is nothing to add — the floor is not a cap, so both lanes walk the same window and the figure above already covers it.

kubectl get jobs -n <namespace> -l app.kubernetes.io/component=github-ingest
kubectl logs -n <namespace> -l app.kubernetes.io/component=github-ingest -f

It prints a line per day bucket, so progress is visible rather than inferred. What Prism is pulling, right now shows the same progress without a shell — how far through the window the backfill is, the rate it is running at, and how your instance is answering — and it is what to watch if the people on the call do not have kubectl. Admin → Context streams shows the row count filling in, and the self test reports the source as populated once it has. A source still showing zero rows after that Job has finished is a credential or egress problem, not a slow backfill — which is the state step 3 exists to have caught already.

Two things that look wrong and are not: re-running an identical flip command does nothing (the Job is named after its own configuration, so Helm no-ops rather than restarting a running backfill), and a second Job completing in seconds is the CronJob firing mid-backfill, finding the source already claimed, and exiting successfully rather than racing it.

6. While the backfill runs: what the source can and cannot answer​

The flip empties the source's table before it refills it — the reason this page is two upgrades rather than one — and the refill is oldest day first. So between the flip and the moment that Job finishes, the source holds the start of your history and none of the recent end. On a low request rate that window is hours; on a very low one it is days.

That is a supported state, and Prism labels it. While the first backfill is unfinished, every answer from this source reports the day it has actually reached rather than the moment its rows were written, so a question about last month comes back marked as running past coverage — a floor, with the uncovered part named — instead of a confident figure over a table that does not hold the data yet. Nothing needs turning off, and the mirror is never a mix of synthetic and real rows.

Two levers if the wait is too long, and they are the only two worth pulling:

  • Raise ingest.github.maxRequestsPerMinute, once your platform team can tell you what the instance will actually take. Backfill time is very nearly inversely proportional to it.

  • Lower ingest.backfillDays for the first pass — 30 days of history arrives in a third of the time — and widen the window later with ingest.<source>.rewalkFrom, below. Raising ingest.backfillDays after this first backfill has finished does nothing: it is read only where a source has no history at all.

    In ingest.engine.mode: authoritative lowering this lever moves the pull request walk only. On a source the engine has not ingested before, GitHub issues reach back at least 365 days however low you set it, so a 7-day first pass still walks a year of issues — roughly 12-24 minutes per organisation at the default request cap, the top of that range at the density the figures above were measured at. On a source the legacy lane has already walked, the engine reads that lane's recorded issue horizon out of the shared ingest_state row and the floor does not apply; ingest.<source>.rewalkFrom is the lever that recovers the year. Raising it past 365 is the other story: the floor is not a cap, so both lanes then walk the whole window and the issue lane costs a full bucket per day, not one request. See Issues reach back a year.

Do not shorten ingest.runDeadlineSeconds to make an interrupted backfill resume sooner. It is the one change that can stop a slow backfill converging altogether: progress is recorded per completed day bucket, so a run killed before it finishes one records nothing, and the run after it reads that as a first run — truncating and starting over, every firing. (The ingest warns in its log where a busy bucket will not fit, and refuses to start where not even an empty one would, rather than looping silently — but stay well clear of the edge. The Jira lane records its progress per page of search results and refuses a deadline too short to record one, in exactly the same way.) If the aim is to resume sooner after a kill, tighten ingest.github.schedule instead — that shortens the idle gap without shortening the window a bucket has to finish in.

Do not add an org to ingest.github.orgs while the first backfill is in flight. An interrupted run records which orgs it covered, and a resume point cannot vouch for ground it never walked — so a run that wants a wider list correctly refuses to resume, and starts the whole backfill again from empty. Wait for the Job to finish, then add the org.

What the GitHub credential needs​

This is the list to hand your GitHub admin before the token is minted. Every item on it is a way for a credential to authenticate cleanly and then either empty the source or quietly undercount it, which is the failure this whole procedure exists to catch — arrived at from the one direction the checks above do not cover.

Prism does not care which kind of token it is. The ingest sends Authorization: Bearer <the token>, which classic and fine-grained personal access tokens both accept. What differs is what you have to ask for.

A GitHub App is the third kind, and it is configured differently — three values rather than a token, because Prism mints the token itself. The permission list below is the one to ask for either way; Widen a source's scope has the procedure. Prefer it wherever the choice is open: an installation token's scope is the App's grant rather than a person's visibility, it carries no user seat, and it gets higher rate limits. A fine-grained token scoped to All repositories is a perfectly good credential for an organisation of any size, and is the simpler place to start.

Classic PAT: repo + read:org, and SSO-authorised for the org wherever SAML is enforced. repo is the part to be uneasy about — GitHub has no read-only private-repo scope, so it grants write as well. Prism never uses it; if your organisation is minting for least privilege, a fine-grained token is the answer, and the four notes below are the price of it.

Fine-grained PAT:

On the tokenWhat to ask for
Resource ownerthe organisation you are measuring — one per token
Repository accessAll repositories
Repository permissionsPull requests: Read · Issues: Read · Metadata: Read
Repository permissions, if mcps.githubLive.enabledthe three above plus Contents: Read · Checks: Read · Commit statuses: Read

The first row is the whole list for the ingest, which is what this procedure is about: no Contents, nothing at organisation level, and no write of any kind. What the ingest stores is pull-request and issue metadata — authors, timestamps, states, labels, review outcomes, and the added/deleted line counts — never a diff, and never the body of a comment or a review.

The optional GitHub Live MCP reads the same github-token, so it widens that one list rather than getting a list of its own. It exists to open one named pull request on request, and its detail tool can return that PR's diff, its changed files, its comments and its CI checks — so it needs more than the three permissions above, and it changes the answer to "does Prism read our code". It is off by default (mcps.githubLive.enabled) and is not in every deployment.

If you are turning it on, add three grants to the token you are already minting — Contents: Read (the diff and changed-files views), Checks: Read and Commit statuses: Read (the CI-checks view; these are two separate GitHub permissions and the CI aspects fail with Resource not accessible by personal access token without them). Still nothing at organisation level, and still no write.

Why one list and not two. Every deployment Prism ships wires one credential to both halves: the chart's ingest Jobs and the live MCP both read secret key github-token, compose gives both ${GITHUB_TOKEN}, and the Cloud Run scripts mount one secret into all three consumers. So a grant list written for the live MCP alone would drop Issues: Read, and the ingest's issue half would authenticate cleanly and ingest nothing — the exact silent failure this procedure exists to catch. Giving the live MCP a credential of its own is real work on the chart, not a wording change; it is tracked (prism #572) and a release note will say when it lands. Until then, the union above is the honest ask.

Issue this one fine-grained even if you issue nothing else fine-grained. A classic token cannot read a private repository without the repo scope, and repo also grants write — push, merge, branch deletion — so the live MCP would be holding a credential that can change your source code in order to read a pull request, with a single setting inside the image (GITHUB_READ_ONLY=1, baked in) as the only thing between the two. A fine-grained token has no write to withhold.

1. One resource owner per token. A fine-grained token reaches resources owned by a single user or organisation — that is the design, not a setting. ingest.github.orgs is a comma list and the ingest reads one credential for all of it, so an install measuring two organisations cannot be done with one fine-grained token. A classic PAT on an account that can see both, or one Prism per organisation, are the two ways out. Decide it before minting rather than relying on the pre-flight: what step 3 reports for the second organisation depends on how visible it is to a token that does not own it — org_not_visible where it is private, org_visible_but_no_prs_found where the organisation page is readable but its pull requests are not, and nothing at all where it has public repositories, because their pull requests are found and the check goes green on a fraction of the org.

2. "All repositories", not "Only select repositories". This is the nastier one, because it does not fail. A token scoped to a subset of repositories walks successfully and ingests that subset; the pre-flight passes, the self test is green, and every org-wide figure is a subtotal wearing the label org-wide (every repo in the index). Nothing anywhere names the repositories that were never fetched. If your organisation insists on an allowlist it has to list every repository you intend the figures to cover — and somebody has to remember it when a new one is created.

3. Three repository permissions — six with the live MCP — and nothing at organisation level. Pull requests: Read is the core grant — the PR list, the sizes, and the review timestamps behind time-to-first-review. Issues: Read is the second and it is not optional: Prism ingests issues as well as pull requests, and search returns only what the token can see. GitHub does not document which grant surfaces a private issue in search, so treat this as the one to prove rather than assume — run the third curl below a second time with type:issue in place of type:pr, and expect a count from both. Metadata: Read is GitHub's mandatory baseline, selected for you as soon as you pick any other repository permission. No organisation permission is needed, which is worth saying because it is the natural thing to reach for: the one organisation-scoped call the ingest and the pre-flight make is GET /orgs/<org>, and GitHub documents that endpoint as requiring no fine-grained permission at all. A 404 there is a question about approval or visibility — note 4 — and no organisation grant will fix it.

4. Organisation policy, and an owner's approval. Two switches sit above the token and neither is yours: the organisation has to permit fine-grained tokens at all (it can block them, and on GitHub Enterprise Server an enterprise-level policy sits above that), and where the organisation requires approval an owner has to approve this specific token. An unapproved token is not a rejected one — it authenticates, and keeps the read-only access to public resources that every fine-grained token carries. What that looks like depends on your organisation, and neither shape is good:

  • All repositories private, which is most Enterprise Server installs. The token sees nothing, and the pre-flight says org_not_visible or org_visible_but_no_prs_found — exactly what a firewall or a repo-scope problem says. Check the organisation's personal access token settings before you go to the network team; it is much the cheaper of the two to check.
  • Some repositories public. Worse, because it goes green: the token can see those, the search finds their PRs, the pre-flight passes, and you flip a source that will mirror the public half of your organisation and label it org-wide. That is note 2 again, reached by a different route.

GraphQL is a second hop, and the pre-flight does not gate on it. The ingest makes a GraphQL call per day bucket for PR sizes, reviews and issue-to-PR links — a different endpoint from the REST search, which is why verifying by hand asserts it separately in step 4. Step 3 of this procedure records that hop and then decides without it, so a credential that passes the REST assertions and cannot reach /graphql is still reported ready to flip. Run step 4 below with the token you are about to install, not with your own. Token type is not the usual reason it fails — fine-grained tokens could not call GraphQL when they launched, but GitHub added that in April 2023 and Enterprise Server did not offer fine-grained tokens until after it, so on any instance that can mint one the answer is an instance or proxy blocking /graphql, not the token. A source flipped on without that hop still ingests — the run leaves those columns null on the days it could not enrich, writes down which days those were, and finishes. Step 4 below says how to read that back.

Expiry. A fine-grained token's lifetime is chosen when it is minted. GitHub allows a non-expiring one, but your organisation or enterprise can forbid that and can cap the maximum well under a year — so find out which you have been given rather than assuming. If it expires, record the date and name the person who owns the rotation at go-live, while somebody is thinking about it: the scheduled probe does report an expired credential, but it reports it on the day it stops working, and a token nobody owns is a token nobody renews. Rotate a credential is that job.

Verifying by hand, where there is no cluster yet​

Before the first install there is nothing to run a hook, and the same three assertions are worth making from a pod in the target namespace. These are the ones the probe makes, in the same order:

GitHub. From a pod in the namespace, with the token you are about to install:

# 1. Does the credential authenticate, and what is it actually granted?
curl -sS -D- -o /dev/null -H "Authorization: Bearer <token>" \
"<your GitHub API URL>/rate_limit" # 200, and read the x-oauth-scopes header

# 2. Is the org visible to it?
curl -sS -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer <token>" \
"<your GitHub API URL>/orgs/<org>" # 200

# 3. The assertion that matters: are that org's PRs findable?
curl -sS -H "Authorization: Bearer <token>" \
"<your GitHub API URL>/search/issues?q=org:<org>+type:pr&per_page=1" # total_count > 0

# 4. GraphQL is a separate hop, and GHE can serve REST while blocking it
curl -sS -o /dev/null -w '%{http_code}\n' -X POST -H "Authorization: Bearer <token>" \
-d '{"query":"{ __typename }"}' "<your GitHub GraphQL URL>" # 200

Step 3 is the one to care about. A token can authenticate, see the org, and still return total_count: 0 — and that is the case that empties your source. Do not settle for step 1 because the scopes look right, and note that a fine-grained token returns no x-oauth-scopes header at all: absent grants and an absent header are the same reading, which is another reason the assertion that counts is the search. What that token should have been granted, either kind, is above. Step 4 failing is narrower but not harmless: PR counts keep working and every PR size lands null, along with reviews and issue-to-PR links. Step 3 does not gate on this hop, so a credential that passes the pre-flight can still fail here — which is why it is worth running with the token you are about to install rather than with your own.

The signal that says it is happening. The ingest does not fail on a dead /graphql: it abandons that day's enrichment, carries on, and finishes. What it leaves behind is the record — the run log carries a WARNING naming the buckets, and the durable list is unenriched_days in the github row of ingest_state:

psql "<your datapond URL>" -c \
"SELECT detail->'unenriched_days' FROM ingest_state WHERE source = 'github'"

Three readings, and they are different:

valuemeaning
absentthis mirror was built before the record existed, or by a source that keeps none
[]the ingest ran and enriched every day it walked
entriesthose org/day buckets have their pull requests and nothing else

An entry names its reason, and clears itself: fix the hop, re-run the ingest over those days, and the bucket that comes back complete drops out of the list. Nothing else removes an entry, so a list that stays full after a re-run means the re-run did not reach /graphql either.

On GitHub Enterprise Server the API base is https://<host>/api/v3 and GraphQL is at https://<host>/api/graphql; on github.com they are https://api.github.com and https://api.github.com/graphql.

Jira. Same shape:

# 1. Reachable, and which deployment — Cloud and DC page differently
curl -sS "<your Jira base URL>/rest/api/2/serverInfo" # 200, note deploymentType

# 2. Does the credential resolve to a user? serverInfo is anonymous on many
# DC installs, so on its own it proves nothing about auth.
curl -sS -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer <PAT>" \
"<your Jira base URL>/rest/api/2/myself" # 200

# 3. Are issues actually visible to it? (Data Center form)
# THIS IS THE UNSCOPED FORM, and it is what the probe asks only when
# ingest.jira.projects is EMPTY. If you have set it, run 3b instead — a
# healthy `total` here proves nothing about the query the probe will send.
curl -sS -X POST -H "Authorization: Bearer <PAT>" -H 'Content-Type: application/json' \
-d '{"jql":"order by created DESC","startAt":0,"maxResults":0}' \
"<your Jira base URL>/rest/api/2/search" # total > 0

# 3b. If you set ingest.jira.projects, THIS is what the probe asks. Jira refuses
# the WHOLE query with a 400 if a single key is unknown or invisible to the
# credential — it does not answer for the keys that were fine.
curl -sS -X POST -H "Authorization: Bearer <PAT>" -H 'Content-Type: application/json' \
-d '{"jql":"project in (\"ABC\",\"DEF\")","startAt":0,"maxResults":0}' \
"<your Jira base URL>/rest/api/2/search" # 200, total > 0

# 3c. Where those keys come from — the ones this credential can actually see,
# rather than the ones a spreadsheet says it should.
curl -sS -H "Authorization: Bearer <PAT>" \
"<your Jira base URL>/rest/api/2/project" # .[].key

Jira Cloud authenticates with ingest.jira.email plus the API token as HTTP basic auth rather than a bearer PAT (curl -u), and counts through /rest/api/3/search/approximate-count. Data Center uses the bearer PAT above.

While you have the credential to hand: if this install has ever run a live Jira source on a release before 0.9.0, or runs now with ingest.jira.absoluteWindowBound turned on, also run Am I affected? — the same two shapes of call, answering whether that credential's timezone skewed the incremental window.

If 3b comes back 400 and you do not know which key it is, you do not have to bisect it by hand: the probe does that for you. On a refusal it re-asks one key at a time and records the verdict for each — see the fault list at the end of this section, and project_verdicts in the diagnostic bundle.

Widening the window later​

Start small if you want to — 7 days first on a rate-limited instance is a good first move. What does not widen it afterwards is raising ingest.backfillDays:

ingest.backfillDays is read only on a source's first run. Once that backfill finishes the source has a watermark, and every run after it walks forward from there. Raise the value, helm upgrade, and you get a clean upgrade, a Job that runs, and the same window you had before.

Nothing goes wrong when that happens, which is what makes it worth stating: the mirror reports its own horizon honestly, so answers stay correctly scoped to the history you have. They are just scoped to 7 days while you believe they are scoped to 90. (An ingest run that notices the gap now says so in its log — INGEST_BACKFILL_DAYS=90 … IT IS NOT CONSULTED ON THIS BRANCH.)

The supported way to widen it is a re-sweep: name the date you want the source to reach back to.

helm upgrade <release> <chart> -n <namespace> -f <your values file> \
--set ingest.github.rewalkFrom=2026-03-01

and the same for Jira, separately — the two are deliberately not one value, so widening one source during a staged go-live does not restart the other:

--set ingest.jira.rewalkFrom=2026-03-01

What it does, and does not do:

  • it walks the ground between that date and where the source is now, and upserts what it finds. Nothing is truncated — this is a repair, not a first run, so the source keeps answering throughout;
  • it starts as soon as the upgrade lands (the ingest Job's name is a hash of its own configuration, so a changed date is a new Job), and it costs what a backfill of that length costs. Watch it the way you watched the first one;
  • it happens once per date. Leave the value in your values file: the run that carries it out records the date, so the 6-hourly schedule does not re-walk those months on every firing. To re-sweep again — a wider window, or another attempt at one that was killed — name a different date;
  • coverage already earned is never given up. Re-sweeping the last six weeks does not make the source claim six weeks of history;
  • under ingest.engine.mode, both lanes re-walk. The engine keeps its own copy of the source's bookkeeping, and a repair that reached only one of the two could never close a gap between them. Each lane records the date separately, so neither re-walks on the other's claim and the "once per date" rule above holds for both. Budget two passes rather than one, and the engine's is the slower: it runs at half the source's rate unless ingest.engine.shadowRequestsPerMinute says otherwise;
  • blank the value before you first arm the engine. A date left in your values file after a repair is inert for the lane that carried it out, but the engine's lane has never seen it — so the upgrade that first sets ingest.engine.mode to shadow starts a full re-sweep from that date at the next tick, unasked. This is the one case where leaving it set is not free.

Check it landed with the horizon the source reports on Admin → Context streams, or:

kubectl exec -n <namespace> statefulset/<release>-postgres -- \
psql -U postgres -d prism -c "
SELECT source, detail->>'backfill_horizon' AS horizon,
detail->>'issue_backfill_horizon' AS issue_horizon, detail->'rewalk'
FROM ingest_state;"

GitHub reports two horizons, because it walks two searches: horizon is how far back its pull requests reach and issue_horizon how far back its issues do. Each answer is scoped to the one it came from, so a pull-request question and an issue question over the same source can honestly be labelled with different coverage.

A re-sweep moves issue_horizon only in ingest.engine.mode: authoritative. In the default off mode the issue search does walk the re-swept ground and the rows land, but the recorded issue horizon stays where it was — so issue answers over the recovered months keep saying they are past coverage until the source is ingested by the engine. Pull-request coverage moves either way. (Under the engine the issue horizon also starts a year back on a new install; see Issues reach back a year.)

The horizon moves back only when a re-sweep finishes. A re-sweep that was killed part way (ingest.runDeadlineSeconds, an evicted pod) keeps every row it already wrote but does not resume by itself and does not move the horizon — so if the horizon has not moved, name a nearby date and run it again.

When a shadow parity verdict will not clear​

Only in ingest.engine.mode: shadow. The engine walks each source beside the lane that already serves it, compares the two, and the self test counts consecutive passing comparisons towards ingest.engine.parityRuns — the evidence that the engine can be trusted with the lane's job. A diff resets that count to 0.

Almost always a diff means what it says, and the verdict names the tables and rows. There is one cause that does not clear by itself, and it is worth knowing because it looks identical from the outside — diff after diff, 0/3 that never climbs, and nothing wrong with the engine:

Your upstream had a bad afternoon while the engine was backfilling. The engine recorded the day it could not fetch in full, and carried on — which is the right behaviour, and the record is a true fact about its mirror. But the lane it is compared against walked that same day months ago, when the upstream was healthy, so only the engine carries the record. That is a real difference and it is reported as one. It is also permanent: an ordinary run starts at the watermark and never walks a settled day twice, so nothing reaches back to repair it, and it stays inside the compared window for as long as ingest.backfillDays reaches that date.

The verdict says so and names the dates and the value that clears them — but not on a page you can read at a glance. /selftest shows the status and the count only (shadow parity: github diff 0/3); Admin → Context streams does not carry this verdict at all, the parity badge there being a different check. There are two ways to the rest, and they do not give you the same thing:

  • press Download on /selftest and read shadow_parity.<source>.summary. That sentence names the key, the earliest date and the value to set, which is enough to act on — but the self test keeps only a summary of the verdict, so the full date list is not in that file;
  • or read the verdict itself out of the database, which has everything:
kubectl exec -n <namespace> statefulset/<release>-postgres -- \
psql -U postgres -d prism -c "
SELECT jsonb_pretty(detail->'parity'->'state')
FROM ingest_state WHERE right(source, 8) = '__shadow';"

An entry under capped_days or unenriched_days carrying shadow_only_days (or legacy_only_days) is this case. The shadow_remedy / legacy_remedy beside it is a re-sweep from the earliest date that entry lists, exactly as in the section above:

helm upgrade <release> <chart> -n <namespace> -f <your values file> \
--set ingest.github.rewalkFrom=<the earliest date the verdict lists>

One cycle later the engine has walked those days against a healthy upstream, the record is cleared by the walk that cleared the cause, and the next comparison is made without it. The count starts from the run after that.

The same thing happens in mirror image, and it is the same repair. An entry carrying legacy_only_days is a day the lane could not fetch, on ground the engine walked cleanly. It is just as sticky, for the same reason — the lane's incremental runs never revisit a settled day either — and the re-sweep clears it from the same one line, because the value re-sweeps both lanes. The verdict says which lane in every case; read shadow_only_days / legacy_only_days rather than assuming the engine is the one at fault.

Two things to expect:

  • unenriched_days always clears; capped_days may not. An unenriched day has all its rows and is missing columns, so re-walking it against a working upstream completes it. A capped day is one whose rows were past a ceiling the upstream imposes — the record clears only where the re-walk gets that day whole, and where it cannot, the engine genuinely holds less than the lane does. That is a difference worth keeping;
  • the count restarts rather than resumes, and it restarts at the first comparison over a window that has moved. Give it ingest.engine.parityRuns cycles before reading anything into the number.

Nothing here is urgent. shadow is a measurement mode: the lane that serves your users is the one that always served them, and it is untouched throughout.

Once the count does read ready, Flip a source to the ingest engine is what to do with it — including how to move one source without moving the others, and how to put it back.

Afterwards, the schedule takes over​

The CronJob re-probes on ingest.checkSchedule, and you can run it on demand rather than waiting:

kubectl create job -n <namespace> probe-github --from=cronjob/<release>-github-check

The probe exits 0 whatever it finds — a failed Job here is Prism's database, not your upstream. An upstream fault comes back as a red upstream check failed: <fault> line on the self test instead.

What a pass does not prove. That the credential still works tomorrow. A token revoked after go-live leaves a row count identical to a healthy one, which is exactly why the probe keeps running on a schedule and why the self test distinguishes "rows are here" from "the upstream is still answering".

If it still fails: the probe's evidence in the diagnostic bundle names the fault — bad_credential, org_not_visible, org_visible_but_no_prs_found, unreachable, timeout. Send that; it is the difference between a scope problem and a routing problem, and it carries no repository names, issue keys or logins.

The two Jira lanes add two more, and the difference between them decides who picks the ticket up:

faultwhat it meanswho fixes it
projects_not_visibleone or more of the keys in ingest.jira.projects (or JSM_PROJECTS) is unknown to this instance or invisible to this credential. project_verdicts.refused names them, and the self-test line carries them tooyou, in values.yaml — or your Jira admin, if the key is right and the grant is not
no_issues_visibleevery configured key resolved, and the credential can see no issues in any of themyour Jira admin: this is a permission or a genuinely empty scope, not a typo
edition_mismatchingest.jira.deployment says one edition and the instance says it is the other — cloud declared against a Data Center, or the reverse. The engine-managed Jira stream runs the declared edition and refuses on this before it walksyou, in values.yaml: set ingest.jira.deployment to the edition the evidence names

project_verdicts also carries readable_but_empty — keys that resolve and hold nothing. Those are not wrong keys, and correcting them is how a working configuration gets broken.

A source Prism queries in place, rather than ingests​

Everything above copies a source's data into Prism's own database and answers out of that. A query-through source does not: nothing is copied, and every question about it becomes one statement against your warehouse, run as a login you control. Today that means Snowflake, and it needs the semantic query MCP, which ships on since 0.10.0.

Most installs set none of this and that is the normal case, not a gap. Skip this section unless you have been asked to connect a warehouse.

What the chart needs for a source you connect in the admin surface​

No switches and no credentials — both of these are the default since 0.10.0. Set them only to be explicit, or if your values file pins either to false:

mcps:
semanticMcp:
enabled: true # the only component that executes a query-through statement.
# DEFAULT since 0.10.0
sources:
registry:
enabled: true # the source registry: a query-through source is a row in it.
# DEFAULT since 0.10.0

sources.registry.enabled is what renders ENCRYPTION_KEY into both the app and the semantic MCP, from the Secret entry your install already has. The app encrypts a source's credential with it; the semantic MCP opens it to run that source's statements. Without the registry there is no row, so there is nothing to connect and the semantic MCP will say so.

mcps.semanticMcp.enabled is not only for Snowflake. It is what puts the semantic query tools on the agent, and those are the only tools that reach a registry source's statement — so the source you designate as your organizational directory is unreadable to the agent without it, whatever kind it is. An uploaded staff list needs it as much as a warehouse view does: the self test's designated route line fails without it, and says so by name. This section is a prerequisite for Connect your Snowflake directory and Upload a staff list, not an upgrade note — the subsection below is the upgrade note.

None of it is a chart value. Earlier releases put the account, the login and the private key in values.yaml and in a Secret, and that was wrong in a way worth stating, because you may have read the older page: it made two connections for one warehouse. The admin page's probe authenticated with the credential stored on the source, the semantic MCP executed with the values in the chart, and nothing compared them. A source could show as connected and answer nothing.

There is one connection now and it belongs to the source. You enter it once, in the admin surface, and the probe that switches the source on is a probe of the connection that will answer.

Upgrading from a release that put this in values.yaml​

If your values file still carries mcps.semanticMcp.snowflake.* or secrets.snowflakePrivateKey, the upgrade will refuse to render and name this page. That is deliberate: the chart no longer reads those keys, so rendering on would drop the warehouse credential you supplied without a word and leave a source that shows as connected and answers nothing.

The migration:

  1. Set mcps.semanticMcp.enabled: true — and delete the mcps.semanticMcp.snowflake block and both secrets.snowflakePrivateKey* keys. sources.registry.enabled defaults to true from 0.10.0, so set it only if your values file pins it to false.
  2. Upgrade.
  3. Enter the connection and the private key on the source (below), and probe it.

Your Snowflake key pair does not change and neither does the grant. What changes is where the appliance reads them from — which is what stops a source probing against one warehouse and answering from another.

What to enter, on the source​

Sign in as a super admin, open Admin → Context streams, and give the source its connection parameters and its credential.

ParameterWhat it is
accountyour account identifier — myorg-myaccount, or a legacy locator. The host Prism posts to is <account>.snowflakecomputing.com
userthe service login Prism signs in as: a TYPE = SERVICE user with your read-only role granted. It is configuration, not a secret — it appears in QUERY_HISTORY and in every authentication failure, and somebody has to be able to read it to fix one
warehousethe virtual warehouse the statements run on. This is what your spend lands on, so give a query-through source its own if you want its cost separated
databasethe database holding the view
schemathe schema holding the view. Optional: leave it empty if the login's default schema is the right one

The credential is the private key of the pair whose public half is registered against that login — a PKCS#8 PEM, the -----BEGIN PRIVATE KEY----- form. Paste it into the credential field on the source, with its passphrase if it has one. Snowflake is retiring password sign-in for TYPE = SERVICE users through the August–October 2026 window, so a key pair is the credential that will still work.

PEM, and nothing else. A .p8 extension says nothing about the encoding: openssl pkcs8 -topk8 -nocrypt writes PEM, and -outform DER writes binary under the same name. Check before you paste — a PEM file's first line is -----BEGIN PRIVATE KEY-----:

head -1 rsa_key.p8

If that shows binary rather than a BEGIN line, convert it:

openssl pkcs8 -topk8 -nocrypt -inform DER -in rsa_key.p8 -out rsa_key.pem

Prism checks the key when you enter it rather than at the first question, so a key it cannot read is a refusal on the credential form and not a source that looks connected and answers nothing.

The role is not a parameter, and that is deliberate. PRISM_READER — the read-only role our recipe tells you to grant — is written into the source's manifest as a literal, and every session activates it. So the ceiling on what a question can read is the grant you made, not a value somebody could forget to set. Grant it USAGE on the warehouse, the database and the schema, and SELECT on the one view; nothing else. The schema grant is the one most often left out — it is the only one that is not about an object you named — and without it the view is invisible to the role however correct everything else is.

Proving it, which is not the same as entering it​

  1. Probe the source from the admin page and read the verdict. It reports the session's user, role, warehouse, database and schema as Snowflake established them — and it compares every one of them with what it asked for. This is the case worth knowing about: Snowflake does not refuse a warehouse or a schema that does not exist. It accepts the request and quietly gives you a session without one, and every real question then fails. So the probe reads the session back and refuses on your behalf, rather than passing and leaving the failure for the first person to ask a question.
  2. Enable the source. Only a probe that returned and passed will let you, and the semantic MCP will not query a source that is not enabled — so a source that says connected is one that answered, from this installation, with the credential the row holds now.
  3. Ask a question the source answers, and check that the answer names it.

If questions still fail, the semantic MCP's log names which state it is in: no row, a row that is not enabled, a stored connection it cannot use (and why), or a statement the warehouse refused.

Rotating the key​

Replace the credential on the source in the admin page, and probe again. That is the whole procedure: the connection is read per question, so the next question uses the new key with no restart and no redeploy. There is no Secret to patch and no pod to roll.

Test a manifest before you register it​

The Test button on a source's Manifest tab tests a source that is already registered. To test a document before registering anything, call the API directly, as a super admin:

POST /api/admin/manifest/test
{"manifest": { … }, "credential": { … }, "parameters": { … }}
  • manifest is the document (or send manifest_yaml with its text).
  • credential carries the same fields POST /api/admin/sources/<id>/credential takes (secret, or username and secret, or private_key_pem). Leave it out entirely when the document's connection.auth.type is none; sending one with such a document is refused.
  • parameters are the values the document's connection.parameters declare.

Nothing is stored: no source is created and no credential is written. The request is tested against the document's own connection, and the credential is sealed with this installation's ENCRYPTION_KEY before it leaves the app, so the route answers 503 on an install without one. The response carries the verdict under result — whether the document validates, and whether each stream or column it declares resolved.

A source Prism reads out of an Oracle database​

The same shape as the Snowflake section above, on the other engine: an Oracle source is queried in place. Nothing is copied. A question over it is compiled into one SELECT against one view and sent to your database over a read-only session, and the answer is built from what comes back. Your database sees one statement per question, bounded by the source's statement timeout, and nothing else. (Until 0.12 this section described the opposite: a whole copy of the view every week, kept for two years. That shape still exists — see If you want the plan's history below — but it is no longer what an Oracle view means by default, because a rolling plan nobody asked to keep history of does not warrant a hundred copies of itself.)

Read this the same way as the Snowflake section: skip it unless a database source appears in your recipe list under Admin → Context streams. Which sources are offered is fixed by the release you installed, and nothing here is something you can add yourself.

Writing the document itself — which view, which columns, what each one means — is Read an Oracle view as a context stream. This section is the connection underneath it: what your DBA prepares, what to enter, and what the probe proves.

One thing an Oracle source does that a Snowflake one cannot yet: when it is your organizational context stream, per-person answers resolve through it. The ingest job reads the two identity columns the recipe names — the employee id and the address — out of the view, about once a day, and compiles the identity crosswalk from them. Two columns per person are held in Prism for that purpose; the plan itself is not. Questions scoped to a leader's organisation ("everyone who reports to X") still need a landed directory — an uploaded staff list, or a snapshot instance of the same view — and say so when refused.

What your DBA prepares, and it is the long pole​

The ingest side is finished: the Oracle driver ships inside the appliance, so there is nothing to install, enable or mirror to read your view. So is the admin page's probe — the app carries that same driver, so an Oracle source is probed, approved and enabled from the screen like any other. Proving it below says what a passing probe does and does not tell you, which is the part worth reading before you plan around it.

Everything on your side of the connection is ordinary, and it is worth starting now because it is the part with a lead time:

WhatWhy it is asked for
A view, not a tablethe source reads exactly one view. What is in it is what Prism can ever see, which makes the view itself the access control
A local database user for the applianceone service account for the whole appliance, never one per person. It appears in your audit trail as itself
SELECT on that view, and nothing elsethe grant is the control. Prism opens its walk with SET TRANSACTION READ ONLY, but that blocks DML and not DDL, so it is defence in depth over a read-only grant and never a substitute for one
A row identity column, if the view can carry oneasked for so a fan-out on a join is detectable; a live query needs no key to run, so a view without one still connects. A last-updated timestamp is no longer asked for — nothing is copied, so nothing needs to know what changed
Host, port and service name, and a network path to the listenerone Oracle Net connection, outbound from the Prism namespace to your listener's port

Two limits that are worth knowing before your DBA plans around them, because both are properties of the driver rather than settings we chose to leave off:

  • No sqlnet.ora of ours is read, and there is no oraaccess.xml. An estate that keeps its connection policy in those files does not get it applied here; the connection is exactly the parameters below. In thin mode — the default — the driver has no notion of those files at all. In thick mode (below) the Oracle client library would read a sqlnet.ora if one were placed on its path, and Prism's images ship none, so the effect is the same: the connection is the parameters, plus whatever your server requires and the client agrees to.
  • Wallets are PEM or nothing. A cwallet.sso auto-login wallet cannot be used, in either mode: the appliance has nowhere to tell it about one. If your policy mandates one, say so before the account is created — it is a real finding and it changes what we ship, rather than a value to set.

Neither of these is a workaround waiting to be found. Tell us early and the answer is a different build, not a different value.

If your database requires Native Network Encryption, that is not one of those findings — it is a value. SQLNET.ENCRYPTION_SERVER = REQUIRED (with or without SQLNET.CRYPTO_CHECKSUM_SERVER) refuses every connection Prism's default driver mode makes, before a session exists, and the source's verdict says so by name. The fix is one chart value, oracle.mode: thick, and no new build: see When a database requires Native Network Encryption.

What to enter, on the Oracle source​

Sign in as a super admin, open Admin → Context streams, pick the database source from the recipe list, and give it its connection parameters and its credential.

ParameterWhat it is
db_hostthe hostname of your Oracle listener
db_portthe listener's port — 1521 unless your estate moved it
db_servicethe service name of the database, not an SID and not a TNS alias. tnsnames.ora is not consulted, so an alias that works from your DBA's laptop is not a value that works here
db_userthe service account the view is granted to. It is configuration, not a secret: it appears in your own audit trail and in every authentication failure, and somebody has to be able to read it to fix one

The credential is that account's password, entered on the source in the admin page. It is encrypted with the installation's ENCRYPTION_KEY and read back in exactly two places: by the app, when an admin probes the source, and by the ingest job on its schedule. Nowhere else — and it is never returned by the API, never written to a log, and never carried in a probe verdict.

There is no schema parameter, deliberately. Either create the view in the service account's own default schema, or give the account a synonym for it. A schema name is the one part of this that differs per customer in a way a shipped recipe cannot carry, and a value nobody remembers to set is a worse failure than a synonym somebody has to create once. If your estate genuinely cannot do either, that is worth telling us — it is a small additive change and not a redesign.

Proving it​

Sign in as a super admin, open Admin → Context streams, pick the Oracle source from the recipe list, enter the four connection values and the password, then press Test. It opens a real Oracle Net session to your listener, from the appliance, with the credential the row holds now — and then reports what that session established.

One word on this screen, whatever it runs: Test. What that means for a source depends on its kind — an Oracle session here, a signed-in request to an API for a source Prism copies, a compiled and explained statement for one it reads in place, a parse of the file for a roster — and the screen says which it did. The button used to be named after the mechanism, differently in four places, and the mechanism is not the thing you are trying to find out.

Storing the password IS a test, and a test that fails refuses the store. The button on that screen is Store and test, and there is one credential on the row rather than a copy each side, so the two steps are one request: Prism opens the session with the password you just typed, and only a verdict it is willing to stand behind gets written. A password your database rejects (ORA-01017) is not stored and whatever was there before is untouched — which is the behaviour you want, and it is the same for every source type. But a probe that cannot reach your listener at all refuses the store for the same reason: a network path not yet open, a listener that accepted and closed, or the timeout are all "no verdict", and no verdict means no write. So an Oracle password cannot be parked on the row before the egress exists. Two ways round it, and the first is usually right: prove the egress first and then store the password once, or — if your change windows force the credential to land before the firewall does — store it without testing it: Save without testing on the source's Credential tab, or POST /api/admin/sources/<id>/credential with {"probe": false} in the body. Then press Test on the source once the path is open. Either way nothing reaches enabled until a probe has actually passed: an unprobed credential records not_probed, which enable refuses. Rotating a credential is the same request and the same rule.

Then, and this step is easy to miss because nothing prompts for it: approve the source, and only then enable it. A source moves registered → probed → approved → enabled, and enable is refused from probed. Approving is you saying you have read the manifest; enabling is you saying it may answer.

A passing probe means these things and no more:

  • the listener was reachable;
  • the credential was not rejected;
  • and — only where the source names a schema — the session Prism got is the one it asked for: the schema the session actually ended up in is read back out of it and compared with the one asked for. Where the source names no schema there is nothing to compare, so the verdict makes two claims rather than three, and the session it established is reported without being vouched for.

That third one is worth its own sentence, because a login that authenticates perfectly well can still land somewhere other than where you meant: a login whose default schema is not the one the walk expects sees none of its tables. So Prism compares rather than assumes, and a session in a different schema is a refusal naming the field — it cannot buy enabled. The comparison folds case, because the schema travels unquoted and Oracle answers PRISM where the connection asked for prism, and refusing a correctly configured install would be worse than the fault it replaced.

The service name is reported, not compared, and that is deliberate. Your listener refuses a service name it does not know before there is a session at all, so there is nothing for a comparison to catch — and there is something it would wrongly catch. A service created the documented way, DBMS_SERVICE.CREATE_SERVICE(service_name => 'svc840', network_name => 'svc840net.example.com'), is registered with the listener under its network name, so that is the value db_service must carry; inside the session Oracle answers the internal svc840. Comparing those two would refuse a correct install with no value you could type to get past it. So the verdict shows you the service the database says you are on — useful on a host running two of them — and lets you judge it.

A passing probe does not mean your view exists, that the account can read it, or that any column you named is real. The probe names no object of yours at all — it asks Oracle about the session and stops there. The Test on the source's Manifest tab is what finds those out: it compiles every column the recipe names into a statement, has Oracle parse each one against your view, runs each with no rows returned, and reports every column that did not resolve with the view's real column list beside it — before the source can be enabled. That is the check to run once the view is cut, and again after any change to it.

A refusal names the engine's own code, and that code is the thing to put in a ticket:

  • ORA-01017 — the database rejected this installation's credential: the password, the login name, or the account's ability to create a session at all. Nothing about the network is wrong. On an installation running in thick mode this code has a second cause, and the verdict says so: Oracle Instant Client refuses a password longer than 30 bytes as though it were wrong. A password longer than 30 bytes is how to tell the two apart before you rotate anything.
  • a service name the listener does not know arrives as DPY-6005 with DPY-6001 inside it — the listener answered and does not serve that name. An SID or a tnsnames.ora alias entered as db_service lands here, because neither is resolved.
  • nothing answered at that host and port — the listener is down, the port is wrong, or the network path is not open. Prove the egress is the check that separates those from a credential fault.
  • the listener accepted and closed — DPY-4011, and it comes back in milliseconds rather than at the timeout. Something took the connection and hung up without giving a session: node validation on the listener (TCP.VALIDNODE_CHECKING with the Prism pod's address not in TCP.INVITED_NODES), a TLS-only (tcps) listener dialled in clear, or a firewall or proxy in the path. It is neither a credential fault nor a timeout, and the fix is on your listener or your network rather than in this screen.
  • the timeout — the probe bounds itself, so an address that swallows packets comes back in seconds instead of hanging the screen.

Oracle's own message never travels into the verdict: it quotes the statement, the schema and sometimes the service name, so what you get is the code, the address dialled, the login and the elapsed time, deliberately and no more.

What has not changed, and it is still the long pole by a wide margin: prepare the database side now. The view, the service account, the grant, the network path and the answers to the two thin-mode questions above are the same whatever the screen does, and no probe verdict shortens any of them.

The sequence, then, is the ordinary one:

  1. Probe the source and read the verdict. It reports what the session actually established, and it is where a wrong service name, a rejected password or a blocked network path is named as itself.
  2. Approve the source, then enable it, and let one scheduled run complete.
  3. Check the row count against what your DBA sees in the view. This is the check worth doing by hand once: a credential that authenticates cleanly and sees an empty view is the failure this whole procedure is arranged around, and from the chat it is indistinguishable from a working product with nothing to say.

And enabled is not verified. Enabling an Oracle source means this installation demonstrably opened a session to your database. Whether the figures it copies are right is step 3 and the questions you ask afterwards, and it is not something the appliance can decide.

Rotating the password​

Replace the credential on the source and probe again. The next scheduled run uses it. As with a warehouse, there is no Secret to patch and no pod to roll.

If you want the plan's history​

A live query answers for the view as it stands now. If you want to ask how the plan changed between March and June, the stream has to keep dated copies — which means running it as a snapshot rather than reading it live.

Change the kind on the stream you already have. On registration, open the recipe in the editor, set kind: snapshot and give it an ingest block with a query stream and a deliberately short keep (a quarter of weekly copies is P90D). The ingest job then takes a dated copy of the view on that schedule and keeps that many. Read an Oracle view as a context stream carries that whole document for the recipe's example view, ready to adapt.

A document you edited is walked like any other. The ingest job walks the document the stream holds, under whatever name you gave it. That includes one you wrote, pasted, or changed in the editor before registering it. It checks only that the document names nothing this release does not implement.

Store the credential on the source. The ingest job never lends its own configuration to a document of your own: the document chooses where its requests go, so the job does not hand it a secret it holds for another stream. A stream with no credential stored on it wears not ingested on Admin → Context streams until you enter one, and the self test's ingest dispatch line names it. Connection settings are covered the same way: a document of your own never reads them from the job's environment, whatever it says.

A document that names a part this release does not implement wears not ingested with the part named, and no edit on your side supplies it. Ask us.

On 0.15.x and earlier, a stream carrying an edited document under a name of its own is never walked, so keep the name the recipe ships with there. Do not rename an existing stream to a prebuilt name to work around it: the name is usually taken, and where it is free the prebuilt document takes over the walk and your edits stop being used.

Size it before you choose, because the multiplier is the schedule and not the view: each copy is kept whole, so a weekly stream kept two years is about a hundred and five copies of the whole plan. One copy of a 2.6-million-row view is roughly 1.2 GiB, which puts that shape north of 100 GiB and a monthly stream kept a year at about 16. Decide the pair — how often, and how long — against the volume you are willing to give it, and expect the pre-flight to warn you: the warning starts at five copies, so every realistic shape trips it.

Only one stream can hold the organizational designation.