Files
tricu/docs/guard-injection.md
James Eversole fdebb6c13d Tricu 2.0.0
Sorry for squashing all of this but 🤷
2026-05-25 12:44:24 -05:00

11 KiB

Guard Injection Semantics

This document describes the runtime guard model for View Contracts.

Views describe portable structural contracts. Guarded views refine those contracts with executable predicates while keeping ordinary value-level code free of Maybe, Result, sentinel, or host-language abort handling.

viewGuarded baseView guard

A guarded view means: when this guarded view is observed along the reachable checked-execution path, run guard against the runtime value.

Goals

  • Preserve ordinary value-level program shapes.
  • Keep guard failure out of user code.
  • Avoid Haskell-specific checker/runtime semantics.
  • Represent guard boundaries explicitly in portable tree data.
  • Make successful guarded execution transparent: guarded values are unwrapped before ordinary code receives them.
  • Prefer correctness-by-default over avoiding repeated predicate cost.

Non-goals

  • Preventing user-written guards from diverging.
  • Letting guards author their own diagnostics.
  • Solving IO interaction-tree composition.
  • Finalizing long-term artifact identity policy.
  • Deduplicating or hoisting repeated guard checks.

Plain Views vs Guards

Plain Views still provide concrete benefits without guards:

  • structural flow checking;
  • portable API metadata;
  • module/export contract metadata;
  • content-store view-tree metadata;
  • cross-frontend agreement on contract structure;
  • diagnostics for wrong-view flows.

Guards are for invariants that require runtime value inspection, such as:

  • non-empty list;
  • sorted list;
  • byte string of exactly 32 bytes;
  • protocol payload with a valid checksum;
  • domain-specific runtime predicate.

Guards are deliberately more expensive than ordinary Views. Use them when the runtime contract must be enforced.

Guard Result Protocol

Guards return one of two standardized shapes:

guardOk value
guardFail

Guards do not provide diagnostics. The checked-exec runner owns diagnostics. Malformed guard output is treated as a checked-runtime failure.

Checked Execution Protocol

A successful typed-program check returns a checked-execution artifact, not a raw payload.

Current constructors:

checkedPure value
checkedFail diagnostic
checkedGuard view guard value continuation
checkedGuardWithContext context view guard value continuation
checkedBind exec continuation

checkedGuard is the compatibility/default constructor. It lowers to checkedGuardWithContext with an unknown context. Checker-injected guard boundaries use checkedGuardWithContext so failures can identify where the boundary came from.

Runner:

runChecked checkedExec

Semantics:

runChecked (checkedPure value)
  = checkedRuntimeOk value

runChecked (checkedFail diagnostic)
  = checkedRuntimeFail diagnostic

runChecked (checkedGuardWithContext context view guard value continuation)
  = case guard value of
      guardOk checkedValue -> runChecked (continuation checkedValue)
      guardFail           -> checkedRuntimeFail (guardFailed context view)
      malformed           -> checkedRuntimeFail (malformedGuardResult context view malformed)

runChecked (checkedGuard view guard value continuation)
  = runChecked (checkedGuardWithContext unknownContext view guard value continuation)

runChecked (checkedBind exec continuation)
  = case runChecked exec of
      checkedRuntimeOk value   -> runChecked (continuation value)
      checkedRuntimeFail diag  -> checkedRuntimeFail diag

Important invariant:

Guard failure is consumed by runChecked. It is never passed into ordinary user code.

Checker Result Shape

checkTypedProgramWith returns checked-exec on success:

ok checkedExec env

Even unguarded programs return:

checkedPure rootPayload

Compatibility helper:

checkedProgramTree result

checkedProgramTree runs/unwraps checked-exec to preserve older raw-tree helper behavior.

The Haskell tricu check path now evaluates successful checker output through runChecked, so source-level guarded annotations fail through the same portable checked-exec protocol.

Boundary Semantics

Guard insertion follows correctness-first semantics:

Every guarded View observation on the reachable checked-execution path runs its guard.

Important boundary kinds:

Guarded typed value

typedValue sym (viewGuarded base guard) payload

This observes sym as a guarded value. It also supplies base-view evidence for flow checking.

Guarded requirement

typedRequire sym (viewGuarded base guard) payload

The symbol must satisfy base; the guarded observation is attached to sym and is enforced when sym is used or exposed along the reachable root path.

Guarded function argument

For:

viewFn [(viewGuarded base guard)] result

application checking guards the argument before the callee receives it.

Guarded function result

For:

viewFn [arg] (viewGuarded base guard)

application checking guards the application result before exposing it as the result value.

Guarded callee symbol

If a function symbol itself has a guarded observation, that guard runs before the function value is applied. A successful guard may transform the function value; the application uses the guarded value.

Global Symbol Observations

Guarded typedValue and typedRequire nodes are global per-symbol observations, not position-sensitive flow events.

All guarded observations for a symbol compose in typed-node order whenever that symbol is used or exposed on the reachable checked-execution path.

This means a later requirement still applies to an earlier syntactic use:

typedValue 1 viewString "x"
typedApply 2 f 1 "x"
typedRequire 1 (viewGuarded viewString guard) "x"

The guarded requirement is attached to symbol 1; compiling the reachable root path that uses symbol 1 runs that guard.

Rationale:

  • typed programs are declarative symbol graphs, not imperative event traces;
  • global observations are simpler and more correct-by-default;
  • producers cannot accidentally bypass a guard by ordering a requirement too late;
  • staged raw/checked phases should use distinct symbols.

Reachability and Repetition

Guards are not run eagerly for every guarded node in a program.

Execution is root-reachable:

compileSymbol (typedProgramRoot program)

Only guarded observations reachable from the root checked-execution path run. Unreachable guarded symbols do not pay guard cost and do not fail execution.

Repeated reachable uses rerun guards. There is currently no deduplication or hoisting. This is intentional: each guarded observation/use is a runtime contract boundary.

Future optimization policies may add explicit deduplication or hoisting, but the baseline semantics are repeated, deterministic guard execution.

Function and Application Compilation

Checked execution is built compositionally from typed-node dependencies:

  1. compile the callee symbol;
  2. compile the argument symbol;
  3. run any guarded observations attached to the argument symbol;
  4. run the guarded function-argument boundary, if present;
  5. apply the callee to the checked argument;
  6. run the guarded function-result boundary, if present;
  7. run guarded observations attached to the application result symbol.

This handles nested and curried application chains because each typedApply consumes one function argument and produces a symbol whose inferred view is the function residual/result view.

Diagnostics

Guards do not author diagnostics. The checked-exec runner renders diagnostics from checker-owned boundary context plus the guarded View.

Checker-injected guard nodes carry portable structural context. Current context kinds are:

  • root typedValue exposure;
  • root typedRequire exposure;
  • non-root typedValue symbol observation;
  • non-root typedRequire symbol observation;
  • function argument boundary;
  • function result boundary;
  • unknown/default context for manually constructed checkedGuard values.

Examples:

guard failed at root typedValue symbol 0 for Guarded String
guard failed at root typedRequire symbol 3 for Guarded String
guard failed at typedRequire symbol 6 for Guarded String
guard failed at argument 0 of application symbol 2 (callee symbol 0, arg symbol 1) for Guarded String
guard failed at result of application symbol 2 (callee symbol 0, arg symbol 1) for Guarded String
malformed guard result at argument 0 of application symbol 2 (callee symbol 0, arg symbol 1) for Guarded String

Manually constructed checkedGuard values use unknown context and therefore render without a boundary suffix:

guard failed for String
malformed guard result for String

The context is diagnostic-only. It does not affect guard execution, View compatibility, success/failure semantics, or continuation values.

The context deliberately contains raw portable data such as symbols and application edges. It does not preserve source aliases such as NonEmptyString, and it does not rely on Haskell-side post-processing or source-name annotation. Named View rendering is a separate future design topic.

Why Not Abort in Haskell?

A host-level abort primitive would move guard semantics into Haskell. The design instead encodes guard failure in portable checked-exec artifacts and interprets it with portable tricu code.

Haskell may evaluate the runner, but Haskell is not the semantic source of guard validity or failure behavior.

Why Not Maybe / Result Everywhere?

Returning Maybe or Result from every guarded boundary would infect ordinary APIs. A function expecting a List Byte would have to accept Maybe (List Byte) or Result Error (List Byte), and every downstream caller would need defensive handling.

The checked-exec runner avoids this. It unwraps successful guard results before continuing and stops checked execution on failure.

Known Sharp Edges

Guard divergence

A user-written guard may diverge. This design handles intentional failure via guardFail; it does not solve arbitrary nontermination. Fuel or timeouts are separate runtime concerns.

Payload trust

Typed nodes carry executable payloads. Guard injection must not expose an unchecked precomputed payload at a guarded boundary. Boundaries are mediated by checked-exec nodes.

This does not make malicious producer forgery impossible; it gives honest frontends a portable, checkable protocol that avoids accidental bypasses.

Cyclic typed-apply graphs

The current symbol compiler assumes typed programs are well-founded dependency graphs as emitted by the frontend/lowering path. Cyclic typed-apply graphs are a malformed-program validation concern, not a guard-specific semantic feature.

Current Implementation Status

Implemented in lib/view.tri and exercised by tests:

  • guardOk / guardFail;
  • checkedPure, checkedFail, checkedGuard, checkedGuardWithContext, checkedBind;
  • runChecked;
  • success from checkTypedProgramWith returns checked-exec;
  • checkedProgramTree compatibility helper;
  • guarded root exposure;
  • guarded typedValue and typedRequire;
  • guarded function arguments and results;
  • guarded callee observations;
  • nested/curried application guard composition;
  • global per-symbol observations;
  • root-reachability behavior;
  • repeated reachable uses rerun guards;
  • source/Haskell tricu check integration;
  • imported/module VTGuarded lowering to portable viewGuarded;
  • portable guard boundary diagnostics with symbol/application context.