# 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.

Published 2026-10-24 by Maxint. Written for: Engineers, Engineering leaders.
Canonical: https://foundry.maxint.com/resources/investigating-undocumented-systems

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 `ref` column 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.

1. **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).
2. **Exits.** Outbound HTTP calls, messages published, tables written, files produced, emails sent. These are the dependencies other teams will ask about.
3. **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](/approach).
