Wado

WEP: Struct Walkability — Field Walks over Reflect and #[secret] Fields

Status: Draft

Context

Tuples are walkable: for let v of tuple unrolls at compile time with each element typed individually (Compile-Time Tuple Enumeration). The same capability is wanted for structs — serialization, structured logging (core:log's efficient field passing walks an anonymous struct's fields in the sink), schema derivation (Jade), and Inspect all need to visit each field with its concrete type.

The substrate already exists. Type packs, [..T::method()] expansion, variadic trait impls, and tuple for-of are implemented (Variadic Type Parameters); Reflect — the compiler-synthesized projection of a struct's fields into a typed tuple — is designed there (§10–11) and extended by Reflect Derivation, but unbuilt.

This WEP settles two questions raised while reviewing that design:

  1. Does struct walking need a new language primitive, in particular for nested structs?
  2. How does field redaction interact with reflection? Today #[secret] affects only Inspect/InspectAlt and wado doc; serde serializes secret fields normally, so a redacted credential still leaks through json::to_string. Once any package can walk any struct via Reflect, this hole widens.

Decision

Struct walk is Reflect::fields() + tuple for-of — no new primitive

A struct walk is the existing tuple unrolling applied to the Reflect projection:

fn walk_fields<T, ..F: Inspect>(s: &T) where T: Reflect<Fields = [..F]> {
    let names = T::field_names();
    for let [i, v] of s.fields().enumerate() {
        println(`{names[i]} = {v.inspect()}`);
    }
}

Fields are heterogeneous, so in each unrolled block v has type F_k and the body can only use what the pack bound provides. A struct walk is therefore always a walk with respect to some capability (Inspect, Serialize, a user-defined visitor trait) — that is the meaningful form, matching Zig's inline for over @typeInfo in a static, monomorphized, no-dynamic-reflection model.

Direct for let v of my_struct (without .fields()) is rejected:

Nesting: trait recursion, not a recursive primitive

The walk primitive stays flat (depth 1). Nested structs are handled by a blanket impl that recurses through the trait:

trait Walk {
    fn walk(&self, visitor: &mut Visitor);
}

impl Walk for i32    { fn walk(&self, v: &mut Visitor) { v.visit_int(*self); } }
impl Walk for String { fn walk(&self, v: &mut Visitor) { v.visit_str(self); } }

impl<T, ..F: Walk> Walk for T
where T: Reflect<Fields = [..F]>
{
    fn walk(&self, visitor: &mut Visitor) {
        let names = T::field_names();
        for let [i, f] of self.fields().enumerate() {
            visitor.enter_field(names[i]);
            f.walk(visitor);   // nested structs resolve to this same impl
            visitor.leave_field();
        }
    }
}

Coherence Rule 1 (non-variadic wins, WEP 2026-03-14 §5) resolves the leaf/node split: a primitive field dispatches to its concrete impl, a struct field falls through to the blanket impl and recurses. Termination is the ordinary generics guarantee — monomorphization instances are memoized, so a recursive type (struct Node { next: Option<Node> }) produces runtime recursion, not infinite expansion; only type-growing recursion diverges, as anywhere else.

Consequently the only compiler work for struct walkability is finishing the three unbuilt items already on the WEP 2026-03-14 / 2026-06-13 checklists: Reflect per-struct synthesis, where-clause pack binding T: Reflect<Fields = [..F]>, and coherence Rules 1–2.

#[secret] — upgrade to a security contract

#[secret] today means only "not shown in inspect output". This WEP upgrades it to: the field's value never flows out through any generic introspection channel. "Display-only" redaction — hide from inspect but still serialize — belongs to serde's own vocabulary (rename/skip), not here.

This is not a fourth rung on the visibility ladder. Visibility already has two orthogonal axes (Visibility): the scope ladder (who can name a symbol) and the export CM flag. #[secret] is a third orthogonal axis: whether the value can flow through generic channels. Direct named access keeps following the scope ladder unchanged — credentials must be readable by the code that uses them; #[secret] guards against incidental exfiltration (logs, serialization, dumps, schemas), not against deliberate use by authorized code.

Threat model, stated honestly: #[secret] seals the language-level generic channels — Inspect, serde, and everything built on Reflect. It does not seal the CM boundary (lowering a struct through an export fn signature is a deliberate act by the type's owner, same rank as direct field access), host memory inspection, or a debugger.

Secret<T> — value-opaque, type-transparent projection

In the Reflect projection (and only there), a #[secret] field appears as Secret<F_k> in the Fields pack. The struct declaration and direct field access are unchanged: self.password is still String.

Secret<T> is compiler-synthesized with three properties:

This preserves the pack arity and the index correspondence with field_names() / field_meta(). FieldMeta gains secret: bool so value-level derivation code can branch on it.

Channel consequences

Each channel's behavior follows mechanically from which traits Secret<T> implements:

Why not plug the leak in serde_synth now: serde is slated to become Reflect-based library code (WEP 2026-06-13 §5), so a secret-skip written into the bespoke synthesizer is thrown away at that migration. The skip belongs in the Reflect-based serde, where it reads FieldMeta::secret and is correct by construction. The whole contract — serialize skip, Eq/Ord refusal, Secret<T> — therefore lands as one change on top of Reflect, not dribbled into the synthesizers ahead of the rest (which would leave a half-migrated semantics: serialize still leaking while inspect is already hardened). The accepted cost is that serde keeps serializing secret fields until the migration lands — a known-open leak we choose over throwaway code.

One mechanism stays unresolved, shared by every generic derivation that must drop a field: how to skip inside an unrolled loop when the untaken arm still type-checks under the C++ template model. Candidates: a compile-time if that drops the untaken arm, or inverting control so the value drives the entry via a defaulted trait method that Secret<T> overrides to a no-op.

Implementation checklist

The #[secret] attribute already exists with inspect-only semantics. Everything below rides on the Reflect substrate, which this WEP does not own — it is the three unbuilt items on the WEP 2026-03-14 / 2026-06-13 checklists (per-struct Reflect synthesis, where-clause pack binding T: Reflect<Fields = [..F]>, coherence Rules 1–2). Struct walkability needs nothing beyond them; the #[secret] security upgrade needs the two staged items below.

Consequences

Benefits

Trade-offs