Collapse the capability stage split into one always-frozen type
The capability layer carried two structurally-identical types: RawCapabilities
(syntactic, sigil-bearing) and Capabilities (resolved), with freeze the
one-way door and composition (meet / join) running on the raw form so a
single freeze settled the result. The decision: freeze at decode time and
carry one Capabilities everywhere — there is no syntactic stage, so the
boundary becomes a pass inside the one constructor rather than a type split.
This supersedes the same-day analyses that declined to unify (the
2026-06-05 wiki-log entries “why two types” and “what unification would
cost” — the log file is retired; git history keeps them). Those declined a
typestate Capabilities<Stage> — a Raw marker plus a serde bound
incantation — on legibility grounds, and they were right about that shape.
This move is different in kind: it does not parameterise the type, it deletes
the stage. With no Raw stage there is no PhantomData, no serde bound, no
stage-ambiguous default / root — the legibility cost the typestate carried
never arises.
Why the stage was not load-bearing
The two-type design justified itself two ways; neither survived scrutiny.
- Compose-then-freeze needs a pure, environment-free form. But every
composition site already has the freeze environment in hand —
eval_grant(live shell),apply_session_profilesand exarch’sfor_invocation(home_from_env()+ session cwd). Freezing each profile as it loads costs nothing those sites didn’t already have. - Asymmetric serialisation keeps sigils off the wire — only the resolved form
derived
Serialize/Deserialize. But if everyCapabilitiesis resolved by construction, the wire is closed to sigils with one type just as well: there is no other form to exclude.
The only thing deferring freeze actually bought was a semantic choice about one
failable check — the XDG-escapes-$HOME guard. Deferred, it validated only the
composed survivor; an escaping xdg: path a later meet narrowed away was never
checked.
The guard becomes a per-profile invariant
Freezing at decode makes the escape guard fire on any input profile naming an
xdg: path that resolves outside $HOME, survive composition or not. This is an
intended, fail-closed behaviour change: for a defence-in-depth control, refusing
the offending input at the door is the more honest semantic than letting its
rejection depend on what the lattice keeps. See [[design/capability-freeze|the
freeze boundary]].
What the collapse removed
- the
RawCapabilitiestype, itsfreeze/freeze_in_env/validate_pathsmethods, andpath::sigil::validate_xdg_tokens(typo rejection still happens during freeze, viaparse_xdg_token); - the second instantiation of the
deny_all/is_restrictivemacro — inlined ontoCapabilities, the macro retired; - the
freezeindirection at every call site:decode_capability_mapnow takes a&FreezeCtxand returns a frozenCapabilities;meet/joinmoved ontoimpl Capabilities.
Two further intended changes follow: meet / join now compare resolved
paths (strictly more precise — /work/proj/src ⊂ /work/proj is seen), and
error attribution moves earlier, from “invalid grant after composition” to a
per-profile decode/freeze error the caller’s provenance prefix names.
Enforcement is unchanged — core/tests/grant_policy.rs, the lattice_tests
laws, and ral/tests/capabilities.rs pass before and after; the lattice
witnesses use concrete paths, so the laws never depended on the stage.
See also capability-freeze, grant, exec-authority-partitioned, capabilities, policy.