The grant surface is typed data
A grant is a record of tagged values, not a record of strings. The authority model underneath was already typed (places, executables, a chain of network modes, verbs); the surface now says so, so a misspelling is a type error before the program runs, and the decoder parses nothing back out of a string.
Landed in 002c654a. The exec and fs algebra it deliberately left alone was then replaced by authority-is-belnap-evidence; where that changed a statement here, the section says so.
Context: what was wrong
- Policies were strings. An exec entry was
'allow','deny'or a list: one sum type spelled as “string or list”, so'alow'failed only when the grant was decoded at run time. An exec record’s labels (command names and sigils) are an open set no table can declare, soexeccould only beHolds::Decoded. - A first-argument filter is not a subcommand policy. It ignored the remaining arguments and understood neither options nor command semantics.
- Places were strings with a prefix language (
cwd:,tempdir:,gitdir:,~,xdg:NAME,path:,system:, a trailing/for a directory), parsed in a cascade byFreezeCtx::pathandfreeze_exec_grant. net: trueanddetach: truerestricted nothing yet activated confinement. They are neutral in authority under meet and widen, butis_restrictive()counted their presence, switching on resource limits, process braking and loader-variable scrubbing.editorandshellwere records of booleans. Afalsefield was a sticky veto under--extend-baseand an omitted field defaulted tofalse; no record type expresses an optional field, which is a variant’s job (optionality-via-variants).- Spec and code disagreed: SPEC listed
xdg:runtime;XdgKindhas noRuntime.
The surface
grant [
exec: [
allow: [`name git, `name rg, `absolute /bin/sh, `cwd tools/build, `dir `system],
deny: [`name bash, `dir `absolute /opt/homebrew],
],
fs: [read: [`cwd ., `xdg-data ., `absolute /usr/share], write: [`cwd build, `tempdir .], deny: [`cwd .git]],
net: `offline,
editor: [`read],
shell: [`chdir],
] { … }Omitted axes inherit; written axes restrict. The values are ordinary record,
list and variant syntax. A tag’s payload is greedy and parentheses group only
inside $[…], so dir’s payload is a tagged value written as is:
`dir `cwd tools.
- Every place is tagged, and every anchor tag takes a subpath. Lists are
homogeneous and each tag has one payload type, so
[`cwd, '/tmp'](a string beside a tag) and[`cwd, `cwd build](a tag with and without a payload) cannot typecheck. That, not taste, forces.for the anchor itself and a tag on every path. - Places name one path, resolved where the grant is frozen, so a
restrictrecord still crosses the wire unfrozen and binds on the receiving side (FOValuecarries variants).absolutetakes an absolute path;cwd,home,tempdir,gitdirand onexdg-*tag perXdgKindare anchors (one tag per kind, so a misspelled kind is a type error;xdg-runtimeis not a tag). A bare string,~,xdg:NAME,path:,system:and the trailing slash are gone. - Selectors live under
dir.pathandsystem(now alsohomebrew, added by the Belnap decision, which moved Homebrew out ofsystem) name directories the environment chooses. Their expansion has no containment guard, so a$PATHentry can be$HOMEor/; writing one would let a child plant programs where the user’s unsandboxed shell looks (~/.local/bin, a user-owned/opt/homebrew). Reading needs no selector: a directory is read by naming it as a place, and whatever a grant runs is readable. Splitting the types (selector tags only inDirTarget, not inPlace) also makesallow: [`path]a static error, where one shared type would accept a value the decoder always refuses. Decided with Alex: selectors only underdir. - An exec target is
name, a place, ordir. Directory scope is spelled, never inferred from a stat (the trailing slash andRawExecGrant::dirsgo). - Lists are sets, repeats are idempotent. An entry written twice, in one
list or through spreads of two computed lists, adds nothing; a target in both
allowanddenyis denied, as two spellings freezing to one key are; a place may be both read and written. A rule refusing repeats would have to tell a written list from a computed one, which the decoder cannot, and would refuse[...$base, `cwd .]for an overlap that changes nothing. (An early draft refused repeats; a later review made them idempotent.) - Nested tables are records, as a profile is. A map,
[:]included, is refused statically, asreturn ["exec": …]is (SPEC §12.12), with the record spelling (“a grant table is a record; for an exec table that admits nothing writeexec: [allow: []]”). So is(), which stays the empty profile and is not an empty nested table: a profile may be(), the only difference between the two depths. The empty table is an empty list:exec: [allow: []],fs: [read: []]. Decided with Alex. nethas one value,`offline. A grant only narrows, sonet: truerestricted nothing and is not a spelling; omitnetto inherit.editorandshellare verb lists,detachashellverb (below).- No spelling widens. Values restrict or are neutral. The socket plan’s mandatory IPC restrictions are envelope invariants and cannot be lifted by grant syntax.
Payloads
-
P(theabsolutepayload) is classified on the raw string, before any folding, host first. Absolute under the host’s rules is live; absolute only under the other platform’s is a dead grant (FreezeCtx::placeanswersNone: it names no path here, matches nothing, and indenydenies nothing), so one profile loads on both platforms; under neither is refused. Host folding would otherwise readC:/../xon POSIX as the relativex. The table (current; SPEC §12.2 carries it):Raw PPOSIX Windows /x,/absolute dead C:\x,C:/x,\\server\share\xdead absolute \x(root-relative)refused refused (names no drive) C:x(drive-relative), anything relativerefused refused Before this,
/xwas dead on Windows andC:\xa relative-path error on POSIX; the table makes the two symmetric.is_foreign_rootedcounted a leading\only because it ran after folding; on raw input it counts/alone./is the universal prefix on POSIX and dead on Windows, so no authored place yields Windows’ universal prefix: only the internally mintedFrozenPath::rootdoes. A later refinement made a well-formed forward-slash UNC//server/share/xlive on both and refused malformed UNC roots; see capability-freeze. Decided with Alex. -
S(an anchor subpath) is lexical..is the anchor;..folds at freeze and may leave the anchor (`cwd ../sibling), the XDG escape guard still judging the result. The empty string, a leading separator and a drive prefixX:are refused on every platform, judged lexically so a profile fails alike everywhere. Before, a leading separator was silently stripped (relative_suffix), and on WindowsPathBuf::joinwithC:xreplaces the anchor. -
Run-time prefixes are refused in every payload.
~andxdg:NAMEare expanded by the resolver when a path is used, not when a word is evaluated, so`absolute ~/xwould reach the decoder as a relative path and`cwd ~/xwould name a directory called~. The refusal names the place meant (`home x,`xdg-config x). -
One resolver.
FreezeCtxfreezes a place to at most one path and a selector to many; the consumer checks the result suits its use (an unwrapped exec place that is a directory is refused with a hint to usedir). -
The freeze guards are unchanged: the XDG escape check, the
.gitpointer claim-back, the refusal of an unset home, the refusal of an anchored place that freezes relative (a relativeHOMEorTMPDIR), and freezing at decode time.
Each error names the right spelling: `absolute src says to write
`cwd src; `cwd /x says `absolute /x; `cwd '' says
`cwd .; `cwd 'C:x' says a drive belongs in absolute; \x on
Windows says it names no drive.
exec
name Nkeeps the name-grant semantics: the executable found through the host process’sPATH, plus a bundled tool of that name; scopedPATHoverrides cannot redirect it; a name deny vetoes the name everywhere.Nmust be a bare command name; a path-shaped payload is refused with a hint to`absoluteor`cwd.- A place is one executable (
ExecKey::Path). A directory place keeps today’s directory stat and the hint did you mean`dir `absolute /usr/bin? dir DisExecKey::Dirfor each pathDresolves to:`cwd tools/buildis one executable,`dir `cwd toolseverything beneathtools, including programs created later.- Allow or deny, regardless of arguments.
only, legacy subcommand lists andVerdict::Onlyare removed, and an old argument restriction is never read as an unrestricted allow: unsupported policies fail before the grant body runs and a replacement needs an explicit allow or deny. exec: [allow: []]admits no executable; omittingexecinherits.fsis three lists of places (read,write,deny);name,dirand selectors are refused there.fs: [read: []]keeps only the floor.
editor and shell as verb lists
editor takes `read, `write, `tui; shell takes
`chdir, `detach. Every verb is gated by Shell::check and none
reaches the OS projection; each withholds what its flag withheld, chdir
gating cd (which moves the session’s cwd) while within [dir: …] scopes one
and is judged by fs alone.
- Restriction. A verb is permitted only where every layer that supplies the
axis lists it.
shell: [`chdir]therefore withholdsdetach: the safe direction, since detach is the authority that outlives the session. Listing every verb is neutral, like omission; an empty list withholds every verb (deny_allholds empty sets). - Extension joins. A list has no spelling for a veto, so under extension an
allowance never removes authority: a base cannot withhold a verb against an
extension (
chdir: false,detach: falsewere sticky before), and a base that omits the axis keeps every verb. This is the Belnap decision’s rule 3, adopted here because the new surface leaves no other reading of a list; the Belnap algebra computes the same thing asCapabilities::extendoverJoin/join_top. - Representation.
EditorVerbandShellVerbenums (Ord,VariantArray,EnumString, kebab-case),Capabilities.editorand.shellasOption<BTreeSet<_>>;EditorPolicy,ShellPolicyandCapabilities.detachare deleted.FlagisEditor(EditorVerb) | Shell(ShellVerb):voteis membership,keyspells the grant, andGrantStack::permitsand every caller’s gate and order are unchanged. On the wire a verb set is a sequence of kebab-case tag names.
net is a chain
Capabilities.net is Option<Net>, enum Net { Offline }. Meet and join are
both Offline, so the veto stays sticky. The effective mode is the meet of the
grant’s opinion and the host ceiling; Option<Net> is a point of
Offline < Managed < Open IP minus the top, and Net::Managed is where a
grant would ask for Managed. It is not added: the socket plan keeps Managed
controller-only. A place can name a socket path and the tag does not stop a
process connecting, so fs authority and service authority are separate: named
IPC refusal is enforced at launch, independently of fs grants, by that plan,
and a future endpoint delegation needs its own authority design. The
projection is online iff no layer says Offline; its own net: bool stays
until that plan’s NetworkMode replaces it. Typed places establish none of
that plan’s socket guarantees and can land independently.
The checker
Tagged values give exec a closed vocabulary and nested-table ascription checks
optional keys; together they remove the blocker the GRANT table comment
named.
- Contextual handler inference is separated from shape construction.
check_optiontreated everyHolds::Shapedas a catch-all handler. NowHolds::CatchAll(within’shandler) alone invokes it;Shapedinfers an ordinary value and unifies it with the minted type; one helper builds the expected type for inline checking and scratch ascription; key lookup and clash reasons are shared. No second grant schema and no separate profile checker. Holds::Table(&'static Table)for nested tables. A nested table has optional keys, which a row without presence flags cannot express, so it is ascribed, as a contract file is (a-table-never-enters-the-unifier), by the same recursive function, never unified against a record type.- Every nested value is inferred normally (written and computed records follow one rule; duplicate-field checks, spreads and polymorphism are preserved; no contextual record inference is added).
- Each nested check is queued with its span and drained once, in
typecheck(contract::drain_nested), after the pending labels settle and before the contract ascription, in a scratch unifier, against the type as it then stands. Deferring makes the check independent of statement order. The one-shot sessions (alias_arm_scheme,binding_value_scheme,catch_all_stands_in) re-infer bodiestypecheckalready accepted and drop their queue unread. - The check never constrains. A
Shapedvalue constrains a bound variable (net: $vfixes$v); aTablevalue is only checked. A type still unresolved when the queue drains, including a generic function’s parameter that no later instantiation constrains, goes to the runtime decoder, which cannot tell a record from a map and reads both, as the top-level decoder does. - Ascription is ral’s unification: a value whose variant row a
casehas closed to a subset of the vocabulary is refused, as wherever ral meets a wider closed row. Grants add no subtyping. - The top-level option syntax and the profile contract are untouched.
- Closed rows come from the Rust enums. The verb rows are minted from
EditorVerbandShellVerb, thexdg-*tags fromXdgKind, throughVariantArrayand kebab-case names, so a new verb or XDG kind is one variant; anchors,absoluteand selectors are declared once.GRANTholdsexecandfsasTable, andnet,editor,shellasShaped(<offline>,[<read | write | tui>],[<chdir | detach>]);EXEC’sallowanddenyare[ExecTarget](name: String, the place tags,dir: DirTarget,DirTargetbeing place and selector tags);FSlists are[Place]. ATy::Listcannot sit in astatic, which is why these areShaped. - Every clash at a closed row names the row’s tags and offers the nearest.
A variant clash surfaced as
RowExtraField, shared by records and variants and worded as a missing record field with the genericavailable:hint first. SoRowExtraFieldnow says whether its row is a record’s or a variant’s and a variant clash is worded as a tag, everywhere in ral and not only in grants; the nested ascription knows which vocabulary it checks (place, exec target,dirpayload, verb) and words the clash at the value’s span; for aShapedkey the key’s reason takes precedence over the generic row hint. The messages:`cwddis not a place, with the places and the nearest;`pathnames directories to run programs from, so write`dir `pathin an exec list (and fs lists take places);net: truesays a grant only narrows, omitnetor writenet: `offline. - The runtime decoder still validates everything: profiles typed at a
variable, values unresolved when the queue drains, and
restrictrecords off the wire. A nullary tag takes no payload there too; an explicit()is refused. - The drift test was repaired.
every_declared_grant_key_is_handled_by_decode_capability_mapcompared a prefixed refusal with the unprefixedunknown_key, so it passed whatever the decoder did. It now feeds every declared key, at every depth, a valid representative value and requires decoding to succeed, and a second test refuses an unknown key at each depth in that table’s words. - The
GRANTtable comment says what is checked statically and what stays decoded.
The model
VerdictlostOnly(this plan:Allow | Deny; the Belnap decision then replaced it withEvidence). Everything servingOnlywent: its meet and widen set operations,ExecDenial::Subcommand, the argv-first check,admit_label, its diagnostic and rendering branches, the kernel projection’sOnly-to-Allowconversion. The one entry pointGrantStack::admit(program, args)stays: arguments still belong to the admitted launch though permission no longer depends on them.ExecGrant’s map is private. It waspuband written directly (projection, deputy, tests), so the decoder’s “one insertion path” did not exist. Outsidelattice.rsit is built only byDefaultandFromIterator, read throughiter()andget(), pruned throughretain(removes entries, rewrites none; exarch’sdrop_dead_exec_grants), and testscollect. A key is a frozen surface (FrozenPath’sOrdputs the surface first):`cwd tools/xand`absolute /work/tools/xare one key, while two spellings through a symlink are two keys, kept apart and met only when compiled. The Belnap decision later addedevery, replaced the meet insertion byjoin_insert(the plan’smeet_insert) andWidenbyJoin; the privacy and the single insertion path stand.- Activation is stated per axis.
Capabilities::is_restrictivewas true when some supplied axis restricts: exec and fs whenever supplied,netwhenOffline, a verb set unless it lists every verb. The Belnap decision replaced the exec and fs clauses withis_top, making activation denotational.GrantStack::is_restrictivefolds the layers andShell::has_active_capabilitiesdelegates to it; no activation marker is added to the decoder, wire format or launch state. Transitive consumers were audited: resource limits, process braking and loader scrubbing, pipeline braking, the assertion inguard/grant.rs, and exarch’sgrant_summary, which had a second private predicate calling a layer ambient when exec, fs and net said nothing, so a layer withholdingdetachread as “every command, path, and network call is permitted” and no summary printed a verb. Ambience is now!is_restrictive()and a supplied verb set is printed, an empty one as “(none)”. - A neutral policy launches with ambient behaviour. A profile containing
only
net: true,detach: trueor fully enabled boolean policies activated protections before; its neutral replacement does not. Accepted and documented rather than kept through a hidden marker. A neutral layer inside a restrictive stack cannot turn protections off; profiles keeping an fs, exec, offline or verb restriction keep their activation. Also accepted: an extension can add a verb its base lacks. - The wire changed; the engine protocol was bumped (15 to 16 here, 17 by the
Belnap decision). The seed and the warrant have one pinned binary at both
ends; the engine does not:
Run.capscarries the grant stack to synod’s guest, whose engine ships in media built earlier. The bump plus rebuilt guest media makes a stale guest fail the packaging check and the attach handshake, not the first dispatch (engine-protocol).Capabilitiesformerly ignored unknown fields, so an olddetach: falsewould have vanished, widening, in a peer that read it; it now denies unknown fields, asFsPolicyalready did. - Decoder and freeze types (
guard::freeze, public):PlaceisAbsolute | Cwd | Home | Tempdir | Gitdir | Xdg(XdgKind, _)andSelectorisPath | System(Homebrewjoined).FreezeCtx::placereturnsResult<Option<FrozenPath>, PolicyError>(Nonethe dead grant) andFreezeCtx::selectoraVec.FreezeCtx::path,paths,absolute, the special cases infreeze_exec_grantandlooks_like_path_or_sigilare deleted;parse_literal_sigilandrelative_suffixleft the freeze, andparse_xdg_tokensurvives only for the run-time prefix inpath::sigil. exarch’sdeny_layer, the one production caller outside the decoder, freezes its credential and restrict-file paths (absolute host paths) asPlace::AbsolutethroughFreezeCtx::place; the test helpers that froze sigil strings build places too. The per-axis decoders collect lists into sets;allowanddenyshare target decoding and executable-path validation; verb lists decode through the enums’EnumString, an unknown verb listing the offered ones. - Hints were rewritten, not dropped. Retained checks keep their hints in
the new syntax; advice recommending subcommand lists is removed; refusals of
obsolete policies explain that argument restrictions are no longer supported.
No compatibility parser, and no fallback to
Allow.
No argument filtering, and what a future one must be
Argument filtering is removed, not carried into the new syntax. A future argument policy needs a separate design over the actual argument vector: it must preserve argument boundaries and say whether a match covers each whole argument and whether extra arguments are permitted; never reconstruct shell text by joining argv; define meet, extension and refusal messages first (regex string equality is not policy intersection); and keep the central admission function without scaffolding for a matcher now. Such a guard would govern direct ral launches only: the OS enforcers cannot impose argv predicates on execs performed inside admitted children (exec-argv-is-words).
Migration principles
There is no compatibility layer. Allow and deny judgments were preserved,
including under nesting; first-argument policies have no automatic replacement
and are rejected rather than broadened. Per-name verdicts became grouped target
lists (git: 'allow' to allow: [`name git]); an anchored file key keeps
its anchor and is never widened to its containing directory
('cwd:tools/build' to `cwd tools/build, 'cwd:tools/' to
`dir `cwd tools). A boolean-era shell/detach pair migrated jointly: the
new list names exactly the verbs with true votes, and one that would name every
verb is written as omission (in a base) except in an extension, where it adds
verbs. A neutral net: true line was deleted and net: false became
`offline. Messages that spell grant syntax (sandbox/diag.rs hints,
ExecKey’s Display, the guard’s refusals) render a file as an anchored place
and a directory with its dir constructor, so deny summaries keep scope
without the trailing-slash convention. The name[sub,sub] legend and its
prompt rendering were removed.
Rejected alternatives and what was left out
- Reading a trailing slash, or a stat, as directory scope (the earlier design).
- Bare strings as paths, a shared type for selectors and places (a static error
becomes a decoder error; a selector in
fsis a planting vector). - Refusing repeats in a list; a
Net::Managednow; verbs as a record of optional booleans; a verb veto surviving extension. - Contextual record inference for nested tables; a second grant schema or profile checker; unifying a nested table against a record type.
- A compatibility parser, a fallback of old restrictions to
Allow, a hidden activation marker to keep neutral layers hardened. - Reading
Pafter host folding (misclassifiesC:/../x); counting\xas absolute on raw input. - Out of scope, still: the run-time
~/xdg:prefixes on ordinary paths (path::sigil::expand_path_prefix; after this,xdg:survives only there, and whether ordinary paths keep it is a separate question); package-manager selectors beyondhomebrew.
What the Belnap decision changed
The plan kept, deliberately and explicitly, every exec and fs judgment, and
said nothing here entrenches them. The Belnap decision then changed each in one
place: lifetimes (fs prefixes had re-resolved on every check, exec Path and
Dir keys kept their frozen identity; now allowances are frozen and exclusions
re-resolve), what reading includes (the guard read the read list alone while
both kernels read every write prefix and run allowance), ral’s own binary
(Landlock and Seatbelt admitted ral’s image; the guard judged it as a host
file; now an ordinary file everywhere), system’s expansion (Homebrew folded
in; now `dir `homebrew), scope ranking and table composition, extension
(the Option lift reading an omitted base axis as empty), exec and fs
activation (presence-based; now is_top), Verdict (to Evidence), and
Widen (to Join with role types). What stands from this decision: the
surface, Place, Selector, FreezeCtx::place/selector, ExecKey,
ExecGrant’s private map and single insertion path, Net, the verb sets with
their membership, restriction, extension and activation, and the checker.
Review history
Drafted against 0c52a8f4; revised at 6bfbb097 after three review rounds (Claude, Fable, Codex on gpt-6-astra). Findings, in order:
- First round: the plan was reshaped to lead into the Belnap plan (this one replaces the surface, decoder and checker; that one the algebra).
- Second round:
dirpayloads take no parentheses (they group only in$[…]); every list is a set;ExecGrant’s map must become private (it waspuband written directly, so the one insertion path did not exist); nested checks drain only intypecheck; the drift test compared the prefixed refusal to the unprefixed message and passed vacuously; state which judgments the Belnap plan changes. Decided with Alex in this round: selectors only underdir, Windows-absolute paths as dead grants on POSIX, nested tables as records only. - Third round: bump the engine protocol (a stale guest would pass the
packaging check) and deny unknown fields on
Capabilities; exarch’s deny layer was the one production caller of the string freezer and moves to places; classifyPon raw input, not after folding; make repeats idempotent rather than refused; name the diagnostic and summary changes (RowExtraFieldwording,grant_summary’s second activation predicate).
Relations
- It supersedes the surface half of
exec-surface-fails-loudly: the
dirconstructor replaces the trailing slash it kept, and the empty subcommand list has no home once subcommand lists are gone. - The surface now mirrors the partition of
exec-authority-partitioned:
name, a place anddirare itsName,PathandDirkeys. - The exec and fs algebra is the subject of authority-is-belnap-evidence.
Where
guard::freeze (Place, Selector, FreezeCtx::place, FreezeCtx::selector),
capability::lattice (Net, EditorVerb, ShellVerb, Flag, ExecGrant),
typecheck::contract (Holds, drain_nested). See grant,
capability-freeze,
two-enforcers and docs/SPEC.md §12.