Below is the implementation handoff for replacing the current recursive/rebuilding IO small-step interpreter with an explicit machine stack, primarily to support `Reader` via `ask` and `local`, while setting up the right shape for eventual async. ## Goal Refactor `IODriver` from this model: ```haskell stepIO :: IOPermissions -> T -> IO Step data Step = Done T | Continue T ``` to an explicit abstract machine: ```haskell Machine = Runtime + current action + continuation frames ``` This is required because `local` is dynamically scoped. It needs to modify the Reader environment for a sub-computation, then restore the previous environment exactly when that sub-computation completes. The current “rebuild `Bind left' k`” approach has nowhere to store that restoration behavior. This change should support: ```tricu ask local ``` now, and keep the structure compatible with future async suspension/resumption. Do not implement async in this pass. --- ## New action tags Extend the tricu IO action language with Reader tags: ```tricu ask = _ : pair 6 t local = f action : pair 7 (pair f action) ``` Host-side: ```haskell data Action = APure T | ABind T T | APutStr T | AGetLine | AReadFile T | AWriteFile T T | AAsk | ALocal T T deriving (Show) ``` Recommended tag allocation: ```text 0 = pure 1 = bind 2 = putStr 3 = getLine 4 = readFile 5 = writeFile 6 = ask 7 = local ``` State tags can come later: ```text 8 = get 9 = put ``` Do not add `bindR`, `bindS`, or `bindRS` yet. Reader is being added as an effect inside the existing IO action language, so the existing IO `bind` remains the only sequencing operator. --- ## New runtime model Add a runtime context: ```haskell data Runtime = Runtime { rtPerms :: IOPermissions , rtEnv :: T } deriving (Show) ``` Later this can become: ```haskell data Runtime = Runtime { rtPerms :: IOPermissions , rtEnv :: T , rtState :: T } ``` but for this pass, keep it minimal unless State is implemented at the same time. Add continuation frames: ```haskell data Frame = BindFrame T | LocalFrame T deriving (Show) ``` Frame meanings: ```text BindFrame k: When the current action produces value x, continue with apply k x. LocalFrame oldEnv: When the current action produces value x, restore oldEnv, then continue with x. ``` Add the machine state: ```haskell data Machine = Machine { machineRuntime :: Runtime , machineCurrent :: T , machineFrames :: [Frame] } deriving (Show) ``` Frames should be treated as a stack, with the head as the top: ```haskell push frame machine = machine { machineFrames = frame : machineFrames machine } ``` --- ## New step result Replace the current `Step` with machine-oriented stepping: ```haskell data Step = Halt Runtime T | Continue Machine deriving (Show) ``` `Halt runtime value` means the entire IO program is done. `Continue machine` means the machine can take another step. --- ## Core stepping semantics The central function should become: ```haskell stepMachine :: Machine -> IO Step ``` It should decode `machineCurrent`. ### `pure` When the current action is `APure value`, do not immediately halt. First inspect the frame stack. Pseudo-code: ```haskell finishValue :: Machine -> T -> IO Step finishValue machine value = case machineFrames machine of [] -> pure (Halt (machineRuntime machine) value) BindFrame k : rest -> pure (Continue machine { machineCurrent = apply k value , machineFrames = rest }) LocalFrame oldEnv : rest -> let runtime' = (machineRuntime machine) { rtEnv = oldEnv } in pure (Continue machine { machineRuntime = runtime' , machineCurrent = pureAction value , machineFrames = rest }) ``` You will need a helper: ```haskell pureAction :: T -> T pureAction x = Fork (ofNumber 0) x ``` This is important: restoring a `LocalFrame` should not discard the value. It restores the environment and re-enters the machine as `pure value`, allowing the next frame to receive the value. ### `bind` For: ```haskell ABind left k ``` do not recursively step `left`, and do not rebuild `Bind left' k`. Instead: ```haskell Continue machine { machineCurrent = left , machineFrames = BindFrame k : machineFrames machine } ``` This is the major refactor. Continuations move out of the tree and into the frame stack. ### `ask` For: ```haskell AAsk ``` produce the current Reader environment: ```haskell finishValue machine (rtEnv (machineRuntime machine)) ``` or equivalently: ```haskell Continue machine { machineCurrent = pureAction currentEnv } ``` Prefer `finishValue` because it avoids an extra step. ### `local` For: ```haskell ALocal f action ``` do: ```haskell let runtime = machineRuntime machine oldEnv = rtEnv runtime newEnv = apply f oldEnv runtime' = runtime { rtEnv = newEnv } Continue machine { machineRuntime = runtime' , machineCurrent = action , machineFrames = LocalFrame oldEnv : machineFrames machine } ``` This is the central correctness point. `local` enters a scoped environment by pushing a restoration frame. When the scoped action finishes, `LocalFrame oldEnv` restores the previous environment and passes the produced value onward. Nested `local` works naturally because frames stack: ```tricu local f ( local g ask ) ``` becomes: ```text push LocalFrame env0 set env = f env0 push LocalFrame env1 set env = g env1 ask returns env2 pop LocalFrame env1 restore env1 pop LocalFrame env0 restore env0 ``` ### Normal IO actions For host IO actions, perform the side effect and then call `finishValue`. Examples: ```haskell APutStr str -> case decodeString str "PutStr" of Right s -> do putStr s finishValue machine Leaf Left _ -> finishValue machine (errResult 6) ``` ```haskell AReadFile path -> case decodeString path "ReadFile" of Right p -> do result <- ... finishValue machine result Left _ -> finishValue machine (errResult 6) ``` Important: IO actions should no longer return `Done value` directly. They should return a value to the frame stack via `finishValue`. --- ## Decode changes Extend `decodeAction`: ```haskell decodeAction :: T -> Either String Action decodeAction tree = case tree of Fork tag payload -> case toNumber tag of Right 0 -> Right (APure payload) Right 1 -> case payload of Fork left k -> Right (ABind left k) _ -> Left "Invalid Bind: expected pair action continuation" Right 2 -> Right (APutStr payload) Right 3 -> Right AGetLine Right 4 -> Right (AReadFile payload) Right 5 -> case payload of Fork path contents -> Right (AWriteFile path contents) _ -> Left "Invalid WriteFile: expected pair path contents" Right 6 -> Right AAsk Right 7 -> case payload of Fork f action -> Right (ALocal f action) _ -> Left "Invalid Local: expected pair function action" Right n -> Left $ "Unknown IO action tag: " ++ show n Left err -> Left $ "Invalid action tag: " ++ err _ -> Left $ "Invalid action tree: expected pair tag payload, got " ++ show tree ``` --- ## Runner API Add a new Reader-aware runner: ```haskell runIOWithEnv :: IOPermissions -> T -> T -> IO T runIOWithEnv perms env action = loop initialMachine where initialMachine = Machine { machineRuntime = Runtime { rtPerms = perms , rtEnv = env } , machineCurrent = action , machineFrames = [] } loop machine = do step <- stepMachine machine case step of Halt _ value -> pure value Continue machine' -> loop machine' ``` Keep the existing API as a compatibility wrapper: ```haskell runIO :: IOPermissions -> T -> IO T runIO perms action = runIOWithEnv perms Leaf action ``` If State is added in the same branch, prefer: ```haskell runIOWith :: IOPermissions -> T -> T -> T -> IO (T, T) ``` where: ```text permissions initial reader env initial state action ``` returns: ```text final result final state ``` But if this handoff is only for Reader, use `runIOWithEnv`. --- ## Permission helpers The current permission helper functions can mostly stay as-is, but they should read permissions from runtime: ```haskell let perms = rtPerms (machineRuntime machine) ``` The current helpers are nested inside `stepIO`. After the refactor, either: 1. keep them in a `where` block under `stepMachine`, or 2. lift them to top-level helper functions. Prefer lifting pure/reusable helpers to top-level if this file is getting large: ```haskell decodeString :: T -> String -> Either String String canonicalizeSafe :: FilePath -> IO (Either String FilePath) pathAllowed :: FilePath -> [FilePath] -> IO Bool tryReadFile :: FilePath -> IO T tryWriteFile :: FilePath -> String -> IO T okResult :: T -> T errResult :: Integer -> T ioErrorCode :: IOException -> Integer ``` This will make `stepMachine` much easier to read. --- ## `io.tri` changes Add the Reader constructors: ```tricu ask = _ : pair 6 t local = f action : pair 7 (pair f action) ``` No new bind is required. Example usage: ```tricu program = bind ask (env : putStrLn env) ``` Example `local` usage: ```tricu program = bind ask (outer : bind (local (env : append env "-inner") (bind ask (inner : pure inner))) (result : bind ask (after : pure result))) ``` Expected behavior: ```text outer ask sees original env inner ask sees transformed env after ask sees original env again ``` --- ## Tests to add Add tests around behavior, not implementation details. ### 1. `ask` returns initial environment Program: ```tricu io (bind ask (env : pure env)) ``` Run with env: ```text "dev" ``` Expected result: ```text "dev" ``` ### 2. `local` transforms environment Program: ```tricu io ( local (env : append env "-local") (bind ask (env : pure env)) ) ``` Initial env: ```text "root" ``` Expected result: ```text "root-local" ``` ### 3. `local` restores environment afterward Program structure: ```tricu bind ask (before : bind (local transform scopedAsk) (inside : bind ask (after : pure (pair before (pair inside after))))) ``` Initial env: ```text "root" ``` Expected: ```text pair "root" (pair "root-local" "root") ``` ### 4. nested `local` composes correctly Program: ```tricu local f ( local g ask ) ``` Initial env: ```text "root" ``` Example: ```tricu f = x : append x "-f" g = x : append x "-g" ``` Expected inner ask: ```text "root-f-g" ``` Also verify after both locals, environment is restored by doing a final `ask`. ### 5. `local` result passes through bind correctly Program: ```tricu bind (local transform (pure "value")) (x : pure x) ``` Expected: ```text "value" ``` This catches a common bug where `LocalFrame` restores env but loses the value. ### 6. IO still works through bind Existing IO tests should continue passing unchanged through `runIO`. ### 7. IO inside local Program: ```tricu local transform ( bind ask (env : bind (putStrLn env) (_ : pure env)) ) ``` Expected: ```text prints transformed env returns transformed env ``` Then optionally ask after local to verify restoration. --- ## Invariants to preserve The implementation should maintain these invariants: ```text 1. The current action is always the next instruction to evaluate. 2. The frame stack contains all pending continuations and cleanup scopes. 3. Bind does not recursively step its left side. It pushes BindFrame and switches current to the left action. 4. local does not run its action to completion. It pushes LocalFrame and switches current to the scoped action. 5. Only LocalFrame restores Reader environment. 6. State, when added later, should not be restored by LocalFrame. 7. Existing runIO behavior remains source-compatible. ``` --- ## Common failure modes The likely bugs are: ```text Bug: local leaks environment. Cause: setting rtEnv but never restoring it. Fix: push LocalFrame oldEnv and restore in finishValue. Bug: local restores environment but loses result. Cause: popping LocalFrame and halting directly. Fix: after restoration, continue with pureAction value. Bug: bind continuations run under the wrong env. Cause: LocalFrame and BindFrame pop order is wrong. Fix: use stack head as top. Push LocalFrame when entering local; push BindFrame when entering bind. Pop exactly one frame when a value is produced. Bug: existing IO bind tests fail. Cause: IO actions halt instead of passing result to finishValue. Fix: every completed primitive action should call finishValue. Bug: nested binds still rebuild trees. Cause: old ABind logic left in place. Fix: ABind should only push BindFrame and switch current to left. ``` --- ## Async relevance, but not implementation This machine representation is intentionally compatible with async. A future scheduler can store: ```haskell Machine runtime current frames ``` when a task blocks, then resume it later by restoring the same `Machine`. Do not implement any of these now: ```haskell AFork AAwait ASleep TaskId Scheduler Runnable queue Blocked table ``` But avoid designs that would make future suspension impossible, especially recursive “run sub-computation to completion” implementations of `local`. The point of the frame machine is that every effect remains small-step and resumable. --- ## Recommended implementation order 1. Add `Runtime`, `Frame`, and `Machine`. 2. Add `pureAction`. 3. Replace `Step` with `Halt Runtime T | Continue Machine`. 4. Implement `finishValue`. 5. Rewrite `ABind` to push `BindFrame`. 6. Rewrite existing primitive IO actions to call `finishValue`. 7. Add `AAsk` and `ALocal`. 8. Add `runIOWithEnv`. 9. Rewrite `runIO` as a wrapper. 10. Add `ask` and `local` to `io.tri`. 11. Add Reader behavior tests. 12. Run all existing IO tests and confirm no regressions. The key handoff instruction is: implement `local` as a continuation frame, not as a recursive nested run. This keeps the interpreter genuinely small-step and gives the eventual async runtime the exact representation it will need for suspension and resumption.