Skip to content

refactor(engine): enforce type design for EnforceMode (closes #81) - #83

Merged
5uck1ess merged 3 commits into
mainfrom
fix/enforce-type-design-81
Apr 11, 2026
Merged

refactor(engine): enforce type design for EnforceMode (closes #81)#83
5uck1ess merged 3 commits into
mainfrom
fix/enforce-type-design-81

Conversation

@5uck1ess

Copy link
Copy Markdown
Owner

Closes #81. Follow-up to PR #80. Bundles the three type-design improvements flagged during review because they all touch the same invariant surface.

Summary

  • Named EnforceMode type (lib.EnforceMode = hard | soft | ""). Workflow, WfStep, and SessionState all carry the invariant at the type level. IsValid() concentrates the valid-value check; engine.validate() now uses it instead of raw string compares.
  • SessionState.UnmarshalJSON rejects invalid enforce values at ReadSessionJSON time — closes the latent silent-soft fall-through in guard.go's switch from stale/corrupt session.json.
  • cmd/guard.effectiveEnforce deleted. Every writer now goes through engine.EffectiveEnforce (PR feat(engine): per-step enforce override + surgical soft-flips (#78) #80) which returns concrete values, and UnmarshalJSON defends the read path, so the empty-default helper is dead code. Guard reads state.StepEnforce directly.
  • SessionState.EnforceStepEnforce. Semantics shifted in feat(engine): per-step enforce override + surgical soft-flips (#78) #80 from "workflow's enforce, set at start" to "current step's effective enforce, re-derived on every transition"; the field name now reflects that. JSON tag unchanged (json:"enforce") — no on-disk migration.

Layer decision

EnforceMode is defined in lib, not engine, because engine already imports lib; inverting that would create a cycle. engine re-exports the type via a type alias and the three constants so call sites in engine/workflow.go stay ergonomic.

Definition of done

  • EnforceMode named type with IsValid() on Workflow.Enforce, WfStep.Enforce, SessionState.StepEnforce
  • UnmarshalJSON on SessionState rejects invalid/missing enforce values
  • cmd/guard.effectiveEnforce helper deleted; guard reads state.StepEnforce directly
  • SessionState.Enforce renamed to StepEnforce (JSON tag unchanged)
  • Existing tests updated; new TestSessionJSONRejectsInvalidEnforce covers missing/empty/bogus cases
  • go test -count=1 ./... green
  • go vet ./... clean

Test plan

  • go build ./...
  • go test -count=1 ./... — all packages pass (cmd, engine, lib, mcp, runners)
  • go vet ./...
  • New test confirms ReadSessionJSON rejects {}, {"enforce":""}, {"enforce":"medium"} with an invalid enforce error
  • Guard stop-hook tests updated: running/done/failed fixtures now set StepEnforce: lib.EnforceHard
  • Missing-enforce guard case reframed: exits 2 via the cannot read session state path instead of the old empty-default fall-through

Refs #78, PR #80.

Introduces a named EnforceMode string type (hard | soft | inherit) in
lib so Workflow, WfStep, and SessionState all carry the invariant at
the type level instead of checking at runtime. SessionState.Enforce is
renamed to StepEnforce to reflect the post-#80 semantics (current
step's effective enforce, re-derived on every transition). A new
SessionState.UnmarshalJSON rejects stale or corrupt session.json with
a missing/invalid enforce value at read time — this closes the latent
silent-soft fall-through in guard.go's switch and lets cmd/guard.go
drop its now-dead effectiveEnforce helper.

- EnforceMode + constants live in lib (engine imports lib, so the type
  must live below engine to avoid a cycle); engine re-exports via type
  alias so engine call sites stay ergonomic.
- JSON on-disk tag stays "enforce" — no session.json migration.
- YAML tag unchanged — workflow authors unaffected.
- New test: invalid/missing/bogus enforce in session.json → parse error
  at ReadSessionJSON time.
Review fixes for PR #83:

- Fix stale `effectiveEnforce` comment in guard.go stop-hook region
  that referenced the helper deleted in the parent commit.
- Add `IsValidOverride()` method on EnforceMode collapsing the
  `!= EnforceInherit && !IsValid()` split-brain check at authoring-time
  call sites. engine.validate() now uses it.
- Expand EnforceInherit doc comment to explicitly state it is an
  authoring-time sentinel that IsValid() rejects — prevents reader
  confusion given the dual-use semantics.
- Expand TestSessionJSONRejectsInvalidEnforce matrix with case
  variants (HARD, Hard), whitespace (" hard", "hard "), and
  non-string (42) to pin the strict-parsing contract.
- Add TestEnforceModeIsValid table test directly exercising
  IsValid and IsValidOverride on boundary inputs — documents the
  contract so a future "be lenient" refactor must be deliberate.
- Add TestGuardPreToolUseMissingEnforceField: end-to-end guard
  test that a session.json without the enforce field fails closed
  through UnmarshalJSON rejection in the PreToolUse path.
- Add stop-hook parse-reject case to TestGuardStopHook matrix
  covering the same corruption through the Stop path.
The hook_test.sh stop-guard fixtures for done / failed / running
workflows were missing the enforce field. Pre-#81 the Python
.get('enforce','hard') fallback masked this; post-#81 the Go binary's
SessionState.UnmarshalJSON rejects the missing field at parse time
and the stop hook fails closed before reaching the status check.

- Add enforce:"hard" to the three stop-guard running/done/failed
  fixtures so the parse succeeds and the status-based routing runs.
- Update the PreToolUse "missing enforce field" test comment: it
  still passes (exits 2), but via the parse-reject fail-closed path
  rather than the old default-hard coercion. Retitle to reflect the
  new semantics.
@5uck1ess
5uck1ess merged commit 484e116 into main Apr 11, 2026
6 checks passed
@5uck1ess
5uck1ess deleted the fix/enforce-type-design-81 branch April 11, 2026 18:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

enforce type design: named type, collapse duplicate helper, rename SessionState field

1 participant