How to investigate an undocumented system without confusing inference with fact
A working method for learning what an undocumented system does, and for keeping what you saw separate from what you assumed.
Most investigations of an unfamiliar system fail in the same way. Not because the engineer finds too little, but because the notes stop distinguishing between what was seen and what was concluded. Three weeks later a decision rests on a sentence like “the service retries failed payments”, and nobody remembers that this was a guess based on one log line.
The fix is a discipline, not a tool. Every statement you record gets one of four labels, and the label travels with the statement until it changes for a reason.
The four labels
- Observed. You saw it happen. A request returned a
409. A row appeared in a table. A log line printed. Observation is specific to the inputs, environment and moment you used. - Inferred. You reasoned your way to it from observations, code reading or naming. “The
refcolumn is probably an idempotency key.” Inferences are useful and often right. They are not facts. - Verified. You wrote a test that exercises the behavior, it passes, and someone else could run it again. Verification turns an observation or inference into something you can build on.
- Open. You do not know, and you have said so. “Something writes to
orders_archive. Nothing we found reads it.”
The failure mode is silent promotion: an inference gets repeated until it sounds observed, then gets treated as verified. Labels make promotion an explicit act.
Start from the outside
Begin with the system’s edges, not its source. The edges are what everything else depends on.
- Entry points. HTTP routes, queue consumers, scheduled jobs, CLI commands, database triggers. List each with how you found it: a route table (observed), a reference in a deployment manifest (observed), a method name that looks like a handler (inferred).
- Exits. Outbound HTTP calls, messages published, tables written, files produced, emails sent. These are the dependencies other teams will ask about.
- State. Which stores does it own, which does it merely read, and which does it share with something else?
Record the method for each item. “Found by grepping for router.” is a different confidence level from “found by capturing traffic for a day in staging”.
Exercise before you read
Reading code tells you what the system was written to do. Exercising it tells you what it does with the configuration, data and dependencies it actually has today. Both matter, and they disagree more often than you would expect.
A practical sequence for one entry point:
- Send the simplest valid request. Record the response, status, and any side effects you can see.
- Send it again, unchanged. Does the second call behave differently? Duplicate detection, caching and rate limiting all show up here.
- Remove each field in turn. Which ones are actually required, as opposed to documented as required?
- Send values at the boundaries: empty strings, very long strings, zero, negative numbers, a currency the docs do not mention.
- Watch what happens when a dependency is slow or down, if you have a safe way to simulate it.
Each result is an observation. Each explanation you attach to it is an inference. Keep them in separate columns.
Turn inferences into tests, or leave them labeled
An inference earns the verified label only when a repeatable test confirms it. The test does not have to be elaborate. For “ref repeated within 24 hours returns 409”, a test that sends two requests with the same ref and asserts on the second status is enough. For “the retry window is 24 hours”, you may not be able to wait, and that is fine: the inference stays an inference, and the report says what evidence it rests on.
Some inferences cannot be tested safely. A production-only batch job that moves money is a good example. Say so. “Not tested; would require production access” is a better sentence than a confident guess.
Keep a ledger, not a narrative
Narrative notes drift. A ledger does not. One row per statement:
| Statement | Label | Evidence | Date |
|---|---|---|---|
POST /v2/orders rejects unsupported currencies with 422 |
Verified | test_currency_rejected.py |
2026-10-21 |
| Rate limiting is per account, not per key | Inferred | Two keys on one account shared a limit in staging | 2026-10-21 |
Writes to orders_archive |
Observed | Row count increases after each create | 2026-10-20 |
Who reads orders_archive |
Open | No reader found in this repository | 2026-10-20 |
The ledger is what you hand to the next engineer, and what you check a decision against. When a label changes, change it in the ledger and note why.
Know when to stop
An investigation is finished when the decision it supports can be made. Not when everything is known. If the question is “can we change the coupon logic safely”, you need the coupon-related behaviors verified and the rest of the system’s dependence on them observed. You do not need the archive table explained.
Write the question down at the start. Check the ledger against it at the end. Anything still open that the decision depends on is a risk to name, not a gap to paper over.
Where Foundry fits
Foundry runs this process at scale and delivers the ledger: a behavior report in which every finding is marked verified, observed, inferred or open, together with a dependency map and the conformance tests that back the verified items. The method is described on the approach page.