Files
hanzo-dev 32bf0d2937 framework: extract the DocType engine as a standalone module
Split from hanzoai/cloud clients/framework, preserving history. The engine
no longer depends on hanzoai/cloud at all.

Everything the engine took from its host is now an injected seam:

- cloud.Service[state] + the package-global 'mounted' become an explicit
  *Engine value, so a process can run more than one and nothing is
  reachable through a global.
- cek.Open becomes Config.OpenDB. Storage policy is the host's: cloud
  injects its encrypted-at-rest opener, a standalone app gets plain
  SQLite. The engine never decides encryption.
- principal.Org becomes the Caller value. The engine needs the validated
  tenant, not the ability to go looking for one, so it takes a value
  rather than a resolver and cannot be handed a request to inspect.
- cloud.Handle HTTP handlers become Engine operations that enforce
  permissions themselves, so a host cannot forget the gate and does not
  reimplement it. framework.go -> ops.go.
- zip HTTP status mapping becomes Classify + Code, so the engine keeps
  the knowledge of what a failure means and hosts cannot drift.

Operations return Doc (document + its schema) so Wire, the Password
redaction choke point, can never be handed a mismatched schema.

Adds ops_test.go: permission enforcement moved out of the HTTP handlers,
where it was only reachable through a router, so it is proven here at the
level every host shares.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 23:18:57 -07:00

89 lines
3.2 KiB
Go

package framework
import (
"errors"
"github.com/hanzoai/doctype"
)
// errors.go is the engine's failure vocabulary.
//
// The engine classifies WHAT WENT WRONG; a host maps that to its own status
// vocabulary (Hanzo Cloud maps it to HTTP). Keeping the classification here is
// deliberate: "a Link points at a record that does not exist in this org" is
// engine knowledge, and if each host re-derived it from error strings they
// would drift. Hosts call Classify and switch on a Code — never on a message.
// Sentinel errors. Compare with errors.Is.
var (
ErrNotFound = errors.New("framework: not found")
ErrConflict = errors.New("framework: already exists")
ErrBadRef = errors.New("framework: referenced record not found in org")
ErrBadState = errors.New("framework: illegal docstatus transition")
ErrForbidden = errors.New("framework: forbidden")
)
// HookAbort wraps the error a GATE hook returned. A gate hook
// (before_insert / before_save / on_submit / on_cancel / on_trash) that returns
// an error aborts the operation before any state change; wrapping it preserves
// the hook's message while telling the host this was a refusal, not a fault.
type HookAbort struct{ Err error }
func (h *HookAbort) Error() string { return h.Err.Error() }
func (h *HookAbort) Unwrap() error { return h.Err }
// Code is what an engine failure MEANS.
type Code int
const (
// CodeInternal is an unexpected fault — the store failed, a marshal failed.
CodeInternal Code = iota
// CodeInvalid is a malformed request: a schema violation, a bad field value,
// an unknown filter field. The caller must change what they sent.
CodeInvalid
// CodeForbidden is an authorization refusal: no validated tenant, or a right
// the caller's roles do not carry.
CodeForbidden
// CodeNotFound is a DocType or document that does not exist in this org.
CodeNotFound
// CodeConflict is a uniqueness or lifecycle-state collision: the record
// already exists, or the document is not in a state that permits this.
CodeConflict
// CodeRejected is a well-formed request the engine refused on the data's
// own terms: a dangling Link, or a gate hook that vetoed the operation.
CodeRejected
)
// Classify maps any engine error to its Code. This is the ONE place that
// decides what a failure means; a host switches on the result and never
// re-derives the classification.
func Classify(err error) Code {
var abort *HookAbort
switch {
case err == nil:
return CodeInternal // callers must check err != nil first
case errors.Is(err, ErrForbidden):
return CodeForbidden
case errors.Is(err, ErrNotFound):
return CodeNotFound
case errors.Is(err, ErrConflict):
return CodeConflict
case errors.Is(err, ErrBadState):
return CodeConflict
case errors.Is(err, ErrBadRef):
return CodeRejected
case errors.As(err, &abort):
return CodeRejected
case doctype.IsValidationError(err):
return CodeInvalid
default:
return CodeInternal
}
}
// IsValidationError reports whether err is a document-schema violation, so an
// in-process caller can answer "bad request" without string-matching. It is the
// value layer's predicate, re-exported so a caller that holds only the engine
// import does not need a second one.
func IsValidationError(err error) bool { return doctype.IsValidationError(err) }