d4285607af
Findings 4 and 7 of REVIEW-2026-07-30.md, as a single user_version step (11 -> 12) because they alter the same table. confirmation_id was written by the INSERT and absent from all three executions SELECTs, so it always read back empty: "which confirmation authorized this destructive act" was unanswerable from the API. causation_id was set on the struct by the engine with no column to land in, and capability_version was missing entirely despite spec §4.1 and the precedent on confirmations. All three are now persisted and selected back. The one-in-flight-per-(capability, target) rule was check-then-insert with no constraint between, so two concurrent requests could both proceed. It is now a partial UNIQUE index; the engine's pre-insert query is demoted to an advisory fast path and a constraint violation maps to ErrExecutionInFlight (409), kept distinct from the idempotency-key replay case. Note the migration also rewrites pre-existing duplicate in-flight rows to status='unknown', keeping the lowest id per pair — creating the index would otherwise fail outright on any database holding stale started rows, which the old non-unique index permitted indefinitely. That is a write to existing audit rows, not just DDL. Rows predating this migration get capability_version=0, which is indistinguishable from a genuine 0; backfill is not possible. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uea55zaiWuEByEDC4UBSdd
154 lines
4.7 KiB
Go
154 lines
4.7 KiB
Go
package execution_test
|
|
|
|
import (
|
|
"errors"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/hexis/internal/domain"
|
|
)
|
|
|
|
// The audit trail is only useful if it survives a round trip through storage:
|
|
// "which confirmation authorized this destructive act" must be answerable from
|
|
// a plain read of the execution, not just from the in-memory struct the engine
|
|
// happened to return.
|
|
func TestExecute_AuditFieldsRoundTripThroughStorage(t *testing.T) {
|
|
prov := &fakeProvider{name: "fake"}
|
|
eng, store := newTestEngine(t, prov)
|
|
cap := mustCreateCapability(t, store, func(c *domain.Capability) {
|
|
c.RequiresConfirmation = true
|
|
c.Risk = "destructive"
|
|
})
|
|
|
|
args := map[string]any{"foo": "bar"}
|
|
conf, err := eng.CreateConfirmation(cap.ID, "ent_x", "kami", args)
|
|
if err != nil {
|
|
t.Fatalf("create confirmation: %v", err)
|
|
}
|
|
|
|
res, err := eng.Execute(&domain.ExecuteRequest{
|
|
CapabilityID: cap.ID,
|
|
TargetEntityID: "ent_x",
|
|
Arguments: args,
|
|
ConfirmationID: conf.ID,
|
|
CorrelationID: "corr_1",
|
|
CausationID: "caus_1",
|
|
IdempotencyKey: "idem_1",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
|
|
// This is what GET /api/v1/executions/{id} serves.
|
|
got, err := store.GetExecution(res.Execution.ID)
|
|
if err != nil {
|
|
t.Fatalf("get execution: %v", err)
|
|
}
|
|
if got.ConfirmationID != conf.ID {
|
|
t.Errorf("confirmation_id: got %q, want %q", got.ConfirmationID, conf.ID)
|
|
}
|
|
if got.CausationID != "caus_1" {
|
|
t.Errorf("causation_id: got %q, want %q", got.CausationID, "caus_1")
|
|
}
|
|
if got.CorrelationID != "corr_1" {
|
|
t.Errorf("correlation_id: got %q, want %q", got.CorrelationID, "corr_1")
|
|
}
|
|
if got.CapabilityVersion != cap.Version {
|
|
t.Errorf("capability_version: got %d, want %d", got.CapabilityVersion, cap.Version)
|
|
}
|
|
|
|
// The idempotency-key read path serves the same record and must agree.
|
|
byKey, err := store.GetExecutionByIdempotencyKey("idem_1")
|
|
if err != nil {
|
|
t.Fatalf("get by idempotency key: %v", err)
|
|
}
|
|
if byKey.ConfirmationID != conf.ID || byKey.CausationID != "caus_1" || byKey.CapabilityVersion != cap.Version {
|
|
t.Errorf("idempotency read path lost audit fields: %+v", byKey)
|
|
}
|
|
}
|
|
|
|
// The in-flight guard used to be check-then-insert. Two concurrent identical
|
|
// executes must produce exactly one execution row, with the loser reported as
|
|
// already-in-flight rather than a 500.
|
|
func TestExecute_ConcurrentDuplicateProducesExactlyOneRow(t *testing.T) {
|
|
prov := &fakeProvider{name: "fake", delay: 150 * time.Millisecond}
|
|
eng, store := newTestEngine(t, prov)
|
|
cap := mustCreateCapability(t, store, nil)
|
|
|
|
const n = 4
|
|
var wg sync.WaitGroup
|
|
errs := make([]error, n)
|
|
start := make(chan struct{})
|
|
for i := 0; i < n; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
<-start
|
|
_, errs[i] = eng.Execute(&domain.ExecuteRequest{
|
|
CapabilityID: cap.ID,
|
|
TargetEntityID: "ent_x",
|
|
})
|
|
}(i)
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
|
|
succeeded := 0
|
|
for i, err := range errs {
|
|
switch {
|
|
case err == nil:
|
|
succeeded++
|
|
case errors.Is(err, domain.ErrExecutionInFlight):
|
|
default:
|
|
t.Errorf("goroutine %d: unexpected error %v (want nil or ErrExecutionInFlight)", i, err)
|
|
}
|
|
}
|
|
if succeeded != 1 {
|
|
t.Errorf("expected exactly 1 successful execute, got %d", succeeded)
|
|
}
|
|
|
|
var rows int
|
|
if err := store.DB().QueryRow(
|
|
`SELECT COUNT(*) FROM executions WHERE capability_id = ? AND target_entity_id = ?`,
|
|
cap.ID, "ent_x",
|
|
).Scan(&rows); err != nil {
|
|
t.Fatalf("count executions: %v", err)
|
|
}
|
|
if rows != 1 {
|
|
t.Errorf("expected exactly 1 execution row, got %d", rows)
|
|
}
|
|
}
|
|
|
|
// Directly exercise the partial unique index, bypassing the engine's advisory
|
|
// pre-check, so the constraint itself is what is under test.
|
|
func TestStore_SecondInFlightRowRejectedByIndex(t *testing.T) {
|
|
_, store := newTestEngine(t, &fakeProvider{name: "fake"})
|
|
now := time.Now().UTC()
|
|
|
|
mk := func(status domain.ExecutionStatus) *domain.Execution {
|
|
return &domain.Execution{
|
|
ID: domain.NewExecutionID(),
|
|
CapabilityID: "cap_1",
|
|
TargetEntityID: "ent_x",
|
|
Status: status,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
}
|
|
|
|
if err := store.CreateExecution(mk(domain.ExecutionStarted)); err != nil {
|
|
t.Fatalf("first insert: %v", err)
|
|
}
|
|
if err := store.CreateExecution(mk(domain.ExecutionStarted)); !errors.Is(err, domain.ErrExecutionInFlight) {
|
|
t.Fatalf("second in-flight insert: got %v, want ErrExecutionInFlight", err)
|
|
}
|
|
// The index is partial: terminal rows for the same pair stay insertable.
|
|
if err := store.CreateExecution(mk(domain.ExecutionSucceeded)); err != nil {
|
|
t.Fatalf("terminal-status insert should be allowed: %v", err)
|
|
}
|
|
if err := store.CreateExecution(mk(domain.ExecutionSucceeded)); err != nil {
|
|
t.Fatalf("second terminal-status insert should be allowed: %v", err)
|
|
}
|
|
}
|