30 Evidence Governance: Claims, Results, and Reports

An analysis that runs is not an analysis that means anything. The first two chapters of this part get a plan executed; this one is about what may be said afterwards, and why sclet refuses to let you say more.

The rule this chapter exists to enforce: a report may only claim what the evidence supports. Every other function in the AI layer is upstream of that check.

30.1 Evidence nodes

An evidence node is a bounded, named record that something was measured. Two properties make it useful:

It is aggregate by construction. Cell counts, gene counts, quantiles, booleans — never a feature name, an antibody name, a barcode, or a per-cell value. A complete matrix is rejected, not truncated. This is what makes a node safe to put in a prompt.

It is write-once, unless the analysis says otherwise. Recording the same node id twice with identical values is a no-op; with different values it raises evidence_id_conflict rather than overwriting. A re-run that silently kept the first run’s numbers would be a citable artifact describing an older analysis — worse than a missing node, because it renders and looks like a measurement of the current object.

The exception is the case where the analysis knows it is re-running itself under a fixed id — imputation is the example: its record id is the same on every run, and its action passes replace = TRUE so the node is refreshed in place. The rule is not “never overwrite” but “never overwrite silently”: an id that a caller cannot rename refreshes and the trace records it as such; an id a caller could have renamed must not.

library(sclet)

sce <- scuttle::mockSCE(ncells = 200, ngenes = 80)
sce <- SingleCellExperiment::SingleCellExperiment(
    list(counts = SummarizedExperiment::assay(sce, "counts"))
)

ev <- list(
    id = "ev:demo_doublet_finder",
    kind = "deterministic_summary",
    summary = list(n_cells = 200L, n_doublets = 12L)
)

with_ev <- RecordAIEvidence(sce, ev)
GetAnalysisLedger(with_ev)$analyses[grepl("demo", names(GetAnalysisLedger(with_ev)$analyses))]
## $`ai_evidence::ev:demo_doublet_finder`
## $`ai_evidence::ev:demo_doublet_finder`$id
## [1] "ev:demo_doublet_finder"
## 
## $`ai_evidence::ev:demo_doublet_finder`$type
## [1] "ai_evidence"
## 
## $`ai_evidence::ev:demo_doublet_finder`$source
## [1] "state"
## 
## $`ai_evidence::ev:demo_doublet_finder`$status
## [1] "completed"
## 
## $`ai_evidence::ev:demo_doublet_finder`$method
## [1] "sclet_ai_evidence"
## 
## $`ai_evidence::ev:demo_doublet_finder`$inputs
## $`ai_evidence::ev:demo_doublet_finder`$inputs$source
## NULL
## 
## $`ai_evidence::ev:demo_doublet_finder`$inputs$parents
## character(0)
## 
## 
## $`ai_evidence::ev:demo_doublet_finder`$summary
## $`ai_evidence::ev:demo_doublet_finder`$summary$id
## [1] "ev:demo_doublet_finder"
## 
## $`ai_evidence::ev:demo_doublet_finder`$summary$kind
## [1] "deterministic_summary"
## 
## $`ai_evidence::ev:demo_doublet_finder`$summary$summary
## $`ai_evidence::ev:demo_doublet_finder`$summary$summary$n_cells
## [1] 200
## 
## $`ai_evidence::ev:demo_doublet_finder`$summary$summary$n_doublets
## [1] 12
## 
## 
## $`ai_evidence::ev:demo_doublet_finder`$summary$parents
## character(0)
## 
## $`ai_evidence::ev:demo_doublet_finder`$summary$scope
## list()
## 
## $`ai_evidence::ev:demo_doublet_finder`$summary$claim_level
## [1] "measured"
## 
## $`ai_evidence::ev:demo_doublet_finder`$summary$object_fingerprint
## [1] "sclet-ai-74763b50"
## 
## $`ai_evidence::ev:demo_doublet_finder`$summary$status
## [1] "completed"
## 
## $`ai_evidence::ev:demo_doublet_finder`$summary$created_at
## [1] "2026-10-22 14:51:42.151762"
## 
## $`ai_evidence::ev:demo_doublet_finder`$summary$dependency_group
## [1] "dc0bf290ae75ac82ecb060afeeb373130d84d0fac62379d4ca9370abde0e0f12"
## 
## 
## $`ai_evidence::ev:demo_doublet_finder`$created_at
## [1] "2026-10-22 14:51:42.152213"

sclet_ai_record_evidence_for_run() is the recorder used inside sclet itself: it tolerates a re-run whose values are unchanged, and refuses one whose values changed unless asked with replace = TRUE.

30.2 The claim ceiling

Every interpretive statement has a claim level, derived from the evidence behind it — not from the confidence of whoever wrote it. GetAnalysisLedger() carries the ceiling, and ValidateAIPlan() reports it alongside a plan.

The clearest consequence: two evidence nodes that share a dependency group are not two lines of support. Run a second contrast over the same graph and you have a second analysis, not independent confirmation. sclet_ai_evidence_independence() answers that question structurally:

a <- list(id = "ev:a", kind = "deterministic_summary",
    summary = list(n = 10L), dependency_group = "group_1")
b <- list(id = "ev:b", kind = "deterministic_summary",
    summary = list(n = 10L), dependency_group = "group_1")

sce_a <- RecordAIEvidence(sce, a)
sce_ab <- RecordAIEvidence(sce_a, b)
sclet_ai_evidence_independence(sce_ab, c("ev:a", "ev:b"))$independent
## [1] FALSE

Common lineage is not a flaw to hide; it is a fact to report. A report that counts the same signal twice overstates its case, which is exactly the failure mode this layer exists for.

30.3 What may leave the object: the privacy line

Design semantics may be exposed. Analysis results may not.

The distinction is not “no colData values leave”, because a contrast cannot be planned without the levels of the design column. The line is about what the analysis is trying to discover:

  • ActiveIdent — the clustering — is the result. It leaves only as group_N codes. Revealing cluster names pre-answers the question the analysis was asked.
  • A design column — condition, Treatment, donor — is the user’s own experimental setup. Its levels must be present, or the planner has to invent them.

capabilities$contrast_levels therefore lists design levels and must exclude the active identity column by name, while capabilities$identity_levels stays anonymised.

Two traps found while implementing this: levels must be a character vector (an unnamed list of scalars is redacted as “unnamed nested values are not bounded”), and note is on the privacy deny list, so explanatory text belongs in guidance.

30.4 Recording results

RecordAIResult() attaches an interpretive result to the object with its supporting evidence attached, so a result cannot be separated from what backs it. ValidateAIEvidenceRefs() checks references against the ledger and enforces the scope and cross-kind rules: a reference may not scope outside the supporting evidence’s own scope, and AI-generated claims may not cite user_decision nodes.

30.5 RenderAIReport: the report checks itself

A round is not finished when execution returns. It is finished when the report has been rendered and its self-check agreed with the ledger.

sce2 <- scuttle::mockSCE(ncells = 200, ngenes = 80)
sce2 <- SingleCellExperiment::SingleCellExperiment(
    list(counts = SummarizedExperiment::assay(sce2, "counts"))
)

# RunDEtest() needs an identity to test against, so go through the same short
# preprocessing chain the driver chapter uses. `RunDEtest()` also refuses a
# single-group object: a marker test on one cluster is undefined, not empty.
sce2 <- NormalizeData(sce2)
sce2 <- FindVariableFeatures(sce2)
sce2 <- ScaleData(sce2)
sce2 <- RunPCA(sce2, ncomponents = 10)
sce2 <- FindNeighbors(sce2, dims = 1:10)
sce2 <- FindClusters(sce2, resolution = 1.0)
sce2 <- RunDEtest(sce2, only.pos = TRUE, name = "markers")

ledger <- GetAnalysisLedger(sce2)
nrow(ledger$analyses)
## NULL

RenderAIReport() re-derives every recorded claim level from the evidence and re-resolves every citation, then prints any disagreement in its own verification section. A round that is not rendered loses that check — and the next round then plans from a raw record, which is where overstated claims get quietly carried forward.

30.6 The failure this whole layer is against

Three failure modes are invisible without it:

  • a silent no-op, where a recorder swallowed an error and the ledger kept the earlier run’s numbers;
  • borrowed support, where two nodes from one analysis counted as independent confirmation;
  • an unrendered round, where nothing re-derived the claims that were made.

Each is a way for a document to say something stronger than the object it describes. The ledger, the ceiling, and the report are three views of the same defence.