feat(workflow): derive affordances from the enforcing guards #38

Merged
mph merged 1 commit from workflow-affordances into main 2026-07-14 08:40:56 +00:00
Owner

What

works.iterative.workflow gains Affordances — per-state command affordances derived from the same Guard objects (and gate policy) that WorkflowInterpreter.decide enforces, so what a UI/agent is told is possible cannot drift from what the engine accepts (the ADR 0011 §4 invariant, lifted from its first consumer).

  • Affordance(command, available, description, reason) + Affordances.compute(defn, auth)(ctx).
  • Mirrors decide structurally: same stateOf, same table order, AuthEnforcement.Skip rewrites auth leaves via Guard.stripAuth / Enforce evaluates intact, same GuardFailure collapse. applicableWhen-failing rows contribute nothing (matching Unhandled); requiresPayload is not evaluated (needs a concrete command); label-level dedupe, available-wins, first-seen order.
  • The collapse helpers (collectFirst, firstDataMessage, firstAuthLabel) move from WorkflowInterpreter into the GuardFailure companion as private[workflow]; decide delegates — observable behavior unchanged (all 32 interpreter tests untouched).

Why

The first kernel consumer (procedures' actionable aspect) had to hand-copy the interpreter's private collapse to compute affordances — duplicated kernel-internal logic that would silently drift. With the second consumer (delegation aspect) landing, the derivation moves to its natural home, next to the interpreter it must match. The auth parameter keeps role-enforcing consumers (MEDECA) honest: affordances computed under the same policy decide runs with.

Tests

  • New AffordancesSpec (5 pins): per-state label sets; reason parity asserted against decide's actual Rejection.Invalid message; dedupe; Skip-vs-Enforce auth; applicableWhen absence == Unhandled.
  • workflow.jvm.test 63/63, workflow.js.test 63/63, whole-repo __.compile green.

After merge: release as v0.1.19 → v0.1.20 (procedures consumes Affordances in its delegation-1 slice).

🤖 Generated with Claude Code

## What `works.iterative.workflow` gains **`Affordances`** — per-state command affordances derived from the same `Guard` objects (and gate policy) that `WorkflowInterpreter.decide` enforces, so what a UI/agent is *told* is possible cannot drift from what the engine *accepts* (the ADR 0011 §4 invariant, lifted from its first consumer). - `Affordance(command, available, description, reason)` + `Affordances.compute(defn, auth)(ctx)`. - Mirrors `decide` structurally: same `stateOf`, same table order, `AuthEnforcement.Skip` rewrites auth leaves via `Guard.stripAuth` / `Enforce` evaluates intact, same `GuardFailure` collapse. `applicableWhen`-failing rows contribute nothing (matching `Unhandled`); `requiresPayload` is not evaluated (needs a concrete command); label-level dedupe, available-wins, first-seen order. - The collapse helpers (`collectFirst`, `firstDataMessage`, `firstAuthLabel`) move from `WorkflowInterpreter` into the `GuardFailure` companion as `private[workflow]`; `decide` delegates — observable behavior unchanged (all 32 interpreter tests untouched). ## Why The first kernel consumer (procedures' actionable aspect) had to hand-copy the interpreter's private collapse to compute affordances — duplicated kernel-internal logic that would silently drift. With the second consumer (delegation aspect) landing, the derivation moves to its natural home, next to the interpreter it must match. The `auth` parameter keeps role-enforcing consumers (MEDECA) honest: affordances computed under the same policy `decide` runs with. ## Tests - New `AffordancesSpec` (5 pins): per-state label sets; reason parity asserted against `decide`'s actual `Rejection.Invalid` message; dedupe; Skip-vs-Enforce auth; `applicableWhen` absence == `Unhandled`. - `workflow.jvm.test` 63/63, `workflow.js.test` 63/63, whole-repo `__.compile` green. After merge: release as **v0.1.19 → v0.1.20** (procedures consumes `Affordances` in its delegation-1 slice). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(workflow): derive affordances from the enforcing guards
All checks were successful
CI / Check Formatting (pull_request) Successful in 48s
CI / Compile (pull_request) Successful in 2m3s
CI / Check Linting (pull_request) Successful in 2m12s
CI / Run Tests (pull_request) Successful in 2m14s
0870652f48
Affordances.compute reads the very Guard objects (and gate policy) that
WorkflowInterpreter.decide enforces — same stateOf projection, same table
order, same Skip/Enforce gating via Guard.stripAuth, same GuardFailure
collapse — so told-vs-enforced drift is structurally impossible. Transitions
whose applicableWhen fails contribute nothing (mirroring decide's Unhandled);
requiresPayload is not evaluated (needs a concrete command; payload
validation surfaces at execution); affordances dedupe by command label in
first-seen order, an available row representing its label.

To share the collapse, the GuardFailure → UserMessage helpers (collectFirst,
firstDataMessage, firstAuthLabel) move from WorkflowInterpreter into the
GuardFailure companion as private[workflow]; decide delegates to them with
unchanged observable behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mph merged commit ba820d5550 into main 2026-07-14 08:40:56 +00:00
Sign in to join this conversation.
No description provided.