Phase 13: Tools & Protocols

Evidence, Confidence, and Unknowns in Binary Analysis

A finding about a compiled program is a record with a hash, a confidence, an authority, and a list of what it cannot show.

Type: Build

Languages: Python, C

Prerequisites: Phase 13 · 32 (ELF from scratch), Phase 13 · 33 (disassembly and control flow), Phase 13 · 34 (lifting to pseudocode)

Time: ~105 minutes

🎯 Learning Objectives
  • Build an evidence record modelled on rea's Evidence with observed, derived, and inferred confidence.
  • Compute content-hash ids that stay the same when the file moves to another folder.
  • Record what each provider cannot show as limitations that travel with the record.
  • Refuse to state a result until an observed run of the function supports it.
  • Keep residual unknowns as revisioned records joined by a hash chain.
  • Cache provider answers under an exact key and refuse a stale replay.

The Problem

You ask an agent what grade in ledger-x86_64-O1.o returns for a score of 55. The agent calls rea, reads the Ghidra pseudocode, and answers 0. A reviewer asks how the agent knows. The agent has a block of text that Ghidra printed. For the default path, that text reads uVar1 = -(param_1 >> 0x1f) ^ (uint)((ulong)((long)param_1 * 0x66666667) >> 0x3f);. The bytes at that point say xor eax, eax.

The expression and the instruction both produce 0 for every input. The agent did not know that. It trusted the text because a tool produced it. A 2026 study ran three agents on 24 reverse engineering tasks (NDSS BAR 2026). It named four recurring weaknesses: training bias, over-trust in observations, context limits, and plan persistence. Another 2026 study planted text inside binaries and found that models proposed the planted unsafe action in 35 of 40 cases (arXiv 2607.12507).

rea 6.5.0 gives the agent a vocabulary for this. Every successful tool call produces one Evidence record with a confidence of observed, derived, or inferred, an authority, and a list of limitations. The packaged skill tells the agent: "Every conclusion must distinguish observations, inferences, and unknowns. Cite Evidence IDs, preserve limitations and incomplete coverage, and never imply that static analysis observed execution.".

Three things rea leaves to you. Its snapshots cache exact query results, not analysis. Its verification passes a claim when a comparison dimension is unchanged, and its own comment says that a pass "does not establish global implementation equivalence.". Its obligation ledger takes candidates only from JavaScript and managed graphs, so a native function never reaches verified through it.

This lesson builds the missing piece in plain Python. You write a ledger that stores every fact about the fixture as a record with a content hash. You make predictions about percent_change and grade, mark them inferred, and check them against runs of a test program you compile yourself. You import two real rea records and the snapshot they came from, and your ledger recomputes every hash rea wrote.

Three Words for Confidence, Five for Authority

confidence is an enum with three values, never a number. authority has five values. When a producer says nothing, rea uses shipped-artifact with observed.

ValueMeaning on the fixtureRecords in this lesson
observedA fact read from bytes or from a runthe ELF reader's section and symbol reads, the two Ghidra records, 17 host runs
derivedA fact computed from observed recordsthe switch table model, the division model, every unknown mutation
inferredAn expectation that no observation states yet18 predictions
shipped-artifactThe file on disk was the sourceevery read of ledger-x86_64-O1.o
controlled-replayA run you controlled produced the factthe host runs of harness.c
analyst-inferenceYour own judgementpredictions and unknown mutations
historical-referenceAn older source tree or documentnone here
external-serviceA service outside the file and the hostnone here

Two confusions cost the most time. The first treats pseudocode as an observation of behaviour. It is an observation of what Ghidra printed. The second treats an empty list as proof of absence. rea's design note is strict: "Missing coverage is unknown, not absence.". The ledger in code/main.py encodes both rules.

The Record

A record has exactly sixteen fields, and rea 6.5.0 rejects any other field. code/main.py keeps the same names as rea, so a record from either side validates on the other. The sixteenth field, relationship_coverage, says how complete a list of callers or callees is. Its value is complete, partial, or unknown, or null when the operation reports no call relationships.

FieldWhat it holds
evidence_idev_ and 64 hex digits, a hash of the content
subjectname, digest.sha256, format, architecture, local_path, or null
providerid, name, version or null
analysis_profilethe provider's settings and their digest, or null, always present
predicate_typea tag, rea.analysis by default
operation, parametersthe tool and its input
raw_result, normalized_resultprovider output and the canonical result
confidence, authoritythe enums above
environmentid, platform, architecture, isolation, or null
limitationsplain sentences
relationship_coveragestatus complete, partial, or unknown with diagnostics, or null
locationsaddress, address-range, artifact-path, file-offset, file-offset-range
evidence_linksthe ev_ ids this record rests on

This is the real record rea wrote for grade, cut to the fields that matter here. The full file is code/fixtures/rea-evidence-grade.json, 37,573 bytes.

json{
  "evidence_id": "ev_67b6d3f992eaf49a2e16de084b6ebfdd9884e8c5c7f75e175b7bb20f4f748ce1",
  "subject": {
    "name": "ledger-x86_64-O1.o",
    "digest": { "sha256": "9a4a8082ba5e285a732552981a6935e0c5255725664f380e37c44ebfb04d803f" },
    "format": "elf",
    "architecture": "x86_64",
    "local_path": "/private/tmp/aiefs-re/phases/13-tools-and-protocols/35-evidence-confidence-and-unknowns/code/fixtures/ledger-x86_64-O1.o"
  },
  "provider": { "id": "ghidra", "name": "Ghidra", "version": "12.1.4" },
  "analysis_profile": {
    "provider": { "id": "ghidra", "name": "Ghidra", "version": "12.1.4" },
    "parameters": { "jump_table_evidence": "typed-case-default-blocks-v1", "import_mode": "ephemeral-source-immutable" },
    "digest": "9c7f6b24be69294e61da046e4583e3856cb9e40433b149c1bf8bb534d2fe03ef"
  },
  "operation": "analyze_function",
  "parameters": { "procedure": "grade" },
  "confidence": "observed",
  "authority": "shipped-artifact",
  "environment": null,
  "limitations": [
    "Default-space addresses use lowercase 0x-prefixed hexadecimal; other address spaces use <percent-encoded-space>:0x<hex>.",
    "Resolved reference metadata identifies computed, indirect, external, call, jump, and data edges; unresolved targetless flows remain unknown, and function classifications distinguish thunks and externals."
  ],
  "relationship_coverage": {
    "status": "complete",
    "diagnostics": [
      "Only resolved Ghidra call references are returned; unresolved computed or indirect calls remain unknown, while function classifications distinguish thunks and externals."
    ]
  },
  "locations": [],
  "evidence_links": []
}

The real parameters object of the profile has 25 keys. The real limitations list has ten sentences. Keep them. A limitation is part of the content, so dropping one changes the id, and your ledger would then reject the record.

Note what the record does not say. environment is null because Ghidra read a file and ran nothing. locations is empty because this operation reports addresses inside normalized_result. relationship_coverage has the status complete. Its diagnostic says that only resolved Ghidra call references are returned, and that unresolved computed or indirect calls stay unknown. So the caller and callee lists are complete for resolved calls only.

The Id Is a Hash of Content, and the Path Is Not in It

rea computes evidence_id as ev_ plus SHA-256 over the RFC 8785 canonical JSON of a projection of the record. The projection keeps the subject's digest, format, and architecture and drops name and local_path. It drops relationship_coverage when its value is null, so a record without call relationships keeps the same id as before the field existed. Everything else, including raw_result and every limitation, is hashed.

RFC 8785 sorts object keys, removes whitespace, and fixes the number and string forms. For records whose numbers are integers, Python's standard library produces the same bytes:

pythondef canonical_json(value):
    return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False)

code/main.py refuses floats before it hashes, because RFC 8785 and Python print some floats differently. The two rea records and the snapshot hold no floats, so the recipe reproduces every rea hash:

rea valueRecomputed by code/main.py
ev_67b6d3f992eaf49a… for gradeequal
ev_e88b10dc0c6400e5… for percent_changeequal
profile digest 9c7f6b24be69294e… over {provider, parameters}equal
query_26122aebe9d8abae… for the grade snapshot entryequal
query_0f95ef38038d3666… for the percent_change entryequal

Why exclude the path? The same observation of the same bytes gets the same id on any machine and in any folder. The local_path in the listing above names a folder on one Mac. The id does not change when you copy the fixture to /home/you/re. rebind_subject_path() lets you show a record under a new path. It refuses when sha256, format, or architecture differ. rea 6.5.0 does the same with rebindEvidenceToTargetPath when it replays a snapshot record for a target at a new path.

validate_evidence() recomputes the id and rejects a mismatch with rea's sentence, "Evidence semantic identifier does not match its record". It also rejects a record whose analysis_profile.provider differs from its provider. The test test_tampered_id_and_mismatched_profile_are_rejected edits one line of pseudocode inside the real grade record and watches the ledger refuse it.

Observe the Object Yourself

Before any provider, the ledger reads the file. observe_object() parses the ELF64 section headers, .symtab, .strtab, and .rela.text with struct.unpack_from. It writes one inspect_object record and one read_function_bytes record per function symbol. All five are observed with authority shipped-artifact, because the source was the file on disk.

RecordContentId
inspect_objectfive sections with offsets, sizes, and hashes, four functions, three relocationsev_645e0a145f28bd5c…
read_function_bytes percent_change16 bytes at .text+0x00ev_2572a7d254ad15cb…
read_function_bytes grade46 bytes at .text+0x10, SHA-256 ffcb75c2a7bb1104…ev_a467c2427bfc0c56…
read_function_bytes total31 bytes at .text+0x40ev_b1e845cea2543073…
read_function_bytes grade_label63 bytes at .text+0x60ev_c50041be26e6b7f4…

llvm-objdump -t agrees: grade at 0000000000000010 with size 2e. The three relocations in .rela.text are R_X86_64_PC32 against .rodata at offsets 0x36, 0x84, and 0x93, with addends 0x10, 0x24, and -0x4. Each record carries a file-offset-range location and one limitation: "Section bytes are read from the file on disk. The reader applies no relocation and runs no code."

Lesson 32 builds this reader in full. Here it is 60 lines, because this lesson is about what you record about the read.

Derive the Table, and Say That You Derived It

The 46 bytes of grade contain four patterns that lesson 33 decoded:

BytesInstructionOffset in grade
48 69 c0 67 66 66 66imul rax, rax, 0x666666673
48 c1 f9 22sar rcx, 3413
83 c1 faadd ecx, -623
83 f9 04 77 0ccmp ecx, 4 then ja28

A multiply by 0x66666667 followed by a shift of 34 is a signed division by 10. The file holds no 10. derive_grade_model() writes that fact as a limitation: "The divisor 10 is a derivation from the multiplier 0x66666667 and the shift 34. The file holds no 10."

The add ecx, -6 sets the first case to 6. The cmp ecx, 4 with an unsigned jump admits five cases, 6 to 10.

The lea at .text+0x33 carries the relocation with addend 0x10. A PC32 relocation measures from the end of the 4-byte displacement, so the table starts at .rodata+0x14. Five little-endian words there read 1, 2, 3, 4, 4. The switch in ledger.c returns 1 for 60 to 69, 2 for the seventies, 3 for the eighties, and 4 for 90 to 100. The compiler turned the switch into a lookup table of return values.

The resulting record ev_605106bff2d9998f… has confidence derived, and its evidence_links name the two observed records it rests on. Remove the bytes record from the ledger and the derived record has a dangling link. The figure above shows that state when you turn off ev_a467c242….

Now compare with Ghidra. Its pseudocode for the same bytes:

cuint grade(int param_1)
{
  uint uVar1;
  uint uVar2;

  uVar2 = param_1 / 10 - 6;
  uVar1 = -(param_1 >> 0x1f) ^ (uint)((ulong)((long)param_1 * 0x66666667) >> 0x3f);
  if (uVar2 < 5) {
    uVar1 = *(uint *)(&DAT_001000b4 + (ulong)uVar2 * 4);
  }
  return uVar1;
}

Ghidra applied the relocation and named the table DAT_001000b4, which is .rodata+0x14 after it placed .text at 0x100000. It recovered the division by 10 and the five cases. For the default value it printed an expression that is always 0 and never says 0. The p-code in native_value_flow marks those operations is_dead: true. The record is a faithful observation of Ghidra. The meaning you read from it is derived, and the value you expect from a run is inferred.

The percent_change record shows the other failure. Ghidra's signature reads undefined1 [16] percent_change(int param_1,int param_2,ulong param_3). The source says int percent_change(int before, int after). idiv writes EDX:EAX, and the zero path leaves EDX untouched, so the decompiler inferred a 16-byte return and a third parameter. Its own native_api.return_type.confidence for grade is "low". Treat a recovered signature as the provider's inference, and record it that way.

Predictions Are Inferred Records

A prediction is a claim about behaviour that no observation has stated. claim_suite() builds eighteen of them from the derived models and from the source. record_prediction() stores each one as a record with confidence inferred and authority analyst-inference, linked to the records it rests on.

ClaimPredicted valueRests on
percent_change(12400, 15500)25, from 310000 / 12400division model ev_076a9167…
percent_change(0, 5)0, the zero guarddivision model
percent_change(100, 50)-50division model
percent_change(-7, 3)-142, because idiv truncates toward zerodivision model
grade(55)0, index -1 fails the unsigned comparetable model ev_605106bf…
grade(60), grade(69)1, 1table model
grade(70), grade(85)2, 3table model
grade(95), grade(100), grade(105)4, 4, 4table model
grade(110), grade(-5)0, 0table model
total(1, 2, 3), total()6, 0source record ev_2f1e6d25…
grade_label(85)"B"source record and table model
grade(score) for 90 to 1004 for all eleven inputstable model

predict() uses C semantics, not Python semantics. c_div() truncates toward zero, and wrap32() folds every intermediate into 32 bits. Python's // rounds toward negative infinity, so (3 - -7) * 100 // -7 gives -143 and the C program gives -142. Lesson 36 makes this grid much larger.

Each prediction opens one residual unknown. The unknown asks for observed evidence with authority controlled-replay and recommends one probe: execute_function on the host build. The next section produces that evidence. The question for the first grade claim reads "Does grade(95) == 4 hold when the compiled function runs?"

Check Them Against a Run

code/fixtures/harness.c includes ledger.c and prints one result per invocation. observe_runs() compiles it with the first of clang, cc, or gcc on your PATH and runs every input in the grid once. Each run becomes one record:

json{
  "operation": "execute_function",
  "parameters": { "function": "grade", "args": [95] },
  "normalized_result": { "result": 4, "stdout": "4\n", "exit_code": 0 },
  "confidence": "observed",
  "authority": "controlled-replay",
  "environment": { "id": "host-process", "platform": "darwin", "architecture": "arm64", "isolation": "process" },
  "limitations": [
    "The run executed a host build of ledger.c, not the x86-64 object. The claim about the object rests on the shared source and on the compiler.",
    "One run observes one input. It proves nothing about other inputs."
  ]
}

The subject of a run record is the harness binary, a Mach-O arm64 file on this Mac, with its own SHA-256. The provider is course-harness with the compiler's version line, here Apple clang version 17.0.0 (clang-1700.6.3.2). The evidence_links name the source record ev_2f1e6d251543d017…, because the run and the object share ledger.c and nothing else.

Read the first limitation twice. The x86-64 object never ran on this machine. What ran was a second build of the same source for another architecture. That is still an observation, and it is the strongest one available without an emulator, so the claim passes with that sentence attached. A report that omits it has lost information the reader needs.

The 17 single-input runs all agreed with the predictions. code/fixtures/run-evidence.json holds those 17 records and the source record, exported with --record on 2026-10-13. When no compiler is present, build_ledger() imports that bundle instead of running, and the report says so in its runs field.

Promotion Rules

A claim moves from inferred to supported only through observed runs. check_claim() looks for execute_function records with the same function and arguments, compares each result with the prediction, and counts the covered inputs:

StatusConditionrea's completion ledger
passevery input has an agreeing run and none disagreespass
failany run disagreesfail
unknownsome inputs have runs, others have noneunknown
unsupportedno input has a rununsupported

promote_claim() is the strict form. It raises ClaimError when any id is inferred or derived. It raises when an id names a run of another function or of other arguments. It raises when the list is empty, and when the runs cover fewer inputs than the claim. The Ghidra pseudocode record and the table model both exist for grade(55), and neither can promote it. Turn off ev_7be90c0c… in the figure and watch the claim fall to unsupported while two records about grade remain.

The range claim shows the partial case. Only two of its eleven inputs, 95 and 100, appear in the run grid. check_claim() returns unknown with covered 2 of 11, and promote_claim() refuses with "2 of 11 inputs have observed runs". The ledger does not round that up.

completion_ledger() mirrors rea's EvidenceCompletionLedger. Its id is ecl_ plus the hash of its records, and complete is true only when pass equals total. The demo prints ecl_da64933ea13befed… with complete: false, because of that one range claim.

Unknowns Are Records with a Hash Chain

rea stores a residual unknown as a strict record. code/main.py keeps the same fields and the same invariants.

The id is unk_ plus SHA-256 over {domain, question, scope_digest}, with the question's whitespace collapsed to single spaces. Two analysts who ask the same question about the same file get the same id. The scope_digest is the SHA-256 of the object.

Every change is a new revision. Revision 1 has previous_revision_digest null. Every later revision names the revision_digest of the one before it, and the digest covers every field except itself. verify_unknown_chain() walks the list and recomputes each digest. It checks that each link points at the previous revision. It checks that the mutation evidence each revision names is in the ledger.

Each mutation is itself an evidence record, as rea does with provider rea-unknown-registry. Ours carries confidence derived, authority analyst-inference, and no subject. Its limitation reads "A registry mutation records what the analyst decided. It is not proof of the answer."

That is why the demo's 81 records include 36 derived mutation records: 18 openings and 18 updates.

Three invariants are enforced on every revision:

  • Only a resolved unknown carries a resolution, and only revision 1 has no predecessor.
  • A contradicted unknown names at least one contradicting id, and no id appears in both lists.
  • A verified resolution names at least one evidence id with the required confidence and authority.

The third rule is where the ledger refuses most often. rea's workflow notes say "Only qualifying observed evidence can verify a resolution.". resolve_unknown() therefore rejects the table model, rejects an observed record with authority shipped-artifact, and accepts the run record ev_f4ffbe8f922ec7fe…. The resolved revision for grade(95) is revision 2, and its previous_revision_digest is ffca840b55a8a188…, the digest of revision 1.

update_unknown() takes expected_revision and raises revision-conflict when it is stale, as rea's update_unknown tool does. The right reaction is the one rea's notes give: reread the current revision and decide again.

One unknown stays open after the demo: unk_f2eb0d7f84c80a19…, "Does grade(score) == 4 for every score from 90 to 100 hold when the compiled function runs?". Its status is investigating, its supporting ids include the two runs, and its probe still names the nine inputs without a run.

A Cache of Answers Is Not a Database of Analysis

rea has three things that look like a cache. The Ghidra target snapshot is a private copy of the input, imported with -readOnly -deleteProject and deleted on close. The in-memory AnalysisSnapshotCache holds up to 10,000 entries for one binary identity. The snapshot file holds target, binding, entries, workflow_entries, and an evidence_bundle.

The cache key is query_ plus SHA-256 over {target, binding, operation, parameters}. target is {sha256, kind, format, architecture}, and a raw-binary target also has entry_point. binding is the provider and the whole analysis profile, digest included. Change any of the 25 profile parameters, for example jump_table_evidence from typed-case-default-blocks-v1 to a v2, and the digest changes, so every key changes.

code/fixtures/rea-snapshot.json is the file rea wrote during the two runs, 142,690 bytes. rea wrote it with owner-only permissions, and the committed copy is readable by everyone. It has two entries and an evidence bundle with the same two records. AnswerCache.import_snapshot() recomputes each query_id and refuses the file when any differs. It refuses a snapshot whose target or binding differs from the active one, with rea's wording profile_mismatch. It refuses a snapshot whose format_version is not course-answer-cache-1.

Three measurements from this machine. The first grade query took 8.6 s, with a fresh Ghidra import. The percent_change query against the same snapshot took 8.5 s, because a different query is a cache miss. A third grade query took 2.1 s and returned the identical record with the identical evidence_id. The in-memory replay in rea adds the limitation "Loaded from a local REA analysis snapshot; this call did not re-run the provider.". Our lookup() adds a sentence of the same kind, so a reader can tell a replay from a fresh answer.

A mutation ends the cache. In rea 6.5.0, a rename or a comment clears the snapshot cache before rea sends the call to the provider. A failed reply cannot prove that the write rolled back, so a failed write also stops replay. A metadata import that changes the database does the same.

Why an Agent Needs This

An agent that drives rea through MCP sees the full record on every call and can cite evidence_id in its report. The research from 2026 says where that goes wrong. Over-trust in tool output was one of the four weaknesses in the NDSS BAR study. In the planted-text study, showing binary content as data alone still left 15 unsafe proposals of 40, and provenance checks with tool authorization removed them. A third study watched an agent drive Ghidra through MCP (arXiv 2606.06838). In binaries with hundreds of functions, only 10 to 15 percent received real analysis before the agent produced stubs.

The ledger answers each of these with a record, and the agent reports only what the ledger holds. A claim without an observed run is unsupported. A claim with some runs is unknown. A provider text is observed about the provider and nothing more. Give the agent the skill file in outputs/ and make the ledger the only place it writes conclusions. That holds for Claude, Cursor, Codex, OpenClaw, Hermes, or any agent that reads SKILL.md.

Build It

Run the standard-library demo:

bashcd phases/13-tools-and-protocols/35-evidence-confidence-and-unknowns
python3 code/main.py

The demo reads the object, imports the two rea records, verifies the rea snapshot, and derives the two models. It then records 18 predictions, opens 18 unknowns, compiles and runs the harness, checks every claim, and resolves 17 unknowns. Expected shape:

json{
  "records": 81,
  "by_confidence": { "observed": 25, "derived": 38, "inferred": 18 },
  "rea_snapshot": { "entries": 2, "replay_hit": true, "stale_profile_refused": true },
  "grade_table": [1, 2, 3, 4, 4],
  "runs": "live: Apple clang version 17.0.0 (clang-1700.6.3.2)",
  "claims": [ { "claim_id": "grade(95)", "status": "pass", "covered": "1/1" }, { "claim_id": "grade(90..100)", "status": "unknown", "covered": "2/11" } ],
  "completion_ledger_id": "ecl_da64933ea13befed9483f8b00447bb3f4ceb74324c01e2515eff03f3af3ca750",
  "complete": false,
  "unknowns": { "resolved": 17, "investigating": 1 }
}

Without a C compiler, runs reads imported 17 recorded run records from run-evidence.json and the rest is the same.

Read code/main.py in this order:

  1. canonical_json() and compute_evidence_id() hold the hash recipe and the semantic projection.
  2. create_evidence() and validate_evidence() build and check a record.
  3. EvidenceLedger.record() and import_bundle() store records and refuse conflicts.
  4. open_unknown(), update_unknown(), and resolve_unknown() keep the revision chain.
  5. observe_object() reads the ELF, and derive_grade_model() reads the table.
  6. claim_suite(), predict(), and record_prediction() write the inferred tier.
  7. observe_runs() turns each harness run into an observed record.
  8. check_claim(), promote_claim(), and completion_ledger() apply the promotion rules.
  9. AnswerCache keys answers like rea's snapshots and refuses stale ones.

Use It

Keep one ledger per target and per investigation. Put the target's SHA-256 in every subject and the question's scope digest in every unknown. When you change tools, change the provider and let the ids diverge. When you rebuild the fixture, the subject digest changes and every old record becomes evidence about another file.

Write limitations at the moment you know them. The reader that applies no relocation says so. The run of a host build says so. The model that infers a divisor says so. A record written without its limitations looks stronger than the facts allow, and a later reader cannot repair that.

Treat every provider text as data. Strings and pseudocode from the target are text that the target controls. They never become instructions, and they never become observations of behaviour. rea 6.5.0 marks such results with _meta.provenance.untrusted_content: true. Its server instructions tell the model to treat target content as data and to report any request in it to run, install, download, or send something as a prompt-injection attempt. The marker helps, but your ledger must still keep target text out of the instructions.

Interactive Lab

Lab A: recompute a rea id

From the code directory, open Python:

bashcd phases/13-tools-and-protocols/35-evidence-confidence-and-unknowns/code
python3 -q

Run:

pythonfrom main import *
record = load_evidence_file(FIXTURES / "rea-evidence-grade.json")
record["evidence_id"] == compute_evidence_id(record)
record["subject"]["local_path"] = "/home/you/re/ledger-x86_64-O1.o"
record["evidence_id"] == compute_evidence_id(record)
record["limitations"].pop()
record["evidence_id"] == compute_evidence_id(record)

The first two comparisons are true. The path is not in the hash. The third is false, because a limitation is content.

Lab B: refuse a promotion

pythonledger, report = build_ledger(live_runs=False)
claim = [c for c in claim_suite(ledger.find(operation="derive_switch_model")[0], ledger.find(operation="derive_arithmetic_model")[0], ledger.find(operation="read_source")[0]) if c.claim_id == "grade(55)"][0]
promote_claim(ledger, claim, [ledger.find(operation="derive_switch_model")[0]["evidence_id"]])

The call raises ClaimError and names the derived id. Now pass the run id:

pythonrun = run_records_for(ledger, "grade", [55])[0]
promote_claim(ledger, claim, [run["evidence_id"]])["status"]

The status is pass, and the returned limitations carry the host-build sentence.

Lab C: refuse a stale snapshot

pythonimport json
snapshot = json.loads(REA_SNAPSHOT_PATH.read_text())
target, binding = rea_snapshot_binding(snapshot)
AnswerCache(target, binding).import_snapshot(snapshot)
stale = dict(binding["analysis_profile"]["parameters"], jump_table_evidence="typed-case-default-blocks-v2")
AnswerCache(target, {"provider": binding["provider"], "analysis_profile": make_profile(binding["provider"], stale)}).import_snapshot(snapshot)

The first import returns 2. The second raises CacheError with profile_mismatch. One version string changed, and the whole snapshot is refused.

Lab D: break the chain

pythonunknown_id = [u for u, r in ledger.unknowns.items() if r[-1]["status"] == "investigating"][0]
ledger.verify_unknown_chain(unknown_id)
ledger.unknowns[unknown_id][0]["severity"] = "critical"
ledger.verify_unknown_chain(unknown_id)

The first call returns 2. The second raises EvidenceIntegrityError, because revision 1 no longer matches its own digest and revision 2 points at a digest that no longer exists.

Practice Lab

Close the open unknown and add a second provider.

Requirements:

  • Extend run_grid() so the range claim's nine missing inputs run, then confirm the unknown moves to resolved at revision 3.
  • Add llvm-objdump -d as a second provider with its own provider object and record its listing of grade as observed.
  • Write a derived record that states the two listings, Ghidra's and llvm-objdump's, decode the same 14 instructions, and link both.
  • Open an unknown for grade_label(105) with severity high, since the table index 4 reads labels[4], and resolve it with a run.
  • Export the ledger with export_bundle(), import it into a fresh ledger, and prove every chain still verifies.

Success means a report in which every sentence cites an id from the bundle and the one unknown claim is gone.

Lesson Artifact

This lesson produces outputs/skill-evidence-ledger.md. It tells an agent how to write every finding as a record with confidence, authority, limitations, and links. It tells the agent how to keep unknowns as revisions, and when a claim is pass, fail, unknown, or unsupported.

Verify It

Run the demo and the deterministic suite:

bashcd phases/13-tools-and-protocols/35-evidence-confidence-and-unknowns
python3 code/main.py
python3 -m unittest discover -s code/tests -v

The 28 tests prove:

  • both rea evidence_id values, the profile digest, and both snapshot query_id values recompute from content
  • a changed path keeps the id and a changed limitation changes it
  • a tampered record, a mismatched profile provider, a float, and an unknown enum value are rejected
  • a duplicate record returns duplicate, a conflicting record raises, and a bundle import is atomic
  • the recorded run bundle imports with 18 records and no dangling links
  • an unknown opens at revision 1, refuses a stale expected_revision, and resolves only with observed controlled-replay evidence
  • an edited revision breaks the chain
  • promotion refuses inferred, derived, empty, and unrelated evidence, and passes with an agreeing run
  • partial coverage is unknown and a disagreeing run is fail
  • the cache hits on the exact key and misses on other parameters
  • the cache refuses a bumped profile version, a wrong target, a wrong format version, and a tampered key
  • the ELF reader agrees with llvm-objdump on offsets, sizes, bytes, and relocations, and the table reads 1, 2, 3, 4, 4
  • the predictions use C division, and the recorded runs agree with all 17 single-input claims
  • with a compiler present, the live runs agree too

Production Failure Modes

FailureWhat the weak report saysWhat the ledger requires
Pseudocode read as behaviour"grade returns 0 for failing scores"an execute_function record for that input, or unsupported
Decompiler signature trusted"percent_change returns 16 bytes"the signature marked as the provider's inference with its low confidence
Empty list read as absence"the function has no callers"an unknown status, or a coverage field when the tool provides one
Path hashed into the id"the record changed" after a copythe semantic projection without name and local_path
Limitation dropped from a copya record that looks stronger than the sourceid mismatch and rejection on import
Partial grid rounded up"all scores from 90 pass"unknown with covered 2 of 11
Host run presented as the object's run"the x86-64 object returned 25"the host-build limitation carried on the claim
Unknown closed by a sentence"resolved, see above"a verified resolution with observed ids of the required authority
Stale revision overwrittentwo analysts' edits colliderevision-conflict and a reread
Old snapshot replayed after a releaseanswers from a changed contractprofile_mismatch on import
Cache kept after a renamea replay that hides a metadata writeinvalidation before dispatch and a failed export

Operational Rule

Record what you read, what you computed, and what you expect as three kinds of record with three words. Close an unknown with an id, never with a sentence. Replay an answer only under the exact key it was stored with, and refuse it when one version string differs.

Further Reading

This free lesson is part of the AI Engineering from Scratch curriculum. Read the full explanation, run the lesson code, and verify the result in the interactive reader or from the repository source.

Browse the complete course catalog or open this lesson on GitHub.