Issue #581: One lowered type per map: the hasher is decided per package, and at a package’s boundary it is a type parameter (ADR-010 D9)

Open and answer this issue on GitHub.

language, compiler, ready · opened 2026-10-11 · updated 2026-11-03 · 4 comments

How it came up

In the self-hosting runs (#125, #558) the builders write BTreeMap/BTreeSet in every tools/*.nika file, because a collections::HashMap field did not keep its Rust type: docs/lowering-defects.md D6, docs/mitigation-hurdles.md §3, docs/import-rust-design.md decision D (#579). The owner’s direction (October 11, 2026): the compiler should be clever enough to fix the type only once all the information is there, and, for the package boundary, as with generics, both variants must be possible.

What was checked (on develop a392be71, binary of 46a0ba06)

What decides a map’s Rust type today (emit_exprs.nika map_name, emit/mod.rs trusted_input/trusted_lets, contracts/trust.rs): the provenance of the unit being lowered. A trusted unit writes TrustedMap (Fx, fixed seed) for every HashMap it mentions, types and constructors alike; an untrusted unit writes std’s keyed HashMap, except a let map that never leaves its function and is filled only from trusted values (#431). Which unit’s provenance is used:

The decision

ADR-010 D9, Part III 17.1 std::collections - and where keys come from, the maps row of Part III 13.5, NK1246 in C.3, one sentence in Part I 3.x (maps).

  1. Classes, per package. Every place a map type is written is a position. Positions a map value passes between (initialised, assigned, passed, returned, stored, compared, the arms of an if/match) are one class and get one Rust type. A class is keyed if any key that reaches any value of it is untrusted, or it touches a barrier; otherwise fast. Decided after every file of the package is read; the order of files and which file builds or fills a map change nothing.
  2. At the boundary, a type parameter. A map position in a pub function’s signature or in a pub type is a hidden type parameter of that item, one per position in declaration order. Each use fills it, inside the package and outside, as a generic’s use does.
  3. The ledger maps key per pub function: which positions are one class, and what the body puts in: either, from(p, ...), keyed. The caller joins it. Absent = keyed. It is a permission key (a wrong either lets a caller pick the fast hash for attacker keys), written by the compiler from the body only.
  4. Fixed keyed positions (barriers): a trait method’s signature, a described Rust crate’s signature, a binding for another language.
  5. Rust names: nikaia_std::hash::Fast, nikaia_std::hash::Keyed; a pub type’s parameters default to Keyed, so Rust code that names none gets the safe map.
  6. Conflicts: the join never conflicts (keyed wins, which is safe). Only hasher: (ADR-316 D4, not built) can fix a class to something else; two different named hashers in one class are refused, NK1246.

To build

Tests first, each in crates/nikaia/tests/map_trust.rs unless named:

  1. Package-wide trust (the security fix, first, alone): the probe above (main.nika reads nothing, serve.nika fills a map from net::Connection::read) lowers that map keyed, and --trust and the code agree. Replace the entry-only analysis in modules.rs emit_reading and the per-file one in sysroot.rs lower_tools_at with one analysis over all units; method calls resolve against the whole package.
  2. Test: one type per class across files. A struct declared in one file, built in a second, filled in a third (with and without a method call on it): every mention lowers to the same Rust type; rustc compiles it. The same with the files in the other order. Then the tools probe (hm_a/hm_b) through nikaia lower-std on a scratch sysroot.
  3. Test: a conflict by join. A private fn count(m: ref HashMap[String, i64]) called with a map from a file and one from a socket: both keyed; without the socket call: both fast.
  4. Classes in the compiler (union-find over positions, seeded by D1’s sources and barriers), replacing trusted_input and trusted_lets in EmitState with a per-position answer; --trust lists the classes.
  5. Test: the boundary as a type parameter. A two-package test as contracts_across.rs does: lib with pub struct Tally { counts: HashMap[String, i64] }, pub fn tally_new() -> Tally, pub fn count_in(mut t: Tally, word: ref String); an app that fills it from a file gets Fast, one that fills it from a socket gets Keyed; an app that writes lib::Tally { counts: collections::HashMap() } compiles in both. Then build it: hidden parameters S: nikaia_std::hash::Seed (trait over BuildHasher + Default + Clone), default Keyed, maps made at such a position with S::default().
  6. Test: the ledger line. nikaia.contracts of lib carries maps = ["t.counts: from(word)"] for count_in and maps = ["<result>.counts: either"] for tally_new; a lib function that inserts socket bytes says keyed, and the app’s map becomes keyed through it. Then write the key (Part III 13.5), sorted (ADR-005 D8).
  7. Test: barriers. A map handed to a trait method lowers keyed; a described Rust function taking &HashMap<K, V> makes its class keyed.
  8. Test: a Rust adapter names it. A Rust file using HmNames (default) and HmNames<nikaia_std::hash::Fast, nikaia_std::hash::Fast> compiles against the lowered tools package, whatever the other files of the package do.
  9. Test: determinism. The tools package lowers to the same package.rs with its files read in reverse order.
  10. Measure (callgrind, counts only, CLAUDE.md): 1BRC (benches/brc/brc.sh) must stay at its table’s count; nikaia lower crates/nikaia-std/src/tools/ty.nika before and after one moved module switches a BTreeMap to HashMap. Record both in the benches README and here.
  11. Tools may use HashMap/HashSet again where step 10 shows it counts fewer instructions and nothing iterates the map into output (ADR-005 D8). Update docs/lowering-defects.md D6 and docs/mitigation-hurdles.md §3.
  12. NK1246 is specified ahead of hasher: (ADR-316 D4); its reproduction test belongs to the commit that builds hasher:. The last commit of steps 1-11 says Fixes #N for this issue.

Not decided here: whether Shared’s count (ADR-037 D8) should become a type parameter at the boundary the same way.

Related: #125, #558, #579, #431.


Generated by Claude Code

Comments

keywan-ghadami, 2026-10-11

The decision is written in 943e6286 (0.0.838): ADR-010 D9, Part III 17.1 (the two paragraphs after the hasher paragraph), the maps row of Part III 13.5, NK1246 in C.3, and one sentence in Part I 4.5 (not 3.x as the body says). Notes updated: docs/mitigation-hurdles.md §3 and its ranking row, docs/import-rust-design.md decision D and its table row, docs/lowering-defects.md D6.

Corrections to the body: the last commit of steps 1-11 says Fixes #581. Step 1 is a security fix of its own (a socket read outside the entry file gets the fixed seed today) and can ship before the rest.


Generated by Claude Code

keywan-ghadami, 2026-10-12

Status at 8505a0fc (0.0.848): steps 1-7 are built (package-wide trust, classes, hidden parameters, the maps ledger key, barriers). Measured (callgrind --branch-sim=yes, 200,000 rows, benches/brc/brc.sh): 1BRC 310.9 instructions and 1.70 mispredictions per row; a pub struct with a map costs the same as a private one (310.8 / 1.70); a keyed map costs 480.4 / 2.83 (+54 % instructions, +1.1 mispredictions); a pub function used with both Fast and Keyed adds 4.8 KB of text (+0.65 %). A technical review found no cheaper mechanism at run time (a keyed floor at every pub position, a run-time hasher branch, a random-seed fast hash for all maps, one answer per package each break a goal).

Still to build:


Generated by Claude Code

keywan-ghadami, 2026-10-15

Status at 86db135 (0.0.970): steps 8-10 are built. Step 11 is still open, and step 12 waits for hasher:.

Still to build: the fill per class at use sites, then step 11 (re-measure against a fast HashSet, and update docs/mitigation-hurdles.md §3). Also step 12 (NK1246 with hasher:) and the commit with Fixes #581.


Generated by Claude Code

keywan-ghadami, 2026-10-18

Step 13 added (owner, 2026-10-18): every lowered map on hashbrown directly (#434).

#434 measured TrustedMap on hashbrown instead of std’s HashMap: −3 instructions a row on 1BRC, −3.2 % on an insert-heavy program, no loss found. Std converts hashbrown’s rustc_entry into its own Entry on every entry call. Since steps 1-7 the hasher is a type parameter (Fast/Keyed), so this is one base type for every map, not a second type; ADR-327 D4 already puts Map on hashbrown. The steps and tests are in #434 (base type, everything that names std’s HashMap for a lowered map, barriers keep std’s type, measure 1BRC, insert-heavy and a Keyed map).

13 does not depend on the fill per class at use sites or on step 11, and can be built before them. Its commit says Fixes #434; Fixes #581 stays with the last of steps 1-12.


Generated by Claude Code

Back to Open Issues