Skip to content

libexpr: builtins.tecnixQueryFile, and fingerprints that record observations - #87

Open
joshheinrichs-shopify wants to merge 2 commits into
mainfrom
tecnix-query-file
Open

joshheinrichs-shopify wants to merge 2 commits into
mainfrom
tecnix-query-file

Conversation

@joshheinrichs-shopify

@joshheinrichs-shopify joshheinrichs-shopify commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Replace the three World-specific manifest builtins from #72 (tectonixManifestEntry, tectonixManifestKeys, tectonixManifestIdToPath) and their .meta/manifest.json# synthetic keys with one generic builtin that queries any JSON or TOML file in the repository, and with a fingerprint format that records what was asked and what the answer was. Tecnix no longer knows anything about World.

builtins.tecnixQueryFile {
  path = ../../.meta/manifest.json;   # format inferred from the extension
  op = "get";                         # get | has | keys | find
  at = [ "//areas/tools/dev" ];       # object keys, root down
  # find only: where = "id"; value = "W-123456";
}

The model. A closure maps a repo path to a fingerprint, and a fingerprint records what evaluation observed about the path: git:<oid>;mode=… is the object observed there, absent that none was. A target that only queried a data file observed less than the whole object, so it records the answers it got:

".meta/manifest.json": "query:json\tget\t//areas/tools/dev\t{\"id\":\"W-c10e84\"}\njson\thas\t//areas\ttrue"

one line per distinct query, sorted. The key stays the repo path, so everything that only knows paths keeps working (tec's affected-targets stays conservatively correct with no change), and the TXDC row format is untouched. A target that also read the file whole records the oid, which subsumes the queries. #72 had moved the observation into the key instead, which is why it needed a key grammar, a # special case in validation, and a tec change.

How it works. The builtin reads the file unrecorded through the repo accessor, parses it once per evaluation, evaluates the op, and records an access <file>\0<line> through the ordinary recordAccess, so labels, tecnixMemoize replay and thread confinement apply unchanged; the line is built in thread-local scratch and interned on first sight like a path. Flattening groups accesses by file into one query: fingerprint. Validation (dependencyFingerprintHolds, replacing the string equality in candidateMatches) splits a stored query: fingerprint into views, re-asks each line of the file as the current tree serves it (parsed once per thread per run, read unrecorded), and memoizes per (path, line) in the existing thread-local memo. Evaluation and validation run the same evaluateDataQuery on the same parsed tree, so they cannot disagree. A line that does not parse, an unknown op or format, or a file that no longer parses does not hold: a miss, never a stale hit. A missing file answers every query as an empty document would, and that answer is recorded.

Format: a line is <format>\t<op>\t<fixed args…>\t<at segment…>\t<answer>, the answer last so a variable number of segments is unambiguous. Fields never contain tab, newline or NUL: the builtin rejects string arguments that do, and answers are absent, compact JSON when at most 64 bytes (which escapes control characters), or sha256:<hex> of it. Parsing a fingerprint is splitting, with no unescaping.

Outside the repo accessor the builtin is fromJSON (readFile path) plus the selection; a path pure evaluation forbids throws as readFile would (lstat, not maybeLstat, which reports forbidden paths as absent).

Removed: the three builtins, getTecnixManifestJson and its EvalData fields, manifestPathFingerprint and the per-thread parsed manifest in eval-cache.cc. The unsafeTectonixInternalManifest* builtins stay; they record the whole file, correctly. Old cache rows (keys .meta/manifest.json#…) read as paths that do not exist and miss; no blob version bump is needed.

Tests: src/libexpr-tests/tecnix-data-query.cc (27: op semantics, line round-trips, rejected characters, answer hashing, TOML and JSON of one tree agreeing, every hold/miss case including unparseable and unknown lines, grouping and subsumption in dependencyFingerprints); tests/functional/tecnix/data-query.sh (replaces manifest-tracking.sh, keeping its memoize cases): exact fingerprints per target; across ten commits, adding a zone misses only keys and the whole-file reader, has b survives b's renumbering, a comment-only TOML edit misses only the whole-file reader, a file whose absence was observed misses when it appears, a dirty data file is queried from disk and its observation follows; argument validation; the builtin outside tracking; the older-rev read. The tecnix suite and the Tecnix unit tests pass.

Docs: explainer §4.2 and the fingerprint vocabulary; a guardrail on fingerprints as observations.

Also, a second commit: EvalSettings::getDefaultNixPath no longer throws when a channels directory cannot be stat'ed. It used the throwing std::filesystem::exists, so the EPERM a strict Darwin sandbox gives for /nix/var/nix/profiles aborted every impure EvalState: every libexpr test fixture, the C API flake tests and the inner nix of recursive.sh, which is how this pin failed to build its own test suites on a Mac with sandbox = true (macOS defaults it off). World's Darwin build of this pin passes all suites with it (unit tests and 200/200 functional).

…vations

Replace the three World-specific manifest builtins from #72
(tectonixManifestEntry, tectonixManifestKeys, tectonixManifestIdToPath) and
their `.meta/manifest.json#` synthetic keys with one generic builtin that
queries any JSON or TOML file in the repository, and with a fingerprint
format that records what was asked and what the answer was. Tecnix no longer
knows anything about World.

    builtins.tecnixQueryFile {
      path = ../../.meta/manifest.json;   # format inferred from the extension
      op = "get";                         # get | has | keys | find
      at = [ "//areas/tools/dev" ];       # object keys, root down
      # find only: where = "id"; value = "W-123456";
    }

The model. A closure maps a repo path to a fingerprint, and a fingerprint
records what evaluation observed about the path: `git:<oid>;mode=…` is the
object observed there, `absent` that none was. A target that only queried a
data file observed less than the whole object, so it records the answers it
got:

    ".meta/manifest.json": "query:json\tget\t//areas/tools/dev\t{\"id\":\"W-c10e84\"}\njson\thas\t//areas\ttrue"

one line per distinct query, sorted. The key stays the repo path, so
everything that only knows paths keeps working (tec's affected-targets stays
conservatively correct with no change), and the TXDC row format is untouched.
A target that also read the file whole records the oid, which subsumes the
queries. #72 had moved the observation into the key instead, which is why it
needed a key grammar, a `#` special case in validation, and a tec change.

How it works. The builtin reads the file unrecorded through the repo
accessor, parses it once per evaluation, evaluates the op, and records an
access `<file>\0<line>` through the ordinary recordAccess, so labels,
tecnixMemoize replay and thread confinement apply unchanged; the line is
built in thread-local scratch and interned on first sight like a path.
Flattening groups accesses by file into one `query:` fingerprint.
Validation (`dependencyFingerprintHolds`, replacing the string equality in
candidateMatches) splits a stored `query:` fingerprint into views, re-asks
each line of the file as the current tree serves it (parsed once per thread
per run, read unrecorded), and memoizes per (path, line) in the existing
thread-local memo. Evaluation and validation run the same evaluateDataQuery
on the same parsed tree, so they cannot disagree. A line that does not
parse, an unknown op or format, or a file that no longer parses does not
hold: a miss, never a stale hit. A missing file answers every query as an
empty document would, and that answer is recorded.

Format: a line is `<format>\t<op>\t<fixed args…>\t<at segment…>\t<answer>`,
the answer last so a variable number of segments is unambiguous. Fields
never contain tab, newline or NUL: the builtin rejects string arguments that
do, and answers are `absent`, compact JSON when at most 64 bytes (which
escapes control characters), or `sha256:<hex>` of it. Parsing a fingerprint
is splitting, with no unescaping.

Outside the repo accessor the builtin is fromJSON (readFile path) plus the
selection; a path pure evaluation forbids throws as readFile would (lstat,
not maybeLstat, which reports forbidden paths as absent).

Removed: the three builtins, getTecnixManifestJson and its EvalData fields,
manifestPathFingerprint and the per-thread parsed manifest in eval-cache.cc.
The unsafeTectonixInternalManifest* builtins stay; they record the whole
file, correctly. Old cache rows (keys `.meta/manifest.json#…`) read as paths
that do not exist and miss; no blob version bump is needed.

Tests: src/libexpr-tests/tecnix-data-query.cc (27: op semantics, line
round-trips, rejected characters, answer hashing, TOML and JSON of one tree
agreeing, every hold/miss case including unparseable and unknown lines,
grouping and subsumption in dependencyFingerprints);
tests/functional/tecnix/data-query.sh (replaces manifest-tracking.sh, keeping
its memoize cases): exact fingerprints per target; across ten commits, adding
a zone misses only `keys` and the whole-file reader, `has b` survives b's
renumbering, a comment-only TOML edit misses only the whole-file reader, a
file whose absence was observed misses when it appears, a dirty data file is
queried from disk and its observation follows; argument validation; the
builtin outside tracking; the older-rev read. The tecnix suite and the Tecnix
unit tests pass.

Docs: explainer §4.2 and the fingerprint vocabulary; a guardrail on
fingerprints as observations.
…h path

EvalSettings::getDefaultNixPath adds ~/.nix-defexpr/channels and root's
channels to the search path when they exist, asking with the throwing
std::filesystem::exists. A stat that fails with anything but ENOENT or
ENOTDIR therefore aborts the construction of every impure EvalState:

    filesystem error: in posix_stat: failed to determine attributes for
    the specified path: Operation not permitted
    ["/nix/var/nix/profiles/per-user/root/channels/nixpkgs"]

That is what a strict Darwin sandbox answers, since /nix/var/nix/profiles
is not among the paths it exposes, so a `nix` run inside a sandboxed build
on macOS cannot evaluate impurely at all. It is how Tecnix's own test
suite fails when built with `sandbox = true` on a Mac (macOS defaults the
sandbox off, which is why nobody saw it): every libexpr test fixture, the
C API flake tests, and the inner `nix build` of tests/functional/recursive.sh.
On Linux the directory is simply absent inside the chroot and the ENOENT
is swallowed, as intended.

Ask with the non-throwing overload. A directory the filesystem will not
even stat is not a default search path, which is the answer for one that
does not exist, and no reason to refuse to evaluate.

The test makes root's channels directory unreadable through a parent
without search permission and checks that getDefaultNixPath neither
throws nor lists it. It is skipped as root, who is never refused a stat.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant