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.
This commit is contained in:
kami
2026-07-20 00:58:42 +04:00
parent cca63269e1
commit 89d8433d17
8 changed files with 812 additions and 98 deletions
+66 -52
View File
@@ -3,32 +3,42 @@ package domain
import "time"
type ExecuteRequest struct {
CapabilityID string `json:"capability_id"`
TargetEntityID string `json:"target_entity_id"`
EntityVersion int64 `json:"entity_version,omitempty"`
Arguments map[string]any `json:"arguments,omitempty"`
RequestedBy map[string]string `json:"requested_by,omitempty"`
Origin map[string]string `json:"origin,omitempty"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
ResolutionEvidence []map[string]any `json:"resolution_evidence,omitempty"`
CapabilityID string `json:"capability_id"`
TargetEntityID string `json:"target_entity_id"`
EntityVersion int64 `json:"entity_version,omitempty"`
Arguments map[string]any `json:"arguments,omitempty"`
RequestedBy map[string]string `json:"requested_by,omitempty"`
Origin map[string]string `json:"origin,omitempty"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
ResolutionEvidence []map[string]any `json:"resolution_evidence,omitempty"`
ConfirmationID string `json:"confirmation_id,omitempty"`
}
type Capability struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
TargetTypes []string `json:"target_types"`
TargetEntityID string `json:"target_entity_id,omitempty"`
Provider string `json:"provider"`
Operation string `json:"operation"`
Risk string `json:"risk,omitempty"`
ReadOnly bool `json:"read_only"`
ExpectedSideEffects string `json:"expected_side_effects,omitempty"`
Attributes map[string]any `json:"attributes,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Version int64 `json:"version"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
TargetTypes []string `json:"target_types"`
TargetEntityID string `json:"target_entity_id,omitempty"`
Provider string `json:"provider"`
Operation string `json:"operation"`
Risk string `json:"risk,omitempty"`
ReadOnly bool `json:"read_only"`
ExpectedSideEffects string `json:"expected_side_effects,omitempty"`
RequiresConfirmation bool `json:"requires_confirmation"`
Enabled bool `json:"enabled"`
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
Attributes map[string]any `json:"attributes,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Version int64 `json:"version"`
}
// IsDestructive reports whether the capability's risk tier requires it to be
// disabled by default per ECOSYSTEM-SPEC.md §4.3.
func (c *Capability) IsDestructive() bool {
return c.Risk == "destructive"
}
type ExecutionStatus string
@@ -38,44 +48,48 @@ const (
ExecutionSucceeded ExecutionStatus = "succeeded"
ExecutionFailed ExecutionStatus = "failed"
ExecutionDenied ExecutionStatus = "denied"
// ExecutionUnknown marks a wall-clock timeout: the executor may or may not
// have completed the side effect. Never retried automatically.
ExecutionUnknown ExecutionStatus = "unknown"
)
type Execution struct {
ID string `json:"id"`
CapabilityID string `json:"capability_id"`
TargetEntityID string `json:"target_entity_id"`
EntityVersion int64 `json:"entity_version,omitempty"`
Arguments map[string]any `json:"arguments,omitempty"`
RequestedBy map[string]string `json:"requested_by,omitempty"`
Origin map[string]string `json:"origin,omitempty"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
Status ExecutionStatus `json:"status"`
Result map[string]any `json:"result,omitempty"`
Error string `json:"error,omitempty"`
ResolutionEvidence []map[string]any `json:"resolution_evidence,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
CapabilityID string `json:"capability_id"`
TargetEntityID string `json:"target_entity_id"`
EntityVersion int64 `json:"entity_version,omitempty"`
Arguments map[string]any `json:"arguments,omitempty"`
RequestedBy map[string]string `json:"requested_by,omitempty"`
Origin map[string]string `json:"origin,omitempty"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
ConfirmationID string `json:"confirmation_id,omitempty"`
Status ExecutionStatus `json:"status"`
Result map[string]any `json:"result,omitempty"`
Error string `json:"error,omitempty"`
ResolutionEvidence []map[string]any `json:"resolution_evidence,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type HexisEventType string
const (
EventCapabilityRegistered HexisEventType = "hexis.capability.registered"
EventCapabilityUnavailable HexisEventType = "hexis.capability.unavailable"
EventExecutionStarted HexisEventType = "hexis.execution.started"
EventExecutionSucceeded HexisEventType = "hexis.execution.succeeded"
EventExecutionFailed HexisEventType = "hexis.execution.failed"
EventExecutionDenied HexisEventType = "hexis.execution.denied"
EventCapabilityRegistered HexisEventType = "hexis.capability.registered"
EventCapabilityUnavailable HexisEventType = "hexis.capability.unavailable"
EventExecutionStarted HexisEventType = "hexis.execution.started"
EventExecutionSucceeded HexisEventType = "hexis.execution.succeeded"
EventExecutionFailed HexisEventType = "hexis.execution.failed"
EventExecutionDenied HexisEventType = "hexis.execution.denied"
)
type Event struct {
ID string `json:"id"`
Sequence int64 `json:"sequence"`
Type HexisEventType `json:"type"`
Timestamp time.Time `json:"timestamp"`
Actor string `json:"actor,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
CausationID string `json:"causation_id,omitempty"`
Payload map[string]any `json:"payload,omitempty"`
ID string `json:"id"`
Sequence int64 `json:"sequence"`
Type HexisEventType `json:"type"`
Timestamp time.Time `json:"timestamp"`
Actor string `json:"actor,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
CausationID string `json:"causation_id,omitempty"`
Payload map[string]any `json:"payload,omitempty"`
}
+82
View File
@@ -0,0 +1,82 @@
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)
}
+9
View File
@@ -15,4 +15,13 @@ var (
ErrValidation = errors.New("validation error")
ErrInternal = errors.New("internal error")
ErrIdempotencyReplay = errors.New("idempotent request already processed")
ErrCapabilityDisabled = errors.New("capability is disabled")
ErrCapabilityVersionMismatch = errors.New("capability version mismatch")
ErrConfirmationRequired = errors.New("confirmation required")
ErrConfirmationNotFound = errors.New("confirmation not found")
ErrConfirmationInvalid = errors.New("confirmation does not match request")
ErrConfirmationExpired = errors.New("confirmation expired")
ErrConfirmationConsumed = errors.New("confirmation already consumed")
ErrExecutionInFlight = errors.New("execution already in flight for this capability and target")
)