Files
hexis/internal/domain/confirmation.go
T
kami 89d8433d17 Implement Hexis confirmations, disabled-by-default risk, and timeout=>unknown
Closes the biggest gap between the running execution engine and
ECOSYSTEM-SPEC.md §4.3: confirmations were entirely unmodeled, so any
capability could execute unconfirmed regardless of requires_confirmation.

- New confirmations table + Confirmation domain type; POST
  /api/v1/confirmations mints a TTL-bound (120s) confirmation binding
  capability id+version, target entity, and a sorted-key args hash.
- Execute() now requires a valid pending confirmation when the
  capability demands one: rejects missing, expired, consumed, or
  args/version-mismatched confirmations; consumes on success.
- Capabilities gain enabled (destructive risk defaults to disabled,
  matching "must be turned on explicitly") and timeout_seconds.
- One in-flight execution per (capability_id, target_entity_id); a
  second concurrent attempt is rejected (surfaced as 409 over HTTP).
- Wall-clock timeout per capability now wraps the provider call; on
  timeout the outcome is "unknown" (new ExecutionStatus), never
  "failed", and the run is never auto-retried.
- 9 new engine tests cover each guard from the spec's Phase 6 gate.

Vikunja #274.
2026-07-20 00:58:42 +04:00

83 lines
2.0 KiB
Go

package domain
import (
"crypto/rand"
"crypto/sha256"
"encoding/base32"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
)
const ConfirmationTTL = 120 * time.Second
type ConfirmationState string
const (
ConfirmationPending ConfirmationState = "pending"
ConfirmationConsumed ConfirmationState = "consumed"
ConfirmationExpired ConfirmationState = "expired"
ConfirmationRejected ConfirmationState = "rejected"
)
type Confirmation struct {
ID string `json:"id"`
CapabilityID string `json:"capability_id"`
CapabilityVersion int64 `json:"capability_version"`
TargetEntityID string `json:"target_entity_id"`
ArgsNormalized string `json:"args_normalized"`
ArgsHash string `json:"args_hash"`
Requester string `json:"requester"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
State ConfirmationState `json:"state"`
}
func NewConfirmationID() string {
b := make([]byte, 10)
rand.Read(b)
return "conf_" + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b))
}
// NormalizeArgs produces a stable JSON encoding of an arguments map (sorted
// keys) so args_hash is comparable across requests that differ only in key
// order.
func NormalizeArgs(args map[string]any) (string, error) {
if args == nil {
args = map[string]any{}
}
keys := make([]string, 0, len(args))
for k := range args {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
b.WriteByte('{')
for i, k := range keys {
if i > 0 {
b.WriteByte(',')
}
kb, err := json.Marshal(k)
if err != nil {
return "", err
}
vb, err := json.Marshal(args[k])
if err != nil {
return "", err
}
b.Write(kb)
b.WriteByte(':')
b.Write(vb)
}
b.WriteByte('}')
return b.String(), nil
}
func HashArgs(normalized string) string {
sum := sha256.Sum256([]byte(normalized))
return fmt.Sprintf("%x", sum)
}