diff --git a/plans/tecnix-target-eval-caching/explainer.md b/plans/tecnix-target-eval-caching/explainer.md
new file mode 100644
index 0000000000..e95197e3b7
--- /dev/null
+++ b/plans/tecnix-target-eval-caching/explainer.md
@@ -0,0 +1,492 @@
+# Tecnix: Exact Target Dependencies and Cross-Commit Eval Caching
+
+**Abstract.** Nix builds are input-addressed: a derivation's inputs are hashed, and identical inputs need never be rebuilt. Tecnix extends the same idea one level up, to evaluation itself. Each target's evaluation result is addressed by the content hashes of the source files that evaluation actually read — an artifact we call the target's *source closure*. The closure serves simultaneously as the target's exact dependency set and as a proof of cache validity: a stored result may be reused at *any* commit whose relevant sources still match, and must be recomputed at any commit where they do not. This document explains why that requires cooperation from the evaluator, how dependency information is threaded through Nix's lazy, memoized evaluation at near-zero cost, and how the resulting cache reduces warm evaluation time by orders of magnitude.
+
+*Reading guide: users of the system need §§1–5 and §9. §§6–8 cover the evaluator internals. §10 concerns maintenance of the fork and may be skipped by everyone else. A glossary appears at the end. For the system in motion — one request traced from call to answer — see the companion document, 'The Life of a Target Evaluation'.*
+
+---
+
+## 1. The Problem
+
+Consider a repository with many thousands of build targets. A developer changes one file. Which targets must CI re-test? Which merge-queue candidates actually conflict? Is anything in the developer's environment now stale? Today, answering any of these questions requires evaluating the repository's build logic — which is the very cost the answers were supposed to help avoid.
+
+Two capabilities resolve this, and they turn out to be two views of one artifact:
+
+**The target dependency graph.** For every target, the precise set of source paths on which its computed result depends:
+
+```json
+{ "//app/web:server": [ "app/web/target.nix", "lib/common.nix", ... ], ... }
+```
+
+**Target evaluation caching.** The ability to skip computing a target entirely when the sources it depends on have not changed.
+
+A single requirement shapes the design: both must remain valid **across commits**. The system runs on every push, every merge-queue candidate, and every developer checkout after every pull. Dependency information tied to the commit that produced it would have little value; the value lies in reusing previous work against a new tree.
+
+Exactness is equally fundamental, because dependency information is a substrate for many downstream decisions: precise test selection in CI, concurrent evaluation of merge-queue candidates, local staleness detection ("your development environment is stale after that pull — your `Gemfile.lock` changed"), and evaluation cheap enough that correctness can be verified on every invocation, in the manner of `bundle exec`.
+
+### 1.1 Three ways to fail
+
+It is useful to fix, at the outset, the three failure modes available to any design in this space. Every mechanism in this document exists to defend against one of them.
+
+| failure | consequence |
+|---|---|
+| **Under-tracking** — a real dependency is missed | *Wrong answers.* A stale cached result is served as current. This is the fatal failure mode, and the design treats any instance of it as a correctness defect. |
+| **Over-tracking** — spurious dependencies are included | *Useless answers.* If every target depends on everything, every commit invalidates everything; hit rates collapse and affected-sets balloon. Sound, but pointless. |
+| **Slowness** — exact answers at impractical cost | *Unused answers.* The gold standard — evaluate each target in a fresh evaluator and log its reads — is exact and hopelessly slow. |
+
+The labels of §6 defend against under-tracking; their per-value granularity — a target inherits only the production history of the values it actually touches — bounds over-tracking; nearly everything else defends against slowness.
+
+One subproblem is contained within the problem. Before targets can be computed, the system must determine which targets *exist* — and in a monorepo the target list is itself derived from the source tree, so it is subject to the same staleness question as the targets. Discovery proves to be the same problem one level up, solved by the same machinery; it is treated in §9 alongside the public interface.
+
+---
+
+## 2. The Setting: Targets Are Programs
+
+Targets in this repository are not declared in a static manifest. Their meaning is computed by Nix evaluation: repository code imports files, reads configuration, tests for the presence of optional overrides, and ultimately produces each target's result, which is a derivation.
+
+### 2.1 Nix evaluation as a memoized value graph
+
+The model on which the entire design rests is the following. Evaluating a Nix expression does not proceed from top to bottom. The evaluator lazily expands a graph of *thunks* (suspended computations) into *values*, on demand, and memoizes every result. A file is imported once; a shared helper is computed once; thousands of targets that use the same library all refer to the same finished value cells.
+
+Tecnix's central move can now be stated in one sentence: **it records file accesses alongside this graph.** As the evaluator expands values, every source read, directory listing, and existence check is attached, in the form of a compact label, to the value whose production caused it. Dependency information thereby becomes part of the memoized graph itself and flows to consumers in exactly the way values do.
+
+### 2.2 Why the mechanism belongs in the evaluator rather than in derivations
+
+Nix already possesses an input-addressed caching mechanism: the derivation. A `.drv` file hashes its build inputs, so identical inputs yield identical builds. It is natural to ask whether this mechanism suffices.
+
+It does not, because the derivation is the *output* of evaluation, not its input. Which derivation a target produces is determined by evaluation-time behavior — which files are imported, what `readFile` returns, whether `pathExists` finds an optional override — and none of these reads is recorded anywhere in the resulting derivation. Derivation-level caching answers the question "have these inputs been built before?"; it cannot answer "would evaluation produce the same derivation?". Answering the latter by evaluating is precisely the cost to be avoided.
+
+Tecnix therefore makes evaluation itself input-addressed, with the source closure (§4) playing the role for evaluation that the input hash plays for builds.
+
+Pure evaluation is load-bearing for this construction. The addressing is sound only if every input to evaluation flows through a channel that can be fingerprinted: the pinned git tree, the overlay of uncommitted changes, and the declared arguments. Impure evaluation may consult environment variables, the clock, or arbitrary filesystem paths, none of which a source closure can certify. The persistent cache therefore engages only under `pure-eval`. Impure evaluation continues to function and is still tracked within a run, but its results are never trusted across runs.
+
+### 2.3 Design constraints
+
+The implementation was required to satisfy the following constraints simultaneously; they recur throughout the document.
+
+- **High performance.** Dependency evaluation over all targets must be practical, and warm runs must be faster than cold runs by orders of magnitude.
+- **Approximately zero allocations during evaluation.** The tracking machinery executes inside the evaluator's innermost loop. Most of the techniques described below are, at bottom, allocation-avoidance techniques.
+- **Minimal effect on parallel evaluation; minimal locking.** The parallel evaluator must continue to scale; tracking may not introduce contended locks on hot paths.
+- **Simplicity.** Complexity is admitted only where it changes what the system can do. Speculative machinery is removed rather than retained.
+- **Minimal invasiveness.** The codebase is a fork that merges upstream Nix regularly. The tracking system resides in its own module, and upstream files carry only a small, stable set of hooks.
+- **Genericity.** The new builtins encode no knowledge of any repository's conventions. They operate on any git repository, which also permits correctness to be tested against small synthetic repositories, independently of the monorepo.
+
+---
+
+## 3. The Central Idea
+
+### 3.1 Why simpler designs fail
+
+Consider the obvious design first: while target *T* is being evaluated, log every file read. This design is defeated by memoization.
+
+```mermaid
+sequenceDiagram
+ participant A as Target A
+ participant T as shared value
(import lib/common.nix)
+ participant FS as sources
+ participant B as Target B
+ A->>T: force
+ T->>FS: read lib/common.nix (the only physical read that ever occurs)
+ T-->>A: finished value (memoized)
+ B->>T: force
+ T-->>B: same value — no read occurs
+ Note over B: a read log records nothing for B
+```
+
+Target B depends on `lib/common.nix` in every meaningful sense: if that file changes, B's result may change. But B never touches the filesystem, because the graph already contains the answer. A per-target read log therefore under-tracks every target that arrives after the first — failure mode one, in its purest form.
+
+The remedy follows directly from the model of §2.1. If values are the unit of sharing, then values must carry the dependency information:
+
+```
+a source read during the production of a value → the value receives a label
+a target forces a value → the target inherits the value's label
+a target's dependencies → the union of inherited labels
+ and its own direct reads
+```
+
+> **Why not…?**
+>
+> *…trace at the syscall layer (strace, fanotify)?* The same memoization argument applies — the read simply never happens for the second consumer — and syscall traces additionally cannot attribute a read to a *target*, only to a process.
+>
+> *…hash the whole repository as one input?* That is maximal over-tracking: every commit invalidates every target. Sound and useless (failure mode two).
+>
+> *…restrict what evaluation may read, and treat the allowed set as the dependency set?* Restriction is not attribution. Knowing what evaluation *may* read says nothing about what a particular target *did* read.
+>
+> *…isn't this flake evaluation caching?* Flake caching keys an entire evaluation by the hash of its locked inputs — all-or-nothing, and keyed by revision. Tecnix operates per target, and reuses results by validating content rather than trusting keys, which is what makes cross-commit reuse possible (§4.1).
+
+### 3.2 The correctness contract
+
+The design carries an executable specification, enforced by the test suite over deliberately adversarial sharing patterns:
+
+> **Oracle.** Evaluating a target in isolation, in a shared evaluator, in a shared parallel evaluator, and answering from the warm cache must all yield identical dependency sets. Under-tracking and cross-target contamination are correctness defects, not performance trade-offs.
+
+Because the builtins are generic (§2.3), the oracle is exercised against small synthetic git repositories constructed by the tests themselves. No monorepo is involved in verifying correctness.
+
+### 3.3 The target-evaluation pipeline
+
+The remainder of the document descends through the layers of Tecnix target evaluation and caching. It may help to hold the whole pipeline in view first:
+
+```mermaid
+flowchart TB
+ subgraph API["§9 Public interface"]
+ B1["tecnixTargetNames
(discovery)"]
+ B2["tecnixTargets
(evaluation)"]
+ end
+ subgraph CACHE["§8 Persistent cache (SQLite)"]
+ LOOK["stored source closures,
validated against the current tree (§4)"]
+ end
+ subgraph EVAL["§6 Tracked evaluation"]
+ LBL["labels on values
frames on the stack
interned path/set identifiers"]
+ end
+ subgraph SRC["§7 Source observation"]
+ ACC["git tree at pinned commit
+ dirty-checkout overlay"]
+ end
+ B1 & B2 --> CACHE
+ CACHE -- "closure matches:
hit, skip evaluation" --> OUT["result"]
+ CACHE -- "miss" --> EVAL
+ EVAL --> SRC
+ EVAL -- "flatten + fingerprint
fresh closure" --> CACHE
+ EVAL --> OUT
+```
+
+---
+
+## 4. Source Closures
+
+The tracked output for a target is its **source closure**: the set of source paths that certify its evaluated result, each paired with a *fingerprint* of that path's current state.
+
+A note on terminology: this use of "closure" is unrelated to the runtime closure of a store path. Here the word refers to the set of source files that close over an evaluation result — everything that could have influenced it.
+
+```json
+{
+ "//app/web:server": {
+ "app/web/target.nix": "git:8a1f…;mode=100644",
+ "lib/common.nix": "git:03bc…;mode=100644",
+ "app/web/vendor": "git:77e2…;mode=040000",
+ "app/web/local.nix": "absent"
+ }
+}
+```
+
+Three properties of this structure deserve attention.
+
+**Fingerprints are git-native.** The fingerprint of a clean path is its git object identifier plus the git file mode observed at that path. The object identifier is content-addressed and cheap to obtain from the repository; the mode is included because Nix source materialization observes executable bits even when file contents are unchanged. The fingerprint of a *directory* currently uses its tree object identifier and tree mode, which changes whenever anything beneath the directory changes; consequently, "the target listed this directory" is captured by a single record that remains conservatively correct. Uncommitted changes extend the fingerprint with a content hash of the modified files:
+
+```
+git:;mode= clean file or directory, at this commit's tree
+git:;mode=;dirty= git base, plus a hash of uncommitted content beneath the path
+absent the path does not currently exist
+absent;dirty= no git object exists, but uncommitted content does
+```
+
+This path-level fingerprinting is intentionally conservative. There is room to refine the closure model from "this path's full fingerprint" toward "the specific property of this path that evaluation observed." For example, a directory listing depends on the complete child name/type map it returned — which also proves that no other child names were present — not necessarily on the full tree object ID or on every descendant's content. The current tree fingerprint is exact enough to avoid stale cache hits, but it can over-invalidate; a future closure format could record operation-specific observations such as existence, file type, executable bit, symlink target, directory child name/type sets, or file contents separately.
+
+**Negative lookups are dependencies.** The entry `"app/web/local.nix": "absent"` records that evaluation checked for a file that was not present — an optional import, or a `pathExists` call. Should the file appear in a later tree, the closure ceases to match and the target is re-evaluated. Omitting this class of dependency is a classic source of staleness bugs; here it is a first-class citizen of the closure.
+
+**The closure serves as the cache's proof of validity.** Validity is established by content, not by name, as the next section describes.
+
+### 4.1 Cache reuse: validation rather than trust
+
+The persistent cache stores bounded historical closure candidates for each target. The essential point is that rows are *not keyed by commit*. A cached answer is reused if and only if one complete stored candidate still matches the current tree — that is, if the current fingerprint of every path in that candidate equals the stored fingerprint:
+
+```mermaid
+flowchart TB
+ Q["look up target"] --> ROW{"stored candidates
exist?"}
+ ROW -- no --> MISS
+ ROW -- yes --> CHK["search candidates:
does one complete closure match?"]
+ CHK -- "yes" --> HIT["hit: reuse the result
evaluation is skipped entirely"]
+ CHK -- "no" --> MISS["miss: evaluate
store a fresh candidate"]
+```
+
+This is the precise sense in which the system works across commits. After a commit, rebase, pull, or merge, any target whose relevant files are byte-identical in the new tree still has a matching closure, and its evaluation is skipped. A typical commit touches a small number of paths in a very large repository, so a typical warm run validates nearly everything and re-evaluates nearly nothing. The cache follows content rather than history — the evaluation-side analogue of the input-addressed builds discussed in §2.2.
+
+The same property makes the system fail-safe. A row is never believed on the strength of its key; it is believed only when proven. Format changes, corruption, and storage defects therefore all degrade into cache misses, never into wrong answers. In practice, the difference between a cold run and a warm run is the difference between roughly a minute of full tracked evaluation and well under a second — several orders of magnitude, with the gap consisting precisely of "all of evaluation" versus "fingerprint checks and output construction."
+
+---
+
+## 5. A Worked Example
+
+Consider a small synthetic repository, of the kind the correctness suite itself constructs. The directory `build/resolver/` contains the repository's target-definition entry point, called the *resolver*; its contract is given in §9.
+
+```
+repo/
+├── build/resolver/resolve.nix # enumerates and evaluates this repo's targets
+├── services/
+│ ├── api/target.nix # defines //services/api
+│ └── web/target.nix # defines //services/web
+└── lib/util.nix # imported by api only
+```
+
+**Run 1, cold, at commit C₁.** Discovery evaluates the resolver, which lists `services/` and reads each `target.nix` to enumerate targets. Evaluating `//services/api` imports its `target.nix`, which in turn imports `lib/util.nix` and probes for `services/api/local.nix`, an optional override that is not present. All of this is recorded:
+
+```
+discovery closure: build/resolver/resolve.nix → git:…;mode=100644, services → git:…;mode=040000,
+ services/api/target.nix → git:…;mode=100644, services/web/target.nix → git:…;mode=100644
+//services/api closure: services/api/target.nix → git:…;mode=100644, lib/util.nix → git:…;mode=100644,
+ services/api/local.nix → absent
+//services/web closure: services/web/target.nix → git:…;mode=100644
+```
+
+Subsequent runs, one commit each:
+
+| commit | change | discovery | `//services/api` | `//services/web` |
+|---|---|---|---|---|
+| C₂ | edit `lib/util.nix` | **hit** — closure doesn't mention it | **miss** — `lib/util.nix` fingerprint stale; re-evaluated | **hit** |
+| C₃ | add `services/api/local.nix` | **hit** | **miss** — `absent` entry no longer matches; re-evaluated, now reading the override | **hit** |
+| C₄ | edit `README.md` only | **hit** | **hit** | **hit** |
+
+C₃ deserves emphasis: a *newly created* file correctly invalidated a target that had merely *looked for* it — the negative-lookup machinery operating as designed. And C₄ shows the steady state: a run whose cost is fingerprint validation and nothing more, independent of repository size or target count.
+
+---
+
+## 6. How Labels Propagate Through the Value Graph
+
+This section descends one level, to the mechanics of attaching and propagating labels, and to the reasons the mechanism is nearly free.
+
+### 6.1 A label is one integer
+
+Storing a set of path strings on every Nix value would be prohibitively expensive in both memory and time. Instead, paths and *sets of paths* are interned into 32-bit identifiers in a per-evaluator, append-only structure:
+
+```
+"lib/util.nix" → AccessId 7 (interned once, on first sight)
+{7} → AccessSetId 3 (canonical: equal sets share one ID)
+{7, 9} → AccessSetId 5
+union(3, {9}) → 5 (resolved by a small pair-keyed cache)
+```
+
+A value's label is thus a single `uint32_t`. Comparing labels is integer comparison; inheriting a label is copying an integer; and taking the union of two labels is a cache lookup keyed by the pair of identifiers. This last case dominates because lazy evaluation overwhelmingly combines exactly two labels at a time, and evaluation is repetitive enough that the same pairs recur constantly.
+
+The label is stored not in the value itself but in a sparse two-level table keyed by the cell's address: a small constant-initialized directory pointing at demand-paged chunks, one 32-bit slot per 16-byte-aligned cell. `Value` keeps its exact upstream size and layout — a `static_assert` enforces this — and the table's physical footprint is proportional to use: a chunk is allocated only by the first labeled value in its address region, so evaluation that never tracks pays nothing, and tracked evaluation pays about four bytes per value cell. The directory's hot entries cover the entire heap in a handful of cache lines, so reading a label is address arithmetic and two loads.
+
+### 6.2 The force path: where memoization is answered
+
+`forceValue` is the innermost operation of the evaluator. When tracking is inactive, Tecnix adds a single thread-local read and a well-predicted branch to it. When tracking is active, the behavior is easiest to see in a concrete trace. Suppose a target's thunk imports a library file:
+
+```
+force lib thunk frame F₁ opens
+ read lib/util.nix AccessId 7 recorded in F₁
+lib finishes F₁ interned → SetId 3 = {7}; lib.label ← 3
+force target thunk frame F₂ opens
+ force lib (finished) lib.label (3) recorded in F₂ ← inheritance: no read occurs
+ read app/target.nix AccessId 9 recorded in F₂
+target finishes F₂ interned → SetId 5 = {7, 9}; target.label ← 5
+```
+
+The general shape:
+
+```mermaid
+flowchart TB
+ F["forceValue(v)"] --> FIN{"is v already
finished?"}
+ FIN -- "yes (memoized)" --> INH["read v's label — one integer load —
and record it into the current frame.
This step is the answer to §3.1."]
+ FIN -- "no (thunk)" --> FRAME["push a stack frame for v's production"]
+ FRAME --> RUN["evaluate: every read and every
inherited label lands in this frame"]
+ RUN --> PUB["v finishes: the frame is interned,
published as v's label, and
handed to the parent frame"]
+```
+
+A **frame** is a small accumulator on the call stack, holding the identifiers of directly-read paths and the labels inherited from forced values. Frames nest with evaluation, forming a stack per thread. When a value finishes, its frame is interned and the resulting set identifier is published as part of the finish operation itself, ordered such that the label is guaranteed visible to other threads before the value appears finished; no consumer can observe a finished value whose label is missing.
+
+A small number of hooks at the evaluator's value-mutation points maintain the labels' single invariant: *a label describes a cell's current contents, never its history.* Every cell becomes a finished value through a single chokepoint, and the label slot is cleared there unconditionally — whatever the slot held for a previous occupant of that address (a recycled heap cell, a reused stack slot), a finished value starts empty and receives its label from the publish that follows. Unconditional clearing at the one point every finished value passes through is what guarantees that a label is always either empty or accurate. Value copies propagate labels, so provenance survives the evaluator's pervasive movement of values between cells: force, call-time capture, and copy are indistinguishable channels, and contents never move without their label.
+
+### 6.3 Scopes: labels for work the evaluator caches
+
+Some provenance is produced in one place and consumed from a cache. The evaluator memoizes more than values: a file is evaluated once and its result cached; an import path is resolved once — through symlinks and `default.nix` selection — and the resolution cached; the resolver is imported once and applied to every target. On a cache hit, the reads that produced the cached artifact do not recur, so the artifact itself must carry them.
+
+A **scope** is the bracket that makes this work: a region of evaluation — the evaluation of a file, the resolution of an import, the import of the resolver, or a region marked by internal builtins — whose collected accesses are interned into a single set identifier when the region closes. That identifier is published as the produced value's label (or stored beside the cache entry, for caches that do not store values), so a later hit replays the provenance through ordinary label inheritance, exactly as if the consumer had performed the production itself. The resolver's scope label is additionally seeded into every target's context, so every target depends on the resolver's own sources without importing it repeatedly.
+
+This is one instance of the general rule of §7: any cache capable of skipping a physical read must replay the provenance of that read.
+
+### 6.4 The cost discipline: approximately zero allocations, approximately zero locks
+
+The tracking hot path is held to an explicit invariant:
+
+> Recording, forcing, and publishing allocate memory only on the first sight of a path or set — never on a hit.
+
+In the steady state, this cashes out as follows. Recording an access is a lock-free hash lookup on a borrowed view of a path string the accessor already holds; no string is constructed. Frames live on the stack, with small inline storage sized to the empirically measured distribution (nearly all frames hold between zero and two entries). Publishing an empty frame touches nothing, and publishing a frame with a single inherited child reuses the child's identifier without consulting the graph at all. Label reads are address arithmetic into the value-label table, whose hot directory entries and quarter-density slot lines stay cache-resident.
+
+One mutex remains, and it is worth being precise about what it covers. The interning graph's single global mutex guards first-sight path interning and every publish that must actually consult the graph: singleton lookup, pair-union lookup — *including hits* — and full set interning, as well as the per-target flatten at finalization. This is the system's one global serialization point, and therefore its most plausible parallel-scaling bottleneck. Two properties bound it. First, the critical sections are tiny — a hash probe or a small append. Second, its acquisition frequency is proportional to thunks that finish having accumulated real dependencies (one direct access, or two or more inherited children), not to total forces; the empty and single-child fast paths drain the overwhelming majority of publishes before the lock. Should measurement ever show contention here, the structures are append-only by design, so the known remedies — per-thread intern memos, sharded intern maps, lock-free readers over atomically published sizes, a thread-local cache in front of the pair-union table — can be applied without changing the model.
+
+Process-level parallelism sidesteps this analysis entirely, and for large all-target runs it is likely the better scaling axis. A `nix-eval-jobs`-style driver that shards targets across independent evaluator processes gives each worker its own interning graph — and its own mutex — so no global serialization point exists at any worker count; the cost is that values shared between targets (the standard library, common helpers) are evaluated once per process rather than once overall. The oracle of §3.2 is what makes this sound: closures are identical across evaluation modes, so a target's closure does not depend on which process produced it, and the processes can share one persistent cache because rows are validated by content, never by producer.
+
+Parallel evaluation receives a stronger treatment: **tracking contexts are thread-confined.** Each parallel work item that evaluates a target owns its context outright — created, recorded into, snapshotted, and destroyed on one thread — so recording never locks and no frame is ever shared between threads. The only cross-thread dependency channel is the published label on a finished value. Tracked evaluation is therefore forbidden from spawning parallel work of its own: the evaluator's detached prefetch sites (`toJSON`'s deep force, `builtins.parallel`) skip prefetching under tracking — the consumer forces sequentially, producing identical results — and the work-item factory fails loudly if anything else tries, because a work item may capture only owned state and a tracking context is a non-owning pointer into another thread's stack. The comparatively expensive step — flattening identifier sets into paths and fingerprinting them — is deferred until all work items have finished, at which point it is a pure function of the recorded snapshots. This is why sequential and parallel evaluation produce identical closures, and why the oracle of §3.2 may legitimately demand that they do.
+
+---
+
+## 7. Observing the Sources
+
+All tracked reads flow through a single source accessor that composes the git tree at the pinned commit with the working checkout. Routing between the two is decided by a set of dirty files computed once per evaluation from `git status`:
+
+```mermaid
+flowchart LR
+ READ["readFile / readDirectory /
readLink / pathExists"] --> ACC{"is the path
dirty?"}
+ ACC -- no --> GIT["git object store
(content at the pinned commit)"]
+ ACC -- yes --> DISK["working checkout"]
+ ACC -. every access, including misses .-> REC["record the repo-relative path
into the current frame"]
+```
+
+Clean paths are served from git's object store — no checkout is required, and evaluation can run against a bare repository. Dirty paths are served from disk. Every access records the repository-relative path. Existence checks are recorded at the primop layer (`pathExists`, `readFileType`), which is how negative lookups enter the closure despite no read occurring.
+
+In a worldtree sandbox (`tectonix-worldtree-socket` set) there is no git repository to read; the clean tree is instead the daemon's immutable FUSE projection of the pinned commit, with repo-relative paths mapped through the committed manifest to per-zone views. The fingerprint vocabulary is unchanged: directories read their exact committed tree oid from the projection's `user.worldtree.tree-oid` xattr, and regular files read the daemon's `user.worldtree.blob-oid` xattr when it is served — one O(1) metadata read each. Symlinks (which cannot carry user xattrs; `getxattr` would follow the link and answer for its target, a different git object) and files under daemons that do not serve blob oids fall back to hashing their bytes as git blobs — a blob oid is a pure function of content — memoized in memory for the accessor's lifetime, which the immutable projection makes sound: each unique file is hashed at most once per evaluation. Because every mechanism emits identical fingerprint strings, closures produced under one backend validate under the other. Two caveats follow from the projection being zone-granular: committed paths outside every visible zone do not exist in this view and observe as `absent`, and zone-ancestor directories are synthesized with a composite `worldtree-union:` fingerprint outside the git vocabulary (their listing genuinely differs from the full git tree, so cross-backend cache misses there are correct, not conservative).
+
+The evaluator's own caches require care, since any of them could silently absorb a read. Each — the file-evaluation cache, the import-resolution cache, the source-to-store copy cache — either maintains a separate tracked-domain instance or replays its provenance on a hit. The general rule: *any cache capable of skipping a physical read must replay the provenance of that read.*
+
+The failure policy throughout is to fail closed. If `git status` fails, evaluation raises an error rather than assuming a clean tree, because the dirty overlay is load-bearing for closure validity. If a path cannot be fingerprinted, evaluation raises an error rather than emitting a partial closure. If evaluation reaches something the closure format cannot represent, it raises an error rather than under-tracking. Every failure mode resolves to a cache miss or a visible error; none resolves to a plausible wrong answer.
+
+---
+
+## 8. The Persistent Cache
+
+The cache is a single SQLite database with one physical row family:
+
+```
+DependencyShards(gitDir, resolver, argsKey, shard → multi-target history blob)
+```
+
+Target discovery (§9) is stored in the same rows, under a reserved key whose candidates carry the discovered target list as a payload; discovery thereby shares the lookup, validation, history, and compaction machinery of ordinary targets rather than maintaining a parallel implementation. The key contains no commit. The `argsKey` column holds the canonical JSON encoding of the caller's `args` value; this is sound as a key because the resolver receives that same value, so results can depend on the arguments only through content that is, by construction, the key.[^ambient-inputs] Validity across trees is established entirely by the closure-matching procedure of §4.1.
+
+[^ambient-inputs]: Ambient inputs that a pure evaluation can still observe — `builtins.nixVersion`, the store directory — are deliberately *not* part of the cache key. This aligns with Nix's existing flake evaluation cache, whose key is likewise content-only. Changes to the evaluator itself, or to Tecnix semantics, are instead handled by bumping the version in the cache's filename (`tecnix-eval-cache-v1.sqlite`), which orphans old rows wholesale rather than mixing results from two evaluator versions in one database.
+
+A dependency shard row is therefore a physical container for many bounded per-target proof histories, not a log indexed by commits. Each target candidate in that history is a complete source closure: a map from observed source paths to the fingerprints they had when the target was evaluated. A cache hit means that one whole candidate for that target still matches the current tree. The commit at which the candidate was learned may be useful metadata for ordering or eviction, but it is never proof of validity.
+
+Sharding is a row-size compromise. With `N` targets, `S` shards, and an average source closure of `P` path/fingerprint pairs, the newest-candidate pair payload in a shard is roughly `(N / S) * P` pair records, plus shared dictionaries. Fewer shards improve dictionary sharing and reduce all-target row count, but make each row larger and make each update rewrite more unrelated target history. More shards make point lookup and update rows smaller, but duplicate side tables and increase all-target row overhead. On the measured 7,254-target `aarch64-darwin` workload, the average closure is about 187 path entries; with 256 shards, one-candidate rows average about 79 KiB and max at about 120 KiB, for about 20 MiB total. That is small enough for fast all-target warm lookup while still sharing path and fingerprint strings across many targets. The shard count should move only with measurement; values in the 128–256 range are the plausible region for this workload, while larger counts mostly trade row size for duplicated dictionaries.
+
+The first implementation deliberately uses flat newest-first candidate history rather than a decision trie. The common case is that the latest candidate still matches, and the history bound is small. In that case, a trie adds another index to build, validate, and explain without reducing the expensive part of validation: computing the current fingerprint once per unique path. The per-run fingerprint memo already makes repeated path checks cheap. A trie over `(pathId, fingerprintId)` predicates could become worthwhile if measurements show many stale candidates per hot target and repeated pair scans dominate warm lookup, but it is not needed for the initial sharded blob design.
+
+Dependency and discovery blobs begin with the magic bytes `TXDC` (for "TecniX Dependency Closure"). The magic serves as the format's self-identifier: foreign, corrupted, or out-of-date blobs are rejected immediately, and rejection is a cache miss rather than an error. The blob bytes are laid out so they are already the data structure used by validation:
+
+```
+header: TXDC magic, fixed format marker, counts, section offsets
+targets: offset table + concatenated target identifiers
+targetRecords: candidate range for each target
+paths: offset table + concatenated repo-relative path bytes
+fingerprints: offset table + concatenated fingerprint bytes
+payloads: offset table + candidate payload bytes
+candidates: pair-stream range + payload id for each historical candidate
+pairs: flat (path id, fingerprint id) streams for validation and output
+```
+
+Each candidate record is one complete historical source closure. Validation searches a target's candidates newest-first. For each pair in a candidate, it asks whether the current fingerprint of that path equals the stored fingerprint. If every pair matches, that candidate is a cache hit and its pair stream is walked directly to construct dependency output. For target discovery, the matching candidate also carries the target-list payload.
+
+Opening a blob is just bounds-checking the section offsets, counts, and ranges, then viewing the arrays in place. There is no JSON parse for dependencies, no heap object graph, no pointer patching, and no decoded index to build before lookup can begin. Bulk queries load all relevant shard rows in a single range scan and search the requested target histories outside the database lock. The search is lazy: it fingerprints a source path only when the candidate currently being checked asks about that path, and a per-run memo eliminates repeated fingerprint computations for paths shared between shards, candidates, and targets. A warm hit thus bypasses the entire tracked-evaluation stack, paying only for shard loading, candidate scanning, fingerprint comparison, and output construction.
+
+### 8.1 Cache history and lifecycle
+
+The cache keeps **bounded historical source closures, not per-commit entries.** The target scale is enough recent history to cover ordinary branch switching and merge-queue churn — on the order of 10–32 historical evals — while keeping lookup fast. The current bound is chosen roughly as the number of distinct source-closure changes a hot target might see in about 24 hours, not as a function of commits per day. Reuse across commits still comes from re-proving a candidate closure against the current tree, not from trusting the commit that produced it.
+
+A fixed candidate count is the simplest first policy. If measurements show that useful histories are mostly time-shaped rather than count-shaped, a future cache could retain candidates by an approximate 24-hour TTL instead: keep all distinct closures learned in the recent window, then evict by age. That would trade a slightly less predictable row size for a policy closer to the product goal of surviving normal daily branch and merge-queue churn.
+
+Consequently, the cache grows with the logical key space and the bounded history per target, not with repository history. A target's history lives inside the `DependencyShards` row selected by `(gitDir, resolver, argsKey, shard)`, where the shard is a stable hash of the target name; discovery history lives under a reserved key in the same scheme. Within a target history, inserting a freshly evaluated closure deduplicates identical closure content and evicts old candidates by policy when the bound is reached.
+
+The important behavioral consequence is that switching between divergent trees need not thrash the cache. If two branches produce different but recently seen closures for the same target, both can remain as candidates, and either branch can hit by proving its candidate against the current tree. If the useful candidate has been evicted, the result is only a cold re-evaluation; eviction is a performance policy, not a correctness policy.
+
+The unbounded dimensions are the key tuples themselves: each distinct `args` value, resolver path, or repository location materializes its own row set, and abandoned tuples are not currently reclaimed. The validation discipline supplies the operational escape hatch: since no row is ever trusted without proof against the current tree, the database is disposable. Deleting it is always safe and costs cold re-evaluation.
+
+---
+
+## 9. The Public Interface, and the Discovery Subproblem
+
+Constructing the dependency graph presupposes an answer to a prior question: which targets exist? The target list is not a manifest; it emerges from evaluating repository code that walks directories and reads definition files. Discovery is therefore the same kind of computation as target evaluation — Nix evaluation reading the repository — and it is handled identically. Discovery acquires its own source closure, comprising the paths that determine the target list, including the negative space in which no definitions were found; and it is cached under the same validation discipline, in the same rows as target closures, under a reserved discovery key (§8). If a new target definition appears anywhere discovery looked, the closure ceases to match and discovery re-runs; otherwise, a previously computed target list is provably still current.
+
+The public interface accordingly consists of two builtins, one per problem:
+
+```nix
+# Discovery: which targets exist?
+builtins.tecnixTargetNames {
+ gitDir = "/path/to/repo/.git";
+ resolver = "build/resolver/resolve.nix"; # repo-relative resolver file
+ rev = "";
+ args = { systems = [ "x86_64-linux" ]; }; # opaque; must be JSON-canonicalizable
+}
+# → [ "opaque-target-id" ... ]
+
+# Evaluation: what do these targets mean, and what do they depend on?
+builtins.tecnixTargets {
+ ... same ...;
+ targets = [ "opaque-target-id" ];
+ includeDependencies = true; # optional: also return source closures
+}
+```
+
+The entire contract between Tecnix and a repository is one file, the **resolver**. The simplest possible resolver makes the contract plain — real resolvers derive the same structure from the source tree:
+
+```nix
+# build/resolver/resolve.nix — evaluates to a function over the caller's args
+args:
+let
+ targets = {
+ "//services/api" = import ../../services/api/target.nix { inherit args; };
+ "//services/web" = import ../../services/web/target.nix { inherit args; };
+ };
+in {
+ allTargetNames = builtins.attrNames targets; # discovery
+ resolve = id: targets.${id}; # evaluation (values expose a drvPath)
+}
+```
+
+Target-identifier syntax, naming conventions, and indeed the very notion of what constitutes a target are decisions belonging to the resolver, not to Tecnix. The engine understands only two operations: arguments in, identifiers out; and identifier in, evaluated value and source closure out. This genericity is deliberate. It makes the machinery applicable to any git repository, and it is what allows the correctness suite to construct small, disposable repositories — such as the one in §5 — and hold the oracle against them.
+
+The older, repository-specific builtins (the `unsafeTectonixInternal*` family) remain available for existing consumers. They are intentionally quarantined in a separate source file, are deprecated, and are expected to be removed once their consumers migrate. Those among them that expose checkout-local state refuse to run under tracking, in keeping with the fail-closed policy.
+
+---
+
+## 10. Minimal Invasiveness
+
+*This section concerns the maintenance of the fork itself and may be skipped by readers interested only in the system's behavior.*
+
+The codebase merges upstream Nix on a regular basis, which makes the merge-conflict surface of upstream files a first-order maintenance cost. The patch is shaped accordingly: the tracking system resides in its own module, and upstream files carry only narrow, stable hooks.
+
+| upstream file | contents |
+|---|---|
+| `value.hh` | three one-line hooks at the finish and copy-assignment points; no members added |
+| `eval.hh` | a five-line dispatch shim in `forceValue`; work-item context capture; one private data member (the file is smaller, net, than its upstream counterpart) |
+| `eval.cc` | cache-domain selection in `evalFile`; one provenance record in `copyPathToStore` |
+| `primops.cc` | two one-line existence-check records |
+| libutil / libfetchers | a small, generic "access observation" accessor interface |
+
+Everything else — the interning structure, the frames, the accessors, the cache, and the builtins — is module-local and unaffected by upstream merges. The module carries no exploratory scaffolding: no tuning knobs, per-call-site instrumentation, or speculative API variants. The working rule is that no optimization is admitted without a profile measurement attributing cost to it, and no complexity without a measured improvement; questions of timing are answered by a profiler rather than by permanent counters.
+
+---
+
+## 11. Limitations
+
+The following limitations are deliberate and documented. The persistent cache requires `pure-eval` (§2.2). Dirty-file state is captured once per evaluation, so mutating the checkout during a query is outside the contract. Access to the repository root is not representable in the closure format and fails closed. The cache has no key-tuple eviction policy; abandoned `(gitDir, resolver, argsKey)` row sets accumulate until the database is deleted, which is always safe (§8.1).
+
+**Future work.** In a worldtree sandbox, directory and regular-file fingerprints are already single O(1) xattr reads when the daemon serves `user.worldtree.blob-oid` beside `user.worldtree.tree-oid` (§7). The remaining hash fallback covers symlinks — which cannot carry user xattrs at all — and daemons that predate the blob-oid xattr; it is memoized in memory per evaluation. A daemon-side answer for symlink oids (for example serving the parent's raw tree object, whose `(mode, name, oid)` entries are exactly what libgit2 itself reads) would delete the fallback entirely; because every mechanism emits identical fingerprint strings, that change invalidates no stored closure. Additionally, the projection is zone-granular: committed paths outside every visible zone are not observable historically, and mutable-sandbox dirty discovery still assumes a local `git status`, whose worldtree replacement is the daemon's `scoped.status`.
+
+---
+
+## Glossary
+
+| term | meaning |
+|---|---|
+| **source closure** | the set of source paths, with fingerprints, that certify one evaluation result; unrelated to a store path's runtime closure |
+| **fingerprint** | a path's current observed state: a git object ID plus git mode, optionally extended with a hash of uncommitted changes, or `absent` |
+| **label** | the compact identifier, carried on a value, naming the set of source paths that produced the value's current contents |
+| **frame** | a stack-resident accumulator collecting the reads and inherited labels of one value's production |
+| **scope** | a bracketed evaluation region whose collected accesses are interned once and published as the produced value's label; how provenance replays through the evaluator's caches |
+| **resolver** | the repository-owned Nix function defining discovery (`allTargetNames`) and evaluation (`resolve`) |
+| **discovery** | determining which targets exist; the same tracked, cached computation as target evaluation, one level up |
+| **negative lookup** | an observed *absence* (e.g. `pathExists` returning false), recorded as a dependency so a new file invalidates correctly |
+| **TXDC** | the magic bytes ("TecniX Dependency Closure") identifying the binary closure format in the cache |
+
+---
+
+## Appendix: The System in Summary
+
+```
+Goal: the exact target dependency graph, and the ability to skip target
+ evaluation across commits.
+Setting: Nix evaluation is a lazily-expanded, memoized graph of values; the
+ derivation is its output, so evaluation itself must be made
+ input-addressed by the sources it reads. Pure evaluation is what
+ makes that addressing sound.
+Mechanism: file accesses are recorded alongside the value graph as labels on
+ values; targets inherit labels from every value they force, so
+ memoization cannot conceal a dependency.
+Product: a target's label set, fingerprinted, is its source closure —
+ including directories listed and files found absent.
+Caching: a stored closure that still matches the tree proves a cached result
+ valid, at any commit. Discovery is the same problem one level up:
+ the target list has a closure too.
+Cost: interned integers, stack frames, lock-free hits, and no allocations
+ on the hot path; validation everywhere, trust nowhere.
+```
diff --git a/plans/tecnix-target-eval-caching/guardrails.md b/plans/tecnix-target-eval-caching/guardrails.md
new file mode 100644
index 0000000000..7aa6aaf600
--- /dev/null
+++ b/plans/tecnix-target-eval-caching/guardrails.md
@@ -0,0 +1,98 @@
+# Tecnix target-eval cache guardrails
+
+Use this as a review checklist for source-dependency tracking and target-eval cache changes. A change should satisfy every guardrail below, or explicitly amend the guardrail as part of the same work.
+
+## Correctness oracle
+
+- **Dependency output must be identical across evaluation modes.**
+ - Isolated single-target deps, shared sequential deps, shared parallel deps, warm-cache deps, and target-discovery deps must agree.
+
+- **No under-tracking. No cross-target contamination. No unproven over-tracking.**
+ - A target's closure must contain exactly the source observations that can affect that target.
+ - Today those observations are represented as path fingerprints; future formats may represent narrower observed properties when they can prove the same oracle.
+
+- **Do not hide tracking bugs by disabling or resetting evaluator behavior.**
+ - Fix provenance propagation rather than avoiding memoization, sharing, or parallelism.
+
+## Source-dependency model
+
+- **Values carry source-deps labels; targets inherit labels.**
+ - Read logs are not sufficient because Nix memoization can reuse a value without repeating the read that produced it.
+
+- **Every source observation that can affect evaluation is a dependency.**
+ - File reads, directory reads, existence checks, symlink reads, and negative lookups must be represented.
+ - Recording happens at the lowest layer that knows the observation is semantic: reads self-record in the Tecnix source accessor; existence/type checks are recorded by their primop call sites (`pathExists`, `readFileType`) via `recordEvalAccess`, because accessor-level stat tracking would over-track plumbing (symlink/import resolution, store copies). A new primop observing existence or type without a read must record the access itself.
+ - Present clean paths fingerprint as `git:;mode=`; dirty present paths add `;dirty=`.
+ - Negative lookups fingerprint as `absent` or `absent;dirty=`.
+ - Directory listings may eventually be tracked by their complete returned child name/type map, including absence of other child names, rather than a full tree fingerprint; cache validation must still prove the exact observed result.
+
+- **Value labels must describe current value contents, never stale history.**
+ - Allocation, overwrite, copy, move, and force/memoization paths must preserve or clear labels correctly.
+
+- **Tracking contexts are thread-confined.**
+ - A context is created, recorded into, snapshotted, and destroyed on one thread; the only cross-thread dependency channel is the published label on a finished value.
+ - Tracked evaluation must not spawn parallel evaluation work: detached prefetch sites skip spawning under tracking, and the work-item factory fails loudly otherwise (work items capture only owned state).
+
+- **Dirty source state must fail closed.**
+ - Dirty status is captured as one coherent evaluation snapshot.
+ - If dirty status or fingerprinting cannot certify the source state, do not accept a cache hit.
+
+- **Repo-root source access remains unrepresentable unless the closure format grows an explicit representation.**
+ - Until then, repo-root access must fail closed.
+
+## Cache validity
+
+- **A persistent cache hit requires one complete stored closure candidate to match current fingerprints.**
+ - Partial matches are misses.
+ - Candidate validation may short-circuit on mismatch, but acceptance requires the whole candidate.
+
+- **Never trust commit identity for cache acceptance.**
+ - No cache validity by `rev`.
+ - No per-commit cache key.
+ - No per-commit/rev fast path.
+ - Changed-path or tree-diff data may filter affected-target output, but must not accept cache rows.
+
+- **Unknown or malformed cache data is a miss.**
+ - Cache data is an optimization; bad rows must not produce stale answers.
+
+- **Miss evaluation must learn a fresh proof.**
+ - A miss evaluates under tracking, finalizes the observed closure, fingerprints it, and stores it for future validation.
+
+## Cache history and storage
+
+- **History is bounded candidate history, not repository history.**
+ - Candidate slots represent distinct source-closure alternatives for a logical key, not commits.
+ - Duplicate closure content should not consume another slot.
+
+- **Logical correctness remains per target even when physical storage is shared.**
+ - Sharding and shared dictionaries must not let one target's closure or payload satisfy another target.
+
+- **Target discovery is cached with the same proof rules as target dependencies.**
+ - A stale target list is as wrong as a stale target closure.
+
+- **The cache hit path must use the stored row directly.**
+ - SQLite blob bytes should open into a bounds-checked view used for validation.
+ - Do not rebuild a heap object graph, decoded index, old packed JSON trie, or normalized SQL dependency graph on the hit path.
+
+- **Public dependency output remains path-to-fingerprint data.**
+ - Internal storage may use IDs/dictionaries, but the public dependency shape remains compact `path = fingerprint` entries.
+
+## Public API boundaries
+
+- **Keep cache history out of the public Tecnix API.**
+ - Public builtins remain `builtins.tecnixTargetNames` and `builtins.tecnixTargets`.
+
+- **Dependency-only queries must not force target values.**
+ - Continue to support `includeDependencies = true; includeTargets = false;`.
+
+- **A dependency-cache hit is not a target-value cache hit.**
+ - If callers ask for target values, those values still need evaluation unless a separate value-cache proof exists.
+
+- **Keep legacy `unsafeTectonixInternal*` compatibility isolated from the new Tecnix cache/history design.**
+
+## Development cache policy
+
+- **Do not add migrations for unshipped development cache formats.**
+ - During development, incompatible local rows should miss or be wiped and rebuilt.
+
+- **Keep the current development blob marker fixed unless the cache format becomes a shipped compatibility contract.**
diff --git a/plans/tecnix-target-eval-caching/walkthrough.md b/plans/tecnix-target-eval-caching/walkthrough.md
new file mode 100644
index 0000000000..63f3b44430
--- /dev/null
+++ b/plans/tecnix-target-eval-caching/walkthrough.md
@@ -0,0 +1,172 @@
+# The Life of a Target Evaluation
+
+*A step-by-step trace through Tecnix.*
+
+*This document is a companion to the explainer ("Tecnix: Exact Target Dependencies and Cross-Commit Eval Caching"). Where the explainer describes the system at rest — the problem, the design, and the structures — this document describes it in motion: a caller asks Tecnix to evaluate one target, and we follow the request from the builtin call to the answer. Each data structure is introduced at the point where it first participates, together with the reason it exists and its cost at that moment.*
+
+*We assume the target's identifier is already known. Discovery (`tecnixTargetNames`) travels the identical road — the same cache question, the same tracked evaluation, the same closure — with the target list as its result rather than a target's value. The example repository throughout is the same synthetic repository as the explainer's §5 worked example.*
+
+---
+
+## 1. The Goal
+
+Consider the following call:
+
+```nix
+builtins.tecnixTargets {
+ gitDir = "/path/to/repo/.git";
+ resolver = "build/resolver/resolve.nix";
+ rev = "";
+ args = { systems = [ "x86_64-linux" ]; };
+ targets = [ "//services/api" ];
+ includeDependencies = true;
+}
+```
+
+The caller expects two things in return: the target's **evaluated value**, and its **source closure** — the fingerprinted set of paths that certify the result, including directories that were listed and files that were found absent. The caller further expects the call to be inexpensive whenever nothing relevant has changed, even if the current commit differs from the one at which the target was last evaluated.
+
+## 2. The Journey at a Glance
+
+Every request follows the same path, which contains two significant branch points. The numbered stations correspond to Steps ① through ⑤ in the sections that follow:
+
+```mermaid
+flowchart TB
+ CALL["① the call:
pin repository context,
derive the cache key"] --> CACHE{"② the cache question:
does a stored closure exist,
and does it still match the tree?"}
+ CACHE -- "yes: cache hit" --> OUT1["answer built from the stored row
evaluation does not run"]
+ CACHE -- "no: cache miss" --> EVAL["③ tracked evaluation begins:
resolver, context, dirty overlay"]
+ EVAL --> LOOP["④ inside evaluation:
many forceValue calls, each either a
memoization hit or a memoization miss"]
+ LOOP --> FIN["⑤ finishing up:
snapshot, flatten, fingerprint,
store, answer"]
+```
+
+The first branch — cache hit or miss — determines whether evaluation runs at all. The second branch is taken a very large number of times *within* evaluation: each time a value is demanded, it has either been computed already (a memoization hit) or it has not (a memoization miss). The remainder of this document walks the path in order. At each branch we take the expensive side, so that every mechanism is visited, and we note what the inexpensive side would have cost instead.
+
+## 3. Step ①: The Call
+
+Argument handling is largely routine. Two decisions made at this stage matter later.
+
+First, **the repository context is pinned.** The `gitDir`, `rev`, and checkout path configure the evaluator's source accessors, and they do so exactly once per evaluator instance. A second call with a different `rev` produces an error rather than a silent reconfiguration. The reason is that the accessors, fingerprints, and cached content constructed downstream are all built lazily against a single commit; permitting reconfiguration would allow content from two commits to mix without any indication that it had.
+
+Second, **the `args` value becomes part of the cache key.** It is converted to a canonical JSON encoding, called the `argsKey`. This is sound because the resolver receives the same value: results can depend on the arguments only through content that is, by construction, the key. It is worth observing what the cache key does *not* contain: the commit. Validity across commits is established by proof rather than by key, as the next step describes.
+
+## 4. Step ②: The Cache Question
+
+> **Structure: `TecnixEvalCache`.** A SQLite database holding shard rows keyed by `(gitDir, resolver, argsKey, shard)`. Each shard row contains bounded source-closure histories for the targets assigned to that shard. It exists because skipping evaluation requires remembering what would certify the skipped result.
+
+The shard containing `//services/api` is loaded. A single target uses a point lookup for its shard; when many targets are requested, one range scan retrieves the relevant shard rows, and their validation proceeds outside the database lock. Each row's blob begins with the magic bytes `TXDC` (explainer §8).
+
+> **Structure: the `TXDC` blob and its `DependencyBlobView`.** The blob is not a serialization that is parsed into objects; it is itself the data structure. Opening a view validates every section, offset, and range once. The view then exposes target, path, fingerprint, and payload dictionaries plus flat candidate and pair records as borrowed spans over the row's own bytes. A malformed or out-of-date row fails to open and is treated as a cache miss rather than an error.
+
+Validation checks historical candidates newest-first until one complete closure still matches the current tree:
+
+```mermaid
+sequenceDiagram
+ participant P as lookup
+ participant BV as blob view
+ participant FP as fingerprint memo
+ participant GIT as accessor / git
+ P->>BV: open(row bytes)
validate sections once
+ loop candidates newest-first
+ BV-->>P: candidate pair range
+ loop candidate pairs
+ BV-->>P: path and stored fingerprint
+ P->>FP: current fingerprint of path?
+ alt already computed this run
+ FP-->>P: memoized result
+ else first sight this run
+ FP->>GIT: git object ID and mode at path
plus dirty-overlay hash if modified
+ GIT-->>FP: fingerprint, memoized
+ end
+ P->>P: compare with stored fingerprint
+ end
+ end
+ P->>P: first fully matching candidate is a hit
+```
+
+> **Structure: the per-run fingerprint memo.** A thread-local table from path to current fingerprint. It exists because the closures of many targets, and the historical candidates for one target, share most of their paths; each unique path is fingerprinted once per run — a git object-identifier and file-mode read, which is itself inexpensive — and every subsequent occurrence is a hash lookup. Validation cost therefore scales with the number of *unique* paths candidate scanning asks about, not with the total number of historical closure entries. (In a worldtree sandbox the clean tree is the daemon's immutable FUSE projection rather than a git repository: directory fingerprints come from a tree-oid xattr, and regular-file fingerprints from a blob-oid xattr when the daemon serves one, falling back to hashing content as a git blob, memoized in memory for the run — identical fingerprint strings, identical validation; see the explainer's §7.)
+
+If one candidate fully matches, the journey ends here: that candidate identifies the matching historical closure, the output is built directly from its pair stream, and everything described in the remaining sections is skipped — the resolver, every force, every frame, all interning. This asymmetry accounts for the difference of several orders of magnitude between warm and cold runs. The `absent` entries participate in the search as well: a path that the target once probed and did not find is checked to still be absent, so a newly created file fails the proof in exactly the way an edited one does.
+
+For the purposes of this walkthrough, suppose no complete candidate matches. The lookup is a miss, and evaluation must run — under observation.
+
+## 5. Step ③: Tracked Evaluation Begins
+
+> **Structure: `TrackingContext`.** One per target, owning a root accumulator. It exists so that each target's dependencies remain isolated from those of every other target sharing the evaluator.
+>
+> **Structure: `TecnixThreadState`.** A single thread-local record holding the active context, the top of the frame stack, and the current publish target. It exists so that tracking state is reachable from the evaluator's innermost loop at the cost of one thread-local access, without threading arguments through upstream code.
+
+Two preparations precede the target itself.
+
+**The resolver is imported, once, under a scope.** The repository's `resolve.nix` is evaluated inside a *source-deps scope*: a bracketed region whose collected accesses are interned into a single set identifier and published as the resolver value's label (explainer §6.3). That identifier is then seeded into every target's context, so that all targets depend on the resolver's own sources without importing it repeatedly.
+
+**The dirty overlay is established.** A single `git status` invocation partitions the tree: clean paths will be served from the git object store at the pinned commit, and modified paths from disk. If `git status` fails, evaluation fails. Assuming a clean tree in that situation would allow stale rows to validate against a tree that does not reflect reality, so the failure is made visible instead.
+
+The resolver is then applied to `"//services/api"`, the resulting value's `drvPath` is forced, and control descends into the evaluator.
+
+## 6. Step ④: Inside Evaluation
+
+Every value demanded during evaluation passes through `forceValue`, which under tracking asks a single question: has this value been computed already?
+
+### 6.1 A memoization miss: producing a value
+
+Suppose the target imports `lib/util.nix`, and nothing has evaluated that file yet.
+
+> **Structure: `TrackedSourceDepsFrame`.** A small, stack-resident accumulator opened for the value under production, holding the identifiers of paths read and labels inherited while producing it. It exists because something must delimit "the reads that occurred while producing this value," and a stack mirrors the shape of evaluation exactly. Its inline storage covers the empirically common case of zero to two entries, so no heap allocation occurs.
+>
+> **Structure: `EvalSourceAccessSetGraph`.** The interning structure: a path becomes a 32-bit `AccessId`; a set of paths becomes a canonical 32-bit `AccessSetId`, with equal sets sharing one identifier. It exists because everything downstream must operate on integers rather than strings: comparing labels is integer comparison, and inheriting a label is copying an integer.
+
+```mermaid
+sequenceDiagram
+ participant E as forceValueTracked
+ participant FR as frame F₁
+ participant ACC as source accessor
+ participant G as interning graph
+ participant V as value cell
+ E->>FR: push frame
set publish target to v
+ E->>V: v.force()
+ V->>ACC: readFile("lib/util.nix")
+ ACC->>G: internAccess(path view)
+ G-->>ACC: AccessId 7
lock-free for previously seen paths
+ ACC->>FR: record AccessId 7
+ V->>V: finish()
intern frame as SetId 3
set label to 3
mark finished
+ V->>FR: hand SetId 3 to the parent frame
+ E->>E: pop frame
+```
+
+The costs along this path are as follows. The accessor records a borrowed view of the repository-relative path, so no string is constructed. `internAccess` is a lock-free hash lookup for any path seen before; the graph mutex is taken only on a path's first sighting in the process. Appending to the frame is an integer store. When the value finishes, the frame is interned through fast paths matched to the common frame shapes: an empty frame publishes nothing, and a frame containing a single inherited child reuses that child's identifier — neither consults the graph. A frame with two children is resolved by the **pair-union cache**, a table from pairs of set identifiers to their union, which exists because lazy evaluation combines exactly two labels far more often than any other number, and because evaluation is repetitive enough that the same pairs recur constantly. The pair-union and singleton lookups do take the graph's global mutex — the system's one global serialization point; its cost profile and the process-level alternative are discussed in the explainer's §6.4.
+
+One ordering detail deserves attention: the label is stored on the value *before* the cell is marked finished. Consequently no thread — including a parallel worker awaiting this thunk — can observe a finished value whose label is missing.
+
+> **Structure: the value-label table.** A sparse two-level table holding one 32-bit slot per 16-byte-aligned value cell: a constant-initialized directory indexed by the top address bits, pointing at demand-paged chunks that are installed by the first labeled value in their region. It exists so that labels cost nothing when tracking is off and about four bytes per cell when it is on, while `Value` keeps its exact upstream layout — a `static_assert` guards the size so any change revisits the decision explicitly.
+
+### 6.2 A memoization hit: inheriting a label
+
+Now the same import is demanded again, whether later in this target or from a different target altogether. The value is finished.
+
+```
+forceValue(v): v is finished
+ → load v's label (one atomic integer read)
+ → record it into the frame (one integer store)
+```
+
+This is the complete flow, and it is the flow that executes most often — many millions of times in a large evaluation. No file is read, no frame is pushed, and nothing is allocated or locked; yet the dependency is fully inherited. This is the resolution of the memoization problem (explainer §3.1), reduced to two integer operations. When the recording frame later publishes, the union of the inherited label with the frame's other contents is, in the common case, a single lookup in the pair-union cache.
+
+## 7. Step ⑤: Finishing Up
+
+Evaluation of the target completes. The context's root frame now holds, as integers, everything the target touched, whether directly or by inheritance.
+
+**Snapshot.** The root frame is copied: a vector of identifiers and nothing more. Tracking contexts are thread-confined — when many targets evaluate in parallel, each executor work item owns its target's context outright, so recording never locks and the snapshot is an ordinary read on the owning thread; worker threads handle only integers.
+
+**Flatten and fingerprint.** After all workers have finished, each snapshot is flattened — a generation-stamped traversal of the interning graph that yields unique path identifiers and then paths — and each path is fingerprinted through the same per-run memo used by the cache lookup. Present clean paths include both git object ID and git mode; dirty paths add the overlay hash. This step is deliberately deferred and pure: it is a function of the snapshots alone, which is why sequential and parallel evaluation produce identical closures, and why the test suite's oracle is entitled to require that they do. If any path cannot be fingerprinted, this step raises an error rather than emitting a partial closure, since a partial closure stored today becomes a stale cache hit tomorrow.
+
+**Store and answer.** The closure is inserted into the row's `TXDC` history — batched into a single transaction when several targets missed. Identical closures are deduplicated, and old candidates may be evicted when the bounded history is full; the cache's size and lifecycle characteristics are discussed in the explainer (§8.1). The caller receives the target's value and, because `includeDependencies` was set, its closure. The next request for this target, on this commit or on any future commit in which these paths are unchanged, takes the short branch at Step ②.
+
+## 8. The Road, Costed
+
+| where | branch | frequency | steady-state cost |
+|---|---|---|---|
+| Step ② | cache hit | the common case across commits | row load, plus one fingerprint per *unique* path |
+| Step ② | cache miss | changed targets only | everything below, once; then cached |
+| Step ④ | memoization hit | many millions per evaluation | approximately two integer operations |
+| Step ④ | memoization miss | once per thunk | integer operations, plus one interning per *new* path or set |
+
+The table exhibits the design's economics: the more frequently a branch executes, the fewer structures it touches. Allocation is confined to first-sight events — a new path, a new set, a new closure — and locking to those events plus the union-producing publishes, all of which occur in proportion to *change* and to distinct dependency structure, while the hit paths, which occur in proportion to *scale*, touch almost nothing. The zero-allocation discipline described in the explainer is therefore visible here not as an optimization applied afterward, but as the organizing principle of the architecture.
diff --git a/scripts/measure-tecnix-eval.sh b/scripts/measure-tecnix-eval.sh
new file mode 100755
index 0000000000..2c4ada5f00
--- /dev/null
+++ b/scripts/measure-tecnix-eval.sh
@@ -0,0 +1,665 @@
+#!/usr/bin/env bash
+# Evaluate Tecnix targets. The Tecnix SQLite eval cache is disabled by default;
+# pass --eval-cache to measure the pure-eval cached path.
+#
+# By default this measures builtins.tecnixTargets by producing target records
+# with drvPaths. Other modes are available for target values, dependency
+# tracking, and target-name discovery.
+
+set -euo pipefail
+
+script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)
+repo_root=$(cd "$script_dir/.." && pwd -P)
+
+default_world_git="$HOME/world/git"
+
+usage() {
+ cat >&2 <<'EOF'
+Usage: measure-tecnix-eval.sh [options]
+
+Options:
+ --git-dir PATH World git directory (default: $GIT_DIR, ~/world/git if it exists, or WORLD/.git)
+ --world PATH World checkout path, used only to derive git-dir/rev unless --checkout-path is set
+ --checkout-path PATH Pass checkoutPath to Tecnix args for dirty-overlay/source-available eval
+ --resolver PATH Repo-relative resolver file (default: $RESOLVER or system/tectonix/resolve.nix)
+ --system SYSTEM System string (default: $SYSTEM or builtins.currentSystem)
+ --platform SYSTEM Platform/system for --mode platform-target-dependencies or
+ platform-target-dependency-paths. May be repeated.
+ --rev REV Git revision to evaluate (default: $REV or git HEAD from git-dir/world)
+ --target TARGET Target to evaluate. May be repeated. If omitted, discovers all target names.
+ --include-dependencies
+ Add a dependencies attr to target-records output
+ --mode MODE target-records-jsonl, target-records, target-drvs, targets,
+ target-dependencies, target-dependency-paths,
+ platform-target-dependencies,
+ platform-target-dependency-paths, or target-names
+ (default: target-records-jsonl)
+ --nix PATH nix executable (default: $NIX_BIN, ./build/src/nix/nix, or nix)
+ --eval-cache Enable the Tecnix SQLite eval cache (implies --pure)
+ --no-eval-cache Disable the Tecnix SQLite eval cache (default)
+ --parallel Enable parallel eval workers with --eval-cores 0
+ --eval-cores N Pass --eval-cores N (0 means auto; implies --parallel)
+ -v, --verbose Pass --verbose to nix (may be repeated)
+ --debug Pass --debug to nix
+ --log-format FORMAT Pass --log-format FORMAT to nix
+ --pure Use --pure-eval instead of --impure
+ --impure Use --impure (default; matches tec eval)
+ --print-result Print the final eval result to stdout (default)
+ --output PATH Write the final eval result to PATH instead of stdout
+ --discard-output Force the result but redirect stdout to /dev/null
+ -h, --help Show this help
+
+Default behavior measures builtins.tecnixTargets by producing one JSON target
+record per line ({ target, drvPath }) for each selected target. Pass
+--include-dependencies to add source dependencies to each record. Dependency
+modes use builtins.tecnixTargets with includeDependencies enabled. Use --mode
+target-dependency-paths for the compact target -> [repo-relative path] graph, or
+--mode target-dependencies for the target -> { path = fingerprint; } graph:
+ nix eval --raw --lazy-trees --impure \
+ --extra-experimental-features 'nix-command parallel-eval wasm-builtin' \
+ --option tecnix-eval-cache false
+
+By default the final expression deepSeqs the discovered target list and selected
+result, then prints the result. Use --discard-output for timing-only runs or
+--output PATH to save it. The Tecnix eval cache is disabled by default, but normal
+in-process evaluator caches remain enabled where the evaluator chooses to use
+them. Pass --eval-cache to enable the Tecnix SQLite eval cache and run with
+--pure-eval, which is required for cache hits.
+EOF
+}
+
+world=${WORLD:-}
+checkout_path=${CHECKOUT_PATH:-}
+git_dir=${GIT_DIR:-}
+resolver=${RESOLVER:-system/tectonix/resolve.nix}
+system=${SYSTEM:-}
+rev=${REV:-}
+nix_bin=${NIX_BIN:-}
+mode=${MODE:-target-records-jsonl}
+eval_cores=${EVAL_CORES:-}
+pure_eval=0
+print_result=1
+output_path=${OUTPUT:-}
+include_dependencies=${INCLUDE_DEPENDENCIES:-0}
+tecnix_eval_cache=${TECNIX_EVAL_CACHE:-0}
+targets=()
+platforms=()
+nix_log_args=()
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --git-dir)
+ git_dir=$2
+ shift 2
+ ;;
+ --world)
+ world=$2
+ shift 2
+ ;;
+ --checkout-path)
+ checkout_path=$2
+ shift 2
+ ;;
+ --resolver)
+ resolver=$2
+ shift 2
+ ;;
+ --system)
+ system=$2
+ shift 2
+ ;;
+ --platform)
+ platforms+=("$2")
+ shift 2
+ ;;
+ --rev)
+ rev=$2
+ shift 2
+ ;;
+ --target)
+ targets+=("$2")
+ shift 2
+ ;;
+ --mode)
+ mode=$2
+ shift 2
+ ;;
+ --include-dependencies)
+ include_dependencies=1
+ shift
+ ;;
+ --nix)
+ nix_bin=$2
+ shift 2
+ ;;
+ --eval-cache)
+ tecnix_eval_cache=1
+ pure_eval=1
+ shift
+ ;;
+ --no-eval-cache)
+ tecnix_eval_cache=0
+ shift
+ ;;
+ --parallel)
+ eval_cores=0
+ shift
+ ;;
+ --eval-cores)
+ eval_cores=$2
+ shift 2
+ ;;
+ -v|--verbose)
+ nix_log_args+=(--verbose)
+ shift
+ ;;
+ --debug)
+ nix_log_args+=(--debug)
+ shift
+ ;;
+ --log-format)
+ nix_log_args+=(--log-format "$2")
+ shift 2
+ ;;
+ --pure)
+ pure_eval=1
+ shift
+ ;;
+ --impure)
+ pure_eval=0
+ shift
+ ;;
+ --print-result)
+ print_result=1
+ output_path=
+ shift
+ ;;
+ --output)
+ output_path=$2
+ print_result=0
+ shift 2
+ ;;
+ --discard-output)
+ print_result=0
+ output_path=
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "unknown argument: $1" >&2
+ usage
+ exit 2
+ ;;
+ esac
+done
+
+case "$mode" in
+ target-records|target-records-jsonl|target-dependencies|target-dependency-paths|platform-target-dependencies|platform-target-dependency-paths|targets|target-drvs|target-names) ;;
+ *)
+ echo "invalid --mode '$mode' (expected target-records, target-records-jsonl, target-dependencies, target-dependency-paths, platform-target-dependencies, platform-target-dependency-paths, targets, target-drvs, or target-names)" >&2
+ exit 2
+ ;;
+esac
+
+if [[ ${#platforms[@]} -gt 0 && "$mode" != platform-target-dependencies && "$mode" != platform-target-dependency-paths ]]; then
+ echo "--platform only applies to --mode platform-target-dependencies or platform-target-dependency-paths" >&2
+ exit 2
+fi
+
+if [[ "$include_dependencies" == 1 ]]; then
+ case "$mode" in
+ target-records|target-records-jsonl) ;;
+ *)
+ echo "--include-dependencies only applies to target-records and target-records-jsonl modes" >&2
+ exit 2
+ ;;
+ esac
+fi
+
+case "$tecnix_eval_cache" in
+ 1|true|yes|on)
+ tecnix_eval_cache=1
+ ;;
+ 0|false|no|off)
+ tecnix_eval_cache=0
+ ;;
+ *)
+ echo "invalid TECNIX_EVAL_CACHE/--eval-cache value '$tecnix_eval_cache'" >&2
+ exit 2
+ ;;
+esac
+
+if [[ "$tecnix_eval_cache" == 1 && "$pure_eval" != 1 ]]; then
+ echo "--eval-cache requires pure evaluation; pass --pure or let --eval-cache imply it, and do not also pass --impure" >&2
+ exit 2
+fi
+
+if [[ -z "$nix_bin" ]]; then
+ if [[ -x "$repo_root/build/src/nix/nix" ]]; then
+ nix_bin="$repo_root/build/src/nix/nix"
+ else
+ nix_bin=nix
+ fi
+fi
+
+if [[ -n "$world" ]]; then
+ world=$(cd "$world" && pwd -P)
+elif [[ -d "$HOME/world" ]]; then
+ world=$(cd "$HOME/world" && pwd -P)
+fi
+
+if [[ -z "$git_dir" ]]; then
+ if [[ -d "$default_world_git" ]]; then
+ git_dir=$default_world_git
+ elif [[ -n "$world" ]]; then
+ git_dir=$(git -C "$world" rev-parse --absolute-git-dir)
+ else
+ echo "could not determine git dir; pass --git-dir or --world" >&2
+ exit 2
+ fi
+fi
+
+git_dir=$(cd "$git_dir" && pwd -P)
+
+if [[ -z "$rev" ]]; then
+ if rev=$(git --git-dir "$git_dir" rev-parse HEAD 2>/dev/null); then
+ :
+ elif [[ -n "$world" ]]; then
+ rev=$(git -C "$world" rev-parse HEAD)
+ else
+ echo "could not determine rev; pass --rev" >&2
+ exit 2
+ fi
+fi
+
+if ! git --git-dir "$git_dir" cat-file -e "$rev:$resolver" 2>/dev/null; then
+ cat >&2 <"$target_names_json"
+ python3 - "$target_names_json" <<'PY'
+import json
+import sys
+with open(sys.argv[1]) as f:
+ for target in json.load(f):
+ print(target)
+PY
+ rm -f "$target_names_json"
+}
+
+targets_discovered_separately=0
+if [[ ${#targets[@]} -eq 0 ]]; then
+ case "$mode" in
+ target-dependencies|target-dependency-paths)
+ # Discover target names in a separate Nix process for dependency
+ # modes. Discovering names in the same evaluator can pre-force broad
+ # resolver/module values and distort the per-target dependency graph
+ # that the second phase is trying to measure.
+ while IFS= read -r target; do
+ targets+=("$target")
+ done < <(discover_target_names_for_system "$system")
+ targets_discovered_separately=1
+ ;;
+ esac
+fi
+
+include_dependencies_nix=false
+if [[ "$include_dependencies" == 1 ]]; then
+ include_dependencies_nix=true
+fi
+
+system_label=$system
+
+if [[ "$mode" == platform-target-dependencies || "$mode" == platform-target-dependency-paths ]]; then
+ if [[ ${#platforms[@]} -eq 0 ]]; then
+ platforms=("$system")
+ fi
+
+ platforms_expr=$(target_list_expr "${platforms[@]}")
+
+ if [[ ${#targets[@]} -gt 0 ]]; then
+ target_refs_expr=$(target_ref_list_expr_for_platforms)
+ targets_label="${#targets[@]} explicit target ref(s)"
+ else
+ target_refs_expr='builtins.tecnixTargetNames baseArgs'
+ targets_label="all discovered target refs for platforms (${platforms[*]})"
+ fi
+
+ if [[ "$mode" == platform-target-dependencies ]]; then
+ result_expr='targetDependencyPathSets'
+ else
+ result_expr='targetDependencyPaths'
+ fi
+ seq_expr='builtins.deepSeq platforms (builtins.deepSeq targetRefs (builtins.deepSeq result result))'
+ discard_seq_expr='builtins.deepSeq platforms (builtins.deepSeq targetRefs (builtins.deepSeq result "ok"))'
+
+ if [[ "$print_result" == 1 || -n "$output_path" ]]; then
+ final_expr=$seq_expr
+ eval_output_flag=--json
+ else
+ final_expr=$discard_seq_expr
+ eval_output_flag=--raw
+ fi
+
+ expr=$(cat <&2 <&2
+ printf '%q ' "${nix_log_args[@]}" >&2
+ printf '\n' >&2
+fi
+
+if [[ -n "$checkout_path" ]]; then
+ echo " checkoutPath: $checkout_path" >&2
+fi
+
+nix_args=(
+ eval
+ "$eval_output_flag"
+ --lazy-trees
+ --extra-experimental-features 'nix-command parallel-eval wasm-builtin'
+ --option tecnix-eval-cache "$tecnix_eval_cache_nix"
+ --option tectonix-git-dir "$git_dir"
+ --option tectonix-git-sha "$rev"
+)
+
+if [[ ${#nix_log_args[@]} -gt 0 ]]; then
+ nix_args+=("${nix_log_args[@]}")
+fi
+
+nix_args+=(--expr "$expr")
+
+if [[ -n "$checkout_path" ]]; then
+ nix_args+=(--option tectonix-checkout-path "$checkout_path")
+fi
+
+if [[ -n "$eval_cores" ]]; then
+ nix_args+=(--eval-cores "$eval_cores")
+fi
+
+if [[ "$pure_eval" == 1 ]]; then
+ nix_args+=(--pure-eval)
+else
+ nix_args+=(--impure)
+fi
+
+if [[ -n "$output_path" ]]; then
+ mkdir -p "$(dirname "$output_path")"
+ exec /usr/bin/time -p "$nix_bin" "${nix_args[@]}" >"$output_path"
+elif [[ "$print_result" == 1 ]]; then
+ exec /usr/bin/time -p "$nix_bin" "${nix_args[@]}"
+else
+ exec /usr/bin/time -p "$nix_bin" "${nix_args[@]}" >/dev/null
+fi
diff --git a/src/libexpr-tests/meson.build b/src/libexpr-tests/meson.build
index 65c88a744b..7e4ab96ea9 100644
--- a/src/libexpr-tests/meson.build
+++ b/src/libexpr-tests/meson.build
@@ -65,6 +65,8 @@ sources = files(
'nix_api_value_internal.cc',
'primops.cc',
'search-path.cc',
+ 'tecnix-dependency-tracking.cc',
+ 'tecnix-worldtree.cc',
'tectonix.cc',
'trivial.cc',
'value/context.cc',
@@ -105,6 +107,7 @@ if get_option('benchmarks')
'dynamic-attrs-bench.cc',
'get-drvs-bench.cc',
'regex-cache-bench.cc',
+ 'tecnix-eval-bench.cc',
)
benchmark_exe = executable(
diff --git a/src/libexpr-tests/primops.cc b/src/libexpr-tests/primops.cc
index ca13f64875..e1bf149565 100644
--- a/src/libexpr-tests/primops.cc
+++ b/src/libexpr-tests/primops.cc
@@ -60,6 +60,12 @@ TEST_F(PrimOpTest, abort)
ASSERT_THROW(eval("abort \"abort\""), Abort);
}
+TEST_F(PrimOpTest, breakForcesThunkBeforeReturning)
+{
+ auto v = eval("builtins.break (1 + 2)");
+ ASSERT_THAT(v, IsIntEq(3));
+}
+
TEST_F(PrimOpTest, ceil)
{
auto v = eval("builtins.ceil 1.9");
diff --git a/src/libexpr-tests/tecnix-dependency-tracking.cc b/src/libexpr-tests/tecnix-dependency-tracking.cc
new file mode 100644
index 0000000000..44f89c569a
--- /dev/null
+++ b/src/libexpr-tests/tecnix-dependency-tracking.cc
@@ -0,0 +1,411 @@
+#include
+#include
+#include