# The profile-resolution engine (Stage 4)

Status: design (2026-07-05). Stage 4 of the roadmap (v1.3 §6): run a level predicate
against a resolved behavior profile, behind a flag, alongside the legacy classifier.
Grounded in the current classifier; file:line anchors are approximate and for
orientation. Not yet implemented.

This changes **classification**, not **parsing** — the CST, its fold, and the
delegation re-entry points are reused verbatim; only the leaf verdict of a
*converted* command is computed a new way.

## 1. Where the engine attaches

Today a command line is folded to a `Verdict` bottom-up: `command_verdict` →
`script_verdict` → `pipeline_verdict` → `cmd_verdict` → `simple_verdict`
(`src/cst/check.rs`), combining per-segment verdicts with `Verdict::combine` —
max-level, deny-absorbing (`src/verdict.rs`). The single leaf act is
`simple_verdict`'s call to `handlers::dispatch(&tokens)` (`src/cst/check.rs`),
which walks the handler chain and ends in `registry::toml_dispatch` →
`dispatch_spec` → `dispatch_kind` (`src/registry/dispatch.rs`), yielding
`Allowed(level)` or `Denied`.

The engine replaces exactly that leaf call, and nothing above it. For a converted
command, `simple_verdict` resolves a **behavior profile** and evaluates the active
level's predicate, projecting the result back to a `Verdict` so the existing fold,
redirect handling, substitution recursion, and chain semantics are untouched.
Redirects are the one leaf act that is *not* a command lookup —
`redirect_verdict`/`is_safe_write_target` (`src/cst/check.rs`) — and they become an
intrinsic capability on the enclosing command's profile (§2.3), not a separate
combine.

Two facets of the model do not fit a per-`Cmd` fold and run as a second pass at
`Script` scope (§3.1): the **flow policy** (a `secret`→outbound edge crosses a pipe;
`CmdSub`/`ProcSub` and the `Pipeline.commands` vector are the edges) and
**session-derived worst-casing**. Because both can only *deny* an otherwise-admitted
line, they compose with the deny-absorbing fold as additional deny sources — they
never rescue a denied segment.

## 2. Profile resolution

A resolved command line becomes a **profile**: a set of `capability` records (v1.3
§2.8). Capabilities attach to grammar nodes, classified by their effect on
facet-space:

- **additive** — contributes a capability (a leaf act: a `Policy` sub, a redirect
  write). The profile is the union.
- **modifier** — transforms the facets of capabilities already in scope, applied in a
  pinned order (§2.4). Never adds an operation of its own except the injection-point
  class (§3.5 of v1.3), which is additive-by-modifier.
- **gating** — narrows what a nested resolution may contribute (an isolation frame
  clamps nested `locus` to `sandbox-scope`; a `--` separator bounds where delegation
  begins).

### 2.1 Mapping the DispatchKind tree onto nodes

The existing `DispatchKind` tree (`src/registry/types.rs`) is already the node
structure; conversion re-labels each arm as a profile contribution rather than a
level literal:

| DispatchKind | node role | profile contribution |
|---|---|---|
| `Policy{level}` | additive leaf | one capability; its facets are the converted form of what `level` summarized |
| `FirstArg`, `RequireAny` | additive leaf, guarded | same, admitted only when the guard/pattern matches |
| `Branching{subs}` | interior node | recurse into the matched `SubSpec`; each sub is its own subtree of nodes |
| `WriteFlagged{write_flags}` | leaf + modifier | base capability; presence of a write flag is the `--force`-class modifier that raises `reversibility`/`persistence` |
| `Wrapper`, `DelegateAfterSeparator`, `DelegateSkip` | delegation frame | a **frame** (§2.2) over the nested profile obtained by re-entering the resolver on `tokens[i..]` |
| `Custom{handler}` | opaque or sub-forest | handler-decided; if it consults `try_sub_dispatch`/`try_fallback_grammar` the subs/fallback are ordinary nodes, else the profile is opaque → worst-case |

The `SafetyLevel` a leaf carries today (`Policy.level`, `first_arg_level`, sub
`level`) is the pre-conversion summary; converting the command replaces each with the
explicit facet vector it stood for. This is the only data edit conversion requires
(§7).

### 2.2 Delegation re-enters the resolver

`Wrapper`/`Delegate*` already reconstruct the inner command line and call
`command_verdict` recursively (`src/registry/dispatch.rs`). Under the engine that call
returns a nested **profile**, and the frame transforms it before union (v1.3 §3.1):

- `transparent` (`bash -c`, `env`, `xargs`) — identity; `env` with `LD_PRELOAD`/`PATH`
  adds `reconfiguring`.
- `privilege` (`sudo`, `doas`) — nested `authority := elevated/root`.
- `remote` (`ssh h CMD`, `docker exec`) — nested `locus.remote`; intrinsic outbound.
- `isolation` (`docker run`, `bwrap`) — clamp nested `locus.local` to
  `sandbox-scope`, re-add breach loci.
- `payload` (`psql -c`, `kubectl apply -f`) — nested is a typed document, decomposed
  by the four gates and resolution ladder (§3.1).
- `task-runner` (`make`, `npm run`) — nested is `ambient-config`, opaque unless a
  trusted-config root.
- `interactive` (bare `ssh`, a REPL) — nested is future input, unavailable → opaque,
  unbounded.

Frames compose (`sudo ssh h CMD` = privilege ∘ remote); nesting is shallow so
resolution runs to completion, and an unparseable inner command is opaque →
worst-case. Cross-segment compounding (`a && b | c`) is **not** delegation — the
`Script`/`Pipeline` fold already handles it; fan-out (`xargs`, `find -exec`) is the
`scale` facet, not a frame.

### 2.3 Argument resolution and worst-case

Locus, secret, disclosure audience, network destination, and scale are functions of
argument *values*, resolved by classifiers that generalize the predicates the current
code already has: `is_safe_write_target` (`src/cst/check.rs`) becomes `classify_locus`
over a write target (the same `/tmp`, `.git/`, `.envrc`, `$`-bearing, parent-escape
distinctions become `local` ordinal rungs); `PositionalShape`/`looks_like_path`
(`src/policy.rs`) becomes a shape input to the same classifier; `consumes_next_value`
(`src/policy.rs`) is unchanged — it is grammar, not facet, and still decides which
token is an argument. Operand roles (source vs sink for `rsync`/`scp`) flip the
dominant facet per v1.3 §3.3.

**Rule (unchanged posture):** any value that cannot be pinned — `$VAR`, glob, stdin, a
substitution — takes the facet's worst term, and the assumption is recorded in
`because`. This is the profile-level statement of the allowlist floor already enforced
by denying unresolved substitutions (`src/cst/check.rs`).

### 2.4 Modifier order

Modifiers must apply in a pinned order so the profile is a function of the form, not
of token order (v1.3 §3.5): `--dry-run` (→ all `observe`) first, then retargeting
(`-o FILE`/redirect → locus), then severity raisers (`--force` → `irreversible`, `-R`
→ `unbounded`), then injection-point flags. `--dry-run` dominating first is what makes
`terraform plan --destroy` observe-only regardless of the `--destroy` token.

## 3. Level evaluation

A level is a disjunction of **clauses**; each clause is a conjunction of per-facet
constraints plus a flow policy (v1.3 §4, §4.1). The TOML primitives are
`facet = "<= term"` (ordinal ceiling), `facet = ["a","b"]` (categorical set), and
`extends = "other"` (inherit clauses, then add).

Evaluation:

1. A **capability is admissible** iff some clause admits *every* one of its facets
   (ordinal ≤ ceiling; categorical ∈ set; compound facets checked axis-by-axis, see
   Findings). Clauses are tried in order; first admitting clause wins.
2. A **profile passes** iff every capability is admissible **and** the `Script`-scope
   flow policy holds.
3. The passing/failing profile is projected to a `Verdict`: pass → `Allowed(L')` where
   `L'` is the legacy-lattice projection of the profile (Stage-3 bridge run backward,
   for the reported level string only); fail → `Denied`. The gate is "profile passes
   active level," not the projected ordinal.

`extends` is clause-set union: `developer` inherits `write-local`'s clauses and adds
its supply-chain and outbound carve-ins (v1.3 §4.1). Compiling a level is the same
shape as `build_command` compiling TOML to a `DispatchKind` (`src/registry/build.rs`)
— a new `build_level` emitting a `Level` value of `Vec<Clause>`.

### 3.1 Resolution depth makes it lazy

The predicate names the resolution it needs (v1.3 §4.4). Facets are resolved
**lazily**: a level whose clauses never reference `execution.supply_chain` does not
descend a payload body; a level that ceilings `payload = forbid` decides at *presence*
of the raw hatch and never parses it. Unavailable resolution (no parser for the nested
language, an unpinnable argument) is worst-case → deny. This is the guarantee that the
engine need not become a universal interpreter: the deep case (a k8s `apply -f` body,
gate 4 — `behavioral-taxonomy-k8s-resolver`) is built once, per `(language, level)`,
and every level that does not demand it stops shallow.

## 4. Coexistence dispatch (v1.3 §4.5)

The converted-vs-legacy switch lives at the *top-level command lookup*, i.e. just
inside `simple_verdict` where `handlers::dispatch` is called today
(`src/cst/check.rs`), keyed on the command name exactly as `toml_dispatch` keys the
registry (`src/registry/mod.rs`):

- **Converted** (TOML carries a behavior profile) → resolve the profile (§2) and
  evaluate the active level (§3).
- **Legacy** (TOML carries only `Inert`/`SafeRead`/`SafeWrite`) → run the existing
  classifier unchanged, then map the legacy level onto the lattice via the Stage-3
  bridge — `Inert→inert`, `SafeRead→read-local`, `SafeWrite→developer` — and admit iff
  the mapped level lies within the active level.
- **Unclassified** (neither) → `Denied`, as today. The floor is unchanged.

A command's TOML signals converted-vs-legacy by the presence of profile data: the
natural encoding is a `[[command.profile]]`/`profile = "…"` presence check on
`TomlCommand` (mirroring how `handler`, `wrapper`, `deny` already select a build path
in `build_command`, `src/registry/build.rs`). Conversion is **atomic at the tool
subtree** (v1.3 §4.5): a command is converted only when every sub/flag/arg-shape under
it has profile data, enforced at build time by a `build_command` assertion (the same
class as the existing flat-vs-structured assertion) — no half-converted tool compiles.

Because the shipped default level is `developer` (≈ the old `SafeWrite` threshold),
converted and legacy commands auto-approve identically until a user opts into a
stricter level; the machinery is invisible until adopted.

## 5. The Stage-4 rollout harness

"Behind a flag" is a three-state selector — env var
`SAFE_CHAINS_ENGINE={legacy|shadow|new}` (or the equivalent CLI flag on the check
subcommand):

- `legacy` (default) — the current classifier is authoritative; the engine does not
  run.
- `shadow` — the legacy classifier is authoritative for the returned verdict, and the
  engine runs alongside, per level, recording old-vs-new divergences without affecting
  the decision.
- `new` — the engine is authoritative.

The corpus diff is a test/CI harness, not a runtime path: for each form in the
golden-set (v1.3 §5, the 65-form seed) and each command's `examples_safe` /
`examples_denied` (`src/registry/types.rs`), and for each shipped level, compute
`legacy_verdict` and `engine_verdict` and classify the divergence:

- **intended-tightening** — engine denies what legacy allowed, and the level's design
  says so (a `read-local` user holding out a `SafeWrite` tool). Logged, expected.
- **unacceptable-breakage** — engine denies what legacy allowed at the *default* level
  (`developer`), or allows what legacy denied. A gate; the build fails.

The harness is the operational form of v1.3 §4.5's "conversion is monotone and
reviewable": no command is worse off than its legacy behavior at the default level by
accident.

## 6. The proptest surface (v1.3 §4.2)

Represent the profile as one Rust enum per facet (ordinals as
`#[derive(PartialOrd, Ord)]` C-like enums, exactly as `SafetyLevel` `src/verdict.rs`;
categoricals as plain enums; compound facets as structs of their axes).
`Clause`/`Level` are `Vec`s of per-facet constraints. The existing proptest
infrastructure (type-directed `Arbitrary` strategies for words/scripts) is the model
for facet generation — add `Arbitrary` for `Capability` and `Profile`, and the
CST-refactor direction (type-directed generation over the parsed tree) supplies the
end-to-end generator.

The five contract properties become concrete tests:

- **facet-monotonicity** — for any admitted `Profile` and any single facet made *less*
  severe (ordinal down one rung / a categorical swapped for an admitted one), the
  profile stays admitted. A level failing this is incoherent by construction; the test
  is the coherence check on hand-authored level TOML.
- **extends ⇒ superset** — `admits(child, p)` ⇒ `admits(parent-extended, p)` for every
  generated `p`, over all `extends` pairs in the level set.
- **totality** — every generated `Profile` yields a decision (admit or deny), never a
  panic or "unknown"; the worst-case rule guarantees a total function.
- **round-trip** — `Level` → TOML → `Level` is identity, so the compiled artifact
  matches the source.
- **golden-set anchor** — the frozen 65-form corpus resolves to its recorded facet
  vectors; any taxonomy or engine change must keep it classifiable and re-review the
  diff (v1.3 §5).

## 7. Migration mechanics

Converting one command, end to end:

1. Replace the command's `level`/`DispatchKind` *data* with behavior-profile data in
   its `commands/*.toml` — each leaf's `level` becomes an explicit facet vector;
   delegating nodes declare their frame; argument-shaped facets declare their
   classifier. The grammar (subs, flags, `consumes_next_value`, positional shapes) is
   unchanged — it still decides *which tokens are what*.
2. The build-time atomicity assertion (§4) confirms the whole subtree carries profile
   data; the command flips to the engine automatically on next build — there is no
   registration step, matching the auto-discovery `build.rs` already does for TOML
   files.
3. Run the Stage-4 harness (§5): review the per-level golden-set diff, label each
   divergence, gate on zero unacceptable-breakage.

The load-bearing point: **the parsed CST is shared**. `command_verdict` parses once;
the `DispatchKind` walk and the profile resolver consume the same `tokens`/CST.
Conversion swaps the *classification* function at one leaf; every line above it (chain
semantics, substitution recursion, redirect handling, `eval` safety) is identical for
converted and legacy commands. This is what lets the corpus be a mix of the two for as
long as migration takes (v1.3 §6, Stage 5).

## Findings / risks

- **Compound facets are the representational sharp edge.** `locus{local,remote}` and
  `persistence{level, trigger{escape,kind}}` are not single ordinals, so admissibility
  is per-axis and monotonicity must hold *per axis* independently — a naive
  `derive(Ord)` on a compound struct will silently order `kernel` vs `remote` (the
  exact bug R25 fixed in the taxonomy). Model each axis as its own enum and write
  `admits` axis-by-axis; do **not** collapse a compound facet to one ordinal for
  convenience.
- **Modifier order-independence is a proptest liability.** §2.4 pins an order, but
  facet-monotonicity generation must not assume a canonical token order — generate
  profiles directly (post-modifier), not command strings, for the algebra tests, and
  test the modifier pipeline separately with a small hand-built set of `(--dry-run,
  --force, -R, redirect)` combinations to prove order-invariance of the *result*.
- **Shadow-compare cost is per-level × per-form.** Running the engine for every shipped
  level over the whole corpus on every CI run is O(levels × forms × resolution depth).
  Keep it a dedicated corpus-diff test, not part of the hot `command_verdict` path, and
  cache resolved profiles (profile resolution is independent of the level).
- **The projected verdict level is a lie of convenience.** Emitting `Allowed(L')` via
  the backward Stage-3 bridge keeps the fold and the reported level string working, but
  that ordinal is not the authoritative decision. Ensure nothing downstream (docs,
  `targets/*.rs` `additionalContext`) treats the projected `SafetyLevel` of a converted
  command as meaningful beyond display.
- **Flow policy escapes the leaf fold.** Because `secret`→outbound crosses pipe and
  substitution edges, it cannot be decided inside `simple_verdict`; it needs a
  `Script`-scope pass with access to the CST edges. Risk: getting it wrong *silently
  widens* (a missed edge admits an exfil). Ship it deny-only and default-conservative —
  an unrecognized channel is an outbound sink (v1.3 §2.5).

**First slice.** Convert **two flat, high-signal commands** that exercise the whole
pipeline without a deep grammar: (1) a pure-`Policy` read tool such as `grep` or `cat`
— additive leaf, argument-resolved `locus`/`disclosure`, no delegation, no modifiers;
it proves profile resolution, the `read-local` level, and the coexistence switch. (2)
`rm` (or a `WriteFlagged` command) — exercises the `--force`/`-R` modifier pipeline,
`reversibility`/`scale` worst-casing on globs and `$VAR`, and an `intended-tightening`
at `read-local`. Together they cover additive + modifier + argument-resolution + both
bridge directions with zero payload/flow depth, so the Stage-4 harness and the five
proptest properties can be stood up and proven green before any delegating or payload
command is touched.
