diff --git a/contracts/work/lease.go b/contracts/work/lease.go index 1406898..756df90 100644 --- a/contracts/work/lease.go +++ b/contracts/work/lease.go @@ -20,3 +20,21 @@ type AgentLease struct { AcquiredAt time.Time ExpiresAt time.Time } + +type AgentCallbackKind string + +const ( + AgentCallbackHeartbeat AgentCallbackKind = "HEARTBEAT" + AgentCallbackCheckpoint AgentCallbackKind = "CHECKPOINT" + AgentCallbackResult AgentCallbackKind = "RESULT" + AgentCallbackCancelAcknowledged AgentCallbackKind = "CANCEL_ACKNOWLEDGED" +) + +type AgentCallback struct { + RunID AgentRunID + WorkerID string + FencingToken AgentFencingToken + Sequence uint64 + Kind AgentCallbackKind + ObservedAt time.Time +} diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index aad79b9..a51ac48 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000116-agent-lease-fencing.json", - "task_id": "DEV-000116", + "task_file": "dev/tasks/DEV-000117-agent-callback-envelope.json", + "task_id": "DEV-000117", "task_revision": 1 } diff --git a/dev/tasks/DEV-000117-agent-callback-envelope.json b/dev/tasks/DEV-000117-agent-callback-envelope.json new file mode 100644 index 0000000..41bb81e --- /dev/null +++ b/dev/tasks/DEV-000117-agent-callback-envelope.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000117", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "9766e53", + "risk_tier": "E", + "intent": "Add typed callback envelope validation for authenticated worker identity, fencing token, monotonic sequence and lease freshness.", + "requirement_ids": ["WP-301", "Callback auth binding", "Forged/out-of-order result rejection"], + "allowed_change_scope": {"modules": ["work-agent-execution"], "paths": ["contracts/work/**", "modules/work/domain/**", "tests/agent_execution/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000117-agent-callback-envelope.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "database/**", "migrations/**", "deploy/**", "security/**", "cmd/**", "runtime/**", "adapters/**", ".gitea/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0}, + "contracts": {"consume": ["AgentLease/fencing contract from DEV-000116", "ADR-0020 callback ordering boundary"], "may_change": ["Agent callback envelope contract"]}, + "semantic_owner": "work-agent-execution", + "canonical_state": [], + "state_transitions": ["lease + callback envelope -> accepted callback", "wrong identity/token/sequence/expiry -> rejected callback"], + "side_effects_and_privileges": ["Pure validation; no transport, persistence, result mutation or external mutation"], + "persistence_and_migrations": ["No schema or migration changes"], + "failure_retry_restart": ["Duplicate and late callbacks are rejected by sequence/fencing; accepted sequence is persisted later by adapter"], + "assumptions": ["Transport authenticates worker identity before this validation", "Sequence is monotonic within a run and lease token", "Result verification remains a separate step"], + "acceptance_scenarios": ["Matching heartbeat accepted", "Wrong worker/token rejected", "Duplicate/out-of-order sequence rejected", "Expired lease rejected", "Callback run mismatch rejected"], + "required_tests": ["go test ./...", "go vet ./...", "go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json", "go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json", "go run ./cmd/devctl check-architecture", "go run ./cmd/devctl/independent", "git diff --check", "agent callback tests"], + "terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/modules/work/domain/lease.go b/modules/work/domain/lease.go index f1f34bb..2964af8 100644 --- a/modules/work/domain/lease.go +++ b/modules/work/domain/lease.go @@ -9,6 +9,8 @@ import ( var ErrLeaseRejected = errors.New("agent lease rejected") +var ErrCallbackRejected = errors.New("agent callback rejected") + func AcquireAgentLease(runID work.AgentRunID, workerID string, token work.AgentFencingToken, now time.Time, ttl time.Duration) (work.AgentLease, error) { if runID == "" || workerID == "" || token == 0 || now.IsZero() || ttl <= 0 { return work.AgentLease{}, ErrLeaseRejected @@ -23,3 +25,10 @@ func RenewAgentLease(lease work.AgentLease, workerID string, token work.AgentFen lease.ExpiresAt = now.Add(ttl) return lease, nil } + +func ValidateAgentCallback(callback work.AgentCallback, lease work.AgentLease, lastSequence uint64) error { + if lease.State != work.AgentLeaseActive || callback.RunID == "" || callback.RunID != lease.RunID || callback.WorkerID == "" || callback.WorkerID != lease.WorkerID || callback.FencingToken == 0 || callback.FencingToken != lease.FencingToken || callback.Sequence <= lastSequence || callback.Kind == "" || callback.ObservedAt.IsZero() || !callback.ObservedAt.Before(lease.ExpiresAt) { + return ErrCallbackRejected + } + return nil +} diff --git a/tests/agent_execution/lease_test.go b/tests/agent_execution/lease_test.go index eb9a74d..ea54280 100644 --- a/tests/agent_execution/lease_test.go +++ b/tests/agent_execution/lease_test.go @@ -32,3 +32,19 @@ func TestExpiredLeaseCannotRenew(t *testing.T) { t.Fatal("expected expired lease rejection") } } + +func TestAgentCallbackRejectsReplayAndWrongFencing(t *testing.T) { + now := time.Unix(100, 0) + lease, _ := workdomain.AcquireAgentLease("run-1", "worker-a", 1, now, time.Minute) + callback := work.AgentCallback{RunID: "run-1", WorkerID: "worker-a", FencingToken: 1, Sequence: 2, Kind: work.AgentCallbackHeartbeat, ObservedAt: now.Add(time.Second)} + if err := workdomain.ValidateAgentCallback(callback, lease, 1); err != nil { + t.Fatal(err) + } + if err := workdomain.ValidateAgentCallback(callback, lease, 2); err == nil { + t.Fatal("expected replay rejection") + } + callback.FencingToken = 2 + if err := workdomain.ValidateAgentCallback(callback, lease, 1); err == nil { + t.Fatal("expected fencing rejection") + } +}