Chapter 6 of 6 · 10 min read
Ranking cannot un-embed a secret, so some content never enters the index
On this page
The request arrived as a two-line comment on the roadmap ticket: Can we just index the whole wiki? The assistant keeps saying it doesn't know things that are obviously written down somewhere. The product manager had a list attached — the 3,200 pages of engineering runbooks, the 11,000 pages sitting at status: draft, and a 2024 export of 380 Slack channels totalling 4.1 million messages that someone had uploaded to the shared drive during a compliance exercise and never deleted.
The assistant is customer-facing. It answers questions from people outside the company, in a chat widget, with citations.
This is the first decision in the topic that is not a tradeoff with a dial on it. Everything so far has been a judgment about where to set a threshold, which copy to keep, or how aggressively to strip, and every one of those decisions was reversible by rebuilding the index. Some of what is on that list produces an outcome that survives the rebuild.
Three sources, three different answers
| Source | Decision | Condition, if any |
|---|---|---|
| Engineering runbooks | Conditional | Only source spaces individually reviewed and marked external_ok; every document passes a secret scan at ingest, with any high-confidence hit failing the build rather than skipping the document; pages holding live credentials excluded outright regardless of review |
| Draft pages | Conditional | Ingested only where status is explicitly published; missing or unrecognised status is treated as draft and excluded |
| Slack channel export | Refused | None available |
The Slack export is refused because there is no condition that makes it safe, and each of the reasons is independently sufficient. A message is one person's belief at one moment, frequently corrected three messages later by someone else, and a chunk of a conversation carries neither the correction nor the fact that the speaker was guessing. The export contains customer names, email addresses and pasted screenshots in volumes nobody has enumerated, in channels whose members wrote assuming an eighteen-month retention policy that the export has frozen indefinitely. A secret scan over the archive found 1,140 high-confidence matches across 380 channels, which surprises nobody who has watched two engineers debug a webhook by pasting the token into a channel. And the whole archive is written by people who never consented to it being read aloud to customers.
Drafts are conditional rather than refused because the failure mode is a wrong answer rather than an irreversible disclosure. A draft pricing page quoting a discount that was never approved will be cited to a customer with exactly the same confident tone as the approved page, which is a real cost, and it is a cost that can be undone by removing the page and correcting the answer.
Runbooks are the interesting case, because the class contains both ordinary operational documentation and the specific pages that must never be indexed under any condition — the ones holding live credentials, connection strings with embedded passwords, API keys pasted "temporarily" in 2023, and internal hostnames with the tokens that reach them.
Ranking is a scoring function over what is already in the store
The counter-argument in the ticket thread was reasonable on its face: retrieval ranking already keeps irrelevant content out of answers, so runbook pages simply will not surface on customer questions, and if that is not enough, a filter on the retrieved chunks can drop anything whose source path starts with wiki/runbooks/.
Ranking orders candidates. It does not decide membership, and a similarity score is a function of the query as much as of the document, which means the claim "this will never be retrieved" is a claim about every query anybody will ever send. Customers ask questions using the vocabulary of the system they are struggling with, and a runbook about the payments queue is written in that same vocabulary. There is no ranking behaviour that makes a document unreachable; there is only a document ranked below other documents for the queries somebody thought of.
The post-retrieval filter is a stronger proposal, and it fails in a way that is specific and enumerable. It guards one code path, and this index had four consumers in front of it:
- the answer service, which is where the filter would sit
- the evaluation harness from chapter two, which reads the collection directly to compute recall at k=200 and writes candidate chunk text into a report that gets attached to tickets
- a debug endpoint,
/internal/retrieve?q=, added during the boilerplate investigation and still receiving around 26,000 requests over the last ninety days from engineers, dashboards and one forgotten cron job - the internal search bar, whose team reused the same collection rather than building a second one
Add to those the analytics pipeline that logs retrieved chunk text into a warehouse table for quality review, the nightly backups, and anyone holding read credentials to the vector store. A single rule sitting on one of seven paths is not a control; it is a decoration on the path that happens to be under review.
There is a more fundamental problem with the filter, which is that it operates after the damage. Once a chunk is in the store, the secret it contains is in the stored chunk text — vector databases keep the original text as a payload, because the answer service needs it — and it is in the index in a form that anybody with query access can pull out. Even with the text withheld, an attacker who can send arbitrary queries and observe similarity scores can narrow down the contents of a chunk, and nobody needs to bother, because in practice read access to the store and read access to the payload are the same access. The exposure begins at the moment of indexing, not at the moment somebody asks the right question.
The gate belongs at ingest, and it fails closed
QUARANTINE_REASONS = ("secret_detected", "source_not_reviewed", "status_not_published")
def ingest_gate(doc, source_policy: dict) -> tuple[bool, str | None]:
policy = source_policy.get(doc.source_space)
if policy is None or policy["decision"] != "allow":
return False, "source_not_reviewed" # default deny for unlisted sources
if policy["requires_published"] and doc.status != "published":
return False, "status_not_published"
findings = scan_secrets(doc.text) # detect-secrets / gitleaks rules
if any(f.confidence == "high" for f in findings):
return False, "secret_detected"
return True, None
def build_index(docs, source_policy: dict) -> None:
quarantined = []
for doc in docs:
ok, reason = ingest_gate(doc, source_policy)
if not ok:
quarantined.append({"doc_id": doc.id, "space": doc.source_space,
"reason": reason})
continue
upsert(doc)
write_manifest(quarantined)
secrets = [q for q in quarantined if q["reason"] == "secret_detected"]
if secrets:
raise BuildFailed(
f"{len(secrets)} documents in allowed spaces contain high-confidence "
f"secrets; rotate and remediate at source before rebuilding"
)Two design choices carry most of the weight. Unlisted sources are denied rather than allowed, so adding a connector cannot silently add a corpus — the person adding it has to write a policy entry, and writing one is where the decision gets made by someone who can make it. And a high-confidence secret in an allowed space fails the entire build instead of quietly skipping the document, because a skipped document means the credential is still live in the wiki where a human will read it, and a build failure is the only event that reliably reaches a person.
When a credential is already in the index
Three months after the runbook spaces were approved, a customer conversation transcript surfaced a returned chunk containing a connection string for the reporting replica, complete with password. The remediation has an order, and the order is not the order in which the systems are listed on an architecture diagram.
Rotation comes first because it is the only genuinely reversible step in the sequence. Every other action removes copies of a string, and no team ever finds all the copies; rotating makes the copies worthless, which converts an open-ended search problem into a bounded one. Purging first and rotating afterwards leaves a live credential in whatever copy the purge missed, and the purge always misses something.
| Step | Scope in this incident | Elapsed | Undoes the exposure? |
|---|---|---|---|
| Rotate the reporting-replica credential | 14 services reading it, staged connection-pool cutover with a 20-minute degraded window | ~3 h | Yes |
| Delete vectors and payload text | One document, 6 chunks; deleted rows are tombstoned — marked deleted but still physically present — for 7 days, so a forced compaction is required | ~15 min | Partially |
| Replicas and caches | Two read replicas, a Redis cache of top-k results with a 24-hour time-to-live | ~1 h | Partially |
| Backups | 35 nightly snapshots, write-once storage with no selective delete; either expire early and lose the recovery window, or document the residue with restricted access and an expiry date | Decision, not a task | No |
| Derived artefacts | 23 evaluation reports in object storage containing candidate text, 4.2M rows in the retrieval_candidates warehouse table, one fine-tuning set assembled in May | ~2 days | Partially |
| Disclosure record | 61 customer conversations where the chunk appeared in a cited answer; the model provider's 30-day request retention | Cannot be undone | No |
The exposure window is three months, from indexing to discovery, not from discovery to rotation, and the database audit log has to be read across that whole window for connections from unexpected addresses. The last row is the one that determines whether this becomes a security incident with regulatory weight: sixty-one identifiable customers received text containing a production credential, and no engineering action recalls it.
What this is not
None of this is access control, and the two are routinely confused because both end with content not reaching a reader. Permission-aware retrieval — filtering candidates against the asking user's access control lists, the per-document records of who may read what, at query time — is the correct tool for content that legitimately belongs in the index and legitimately has an audience smaller than everyone. An internal assistant answering employee questions about salary bands needs exactly that, and building it is a different project with different failure modes. It does not substitute for exclusion, because it is another query-time filter on one path, and it presumes the content is something somebody should be able to retrieve. A live credential is not that.
Secret scanning is a backstop rather than a control, and treating it as the primary defence is the mistake that this chapter's gate is arranged to avoid. Entropy-based detectors, which flag strings too random-looking to be prose, and pattern-based ones, which match known key formats, find AWS keys, JSON Web Tokens, connection strings and PEM-encoded private keys reliably. They do not find the staging admin password is autumn-harvest-7, written in prose in the middle of a runbook, and they do not find the credential visible in a pasted screenshot unless an optical character recognition stage runs first and feeds the text it reads out of the image back through the scanner. Source-level review is what carries the load; the scanner catches what review missed within a source that was approved.
What the refusal costs, and what to do with the archive instead
Refusing a source has a price, and stating it honestly is what makes the refusal survive the next roadmap review. The product manager's underlying complaint was correct: the assistant did not know things that were written down somewhere, and a good share of those things were written down in Slack and nowhere else. Refusing the archive as an index source does not require refusing it as an input to the documentation effort. Running the 150 unanswered questions from chapter two against a local, access-controlled search over the archive found plausible answers for 34 of them, each of which became a wiki page written by a person who checked whether the answer was still true. That path produces documents with an author, a review, a URL and a date, and it takes a few weeks rather than an afternoon.
The distinction generalises: a corpus that is unfit to index can still be fit to mine. Reading it offline, under access control, to decide what should be written is a different activity from embedding it and serving it to customers, and conflating the two is what makes "index everything" sound like the only way to capture the value.
Writing the list down
The output of all of this is a file, and it is the artefact the rest of the topic has been building toward. One row per source, with its decision, its condition, the person who owns that decision, and the date it is next reviewed — the runbook spaces with their external_ok markers, the draft policy, the Slack export with refused and a one-line reason, and every source the connectors will pick up next quarter absent from it and therefore denied by default. The pipeline reads that file rather than a constant embedded in the ingestion code, so changing what is indexed is a reviewed change to a document that a security engineer and a product manager can both read.
Everything from the previous five chapters lives alongside it in the same reviewed form: the flagged boilerplate blocks, the near-duplicate similarity floors and their exempted families, the quality thresholds with their exemption patterns and per-class casualty rates, and the coverage gates that fail the build. Together those files are the corpus policy, and the reason they are worth the effort is that the alternative is a set of decisions distributed across four scripts and three people's memory, in a system where one of the decisions is not reversible.
Corpus quality filtering for AI use — read the current version at https://daviddominguez.dev/learn/en/corpus-quality-filtering-for-ai-use/ranking-cannot-un-embed-a-secret-so-some-content-never-enters-the-inde