diff --git a/db/migrations/00014_intent_failure_class.sql b/db/migrations/00014_intent_failure_class.sql new file mode 100644 index 0000000..65d2a1d --- /dev/null +++ b/db/migrations/00014_intent_failure_class.sql @@ -0,0 +1,29 @@ +-- Why an intent failed, as a class a client can act on (RB4-F13). +-- +-- A failed intent was status = 'failed' with error_message text only. Nothing told the intent's own defect (it +-- will never settle; do not resubmit it as is) from a governance verdict, a missing entitlement, a chain member +-- that did not settle, or CERTEN failing to process it. The class is set from typed errors where the failure is +-- recorded, never parsed from the message. NULL on a failed intent means it failed before the class was recorded. +-- +-- refused the intent itself cannot be settled (its bytes are final on Accumulate) +-- not_entitled its principal holds no CERTEN entitlement +-- governance_unsatisfied its governance proof shows it lacks the authority it needs +-- governance_unavailable its governance proof could not be produced (not a verdict on the intent) +-- settlement_failed a chain member did not settle, or was not proven or written back +-- processing_failed CERTEN could not complete processing it + +ALTER TABLE public.intent_lifecycle ADD COLUMN failure_class character varying(32); + +ALTER TABLE public.intent_lifecycle ADD CONSTRAINT intent_lifecycle_failure_class_known CHECK ( + failure_class IS NULL OR failure_class IN ( + 'refused', 'not_entitled', 'governance_unsatisfied', 'governance_unavailable', 'settlement_failed', 'processing_failed' + ) +); + +-- Only a failed intent has a failure class. +ALTER TABLE public.intent_lifecycle ADD CONSTRAINT intent_lifecycle_failure_class_only_when_failed CHECK ( + failure_class IS NULL OR status = 'failed' +); + +COMMENT ON COLUMN public.intent_lifecycle.failure_class IS + 'Why the intent failed (RB4-F13): refused | not_entitled | governance_unsatisfied | governance_unavailable | settlement_failed | processing_failed. NULL on a failed intent: failed before the class was recorded.'; diff --git a/db/schema.fingerprint b/db/schema.fingerprint index 21938d1..7e9cf25 100644 --- a/db/schema.fingerprint +++ b/db/schema.fingerprint @@ -1 +1 @@ -a0a6a21d55a394e3c1210ef70b16178c3f930f951b7c41d83476bcf47f6fc80d +b9a4c39c2196b0ad4816a0842c22f7c487f932330771820f835d4650ec9c63c2 diff --git a/pkg/consensus/bft_integration.go b/pkg/consensus/bft_integration.go index 306870d..6b54aa6 100644 --- a/pkg/consensus/bft_integration.go +++ b/pkg/consensus/bft_integration.go @@ -55,6 +55,16 @@ import ( // retry — so when in doubt, leave it retryable. var ErrIntentPermanentlyInvalid = errors.New("intent is permanently invalid") +// Why an intent that is not permanently invalid failed, for the lifecycle's failure class (RB4-F13). +var ( + // ErrNotEntitled is an intent whose principal holds no CERTEN entitlement. + ErrNotEntitled = errors.New("principal is not entitled to CERTEN execution") + // ErrGovernanceUnsatisfied is an intent whose governance proof shows it lacks the authority it needs. + ErrGovernanceUnsatisfied = errors.New("governance unsatisfied") + // ErrGovernanceUnavailable is a governance proof that could not be produced - not a verdict on the intent. + ErrGovernanceUnavailable = errors.New("governance proof unavailable") +) + // Version information - can be set at build time via ldflags: // go build -ldflags "-X github.com/certen/independant-validator/pkg/consensus.Version=v1.0.0" var ( @@ -1003,8 +1013,12 @@ func (bv *BFTValidator) executeCanonicalBFTWorkflow( // Build governance proof request from intent data keyPageURL, keyPageErr := bv.resolveSigningKeyPage(ctx, certenIntent, governanceData) if keyPageErr != nil { - return nil, fmt.Errorf("governance proof for intent %s cannot name its key page: %w", - certenIntent.IntentID, keyPageErr) + class := ErrGovernanceUnavailable + if errors.Is(keyPageErr, proof.ErrNoSigningKeyPage) { + class = ErrGovernanceUnsatisfied + } + return nil, fmt.Errorf("%w: governance proof for intent %s cannot name its key page: %w", + class, certenIntent.IntentID, keyPageErr) } bv.logger.Printf("🔑 [GOV-PROOF] intent %s signed by key page %s (declared %q)", certenIntent.IntentID, keyPageURL, governanceData.Authorization.RequiredKeyPage) @@ -1030,30 +1044,30 @@ func (bv *BFTValidator) executeCanonicalBFTWorkflow( // governance proof. Fail the intent instead of attesting to a // weaker claim than the one being made. if govRequest.KeyPage == "" { - return nil, fmt.Errorf("governance proof requires a key page: "+ + return nil, fmt.Errorf("%w: governance proof requires a key page: "+ "G1/G2 cannot be established without one, and G0 alone is not a governance proof "+ - "(intent %s)", certenIntent.IntentID) + "(intent %s)", ErrGovernanceUnsatisfied, certenIntent.IntentID) } g0ProofWrapper, g0Err := bv.governanceProofGen.GenerateG0(ctx, govRequest) if g0Err != nil { - return nil, fmt.Errorf("G0 governance proof failed for intent %s: %w", certenIntent.IntentID, g0Err) + return nil, fmt.Errorf("%w: G0 governance proof failed for intent %s: %w", ErrGovernanceUnavailable, certenIntent.IntentID, g0Err) } if g0ProofWrapper == nil || g0ProofWrapper.G0 == nil { - return nil, fmt.Errorf("G0 governance proof returned no result for intent %s", certenIntent.IntentID) + return nil, fmt.Errorf("%w: G0 governance proof returned no result for intent %s", ErrGovernanceUnavailable, certenIntent.IntentID) } g0Proof = g0ProofWrapper.G0 govReceipts = append(govReceipts, g0ProofWrapper.Receipts...) if !g0Proof.G0ProofComplete { - return nil, fmt.Errorf("G0 governance proof incomplete for intent %s", certenIntent.IntentID) + return nil, fmt.Errorf("%w: G0 governance proof incomplete for intent %s", ErrGovernanceUnavailable, certenIntent.IntentID) } // G0 is final because its receipt is the chained proof's L1 // receipt, ending at the root the BVN quorum signed, at the block // it signed it (pkg/proof/g0_binding.go). Two proofs of one entry // that disagree describe different facts. if err := proof.BindG0ToChainedProof(g0Proof, liteClientProof); err != nil { - return nil, fmt.Errorf("G0 governance proof for intent %s does not bind to its chained proof: %w", - certenIntent.IntentID, err) + return nil, fmt.Errorf("%w: G0 governance proof for intent %s does not bind to its chained proof: %w", + ErrGovernanceUnavailable, certenIntent.IntentID, err) } governanceLevel = "G0" bv.logger.Printf("✅ [GOV-PROOF] G0 proof generated: TXID=%s, ExecMBI=%d, Complete=%v", @@ -1061,18 +1075,18 @@ func (bv *BFTValidator) executeCanonicalBFTWorkflow( g1ProofWrapper, g1Err := bv.governanceProofGen.GenerateG1(ctx, govRequest) if g1Err != nil { - return nil, fmt.Errorf("G1 governance proof failed for intent %s: %w", certenIntent.IntentID, g1Err) + return nil, fmt.Errorf("%w: G1 governance proof failed for intent %s: %w", ErrGovernanceUnavailable, certenIntent.IntentID, g1Err) } if g1ProofWrapper == nil || g1ProofWrapper.G1 == nil { - return nil, fmt.Errorf("G1 governance proof returned no result for intent %s", certenIntent.IntentID) + return nil, fmt.Errorf("%w: G1 governance proof returned no result for intent %s", ErrGovernanceUnavailable, certenIntent.IntentID) } g1Proof = g1ProofWrapper.G1 govReceipts = append(govReceipts, g1ProofWrapper.Receipts...) govTimingBasis = append(govTimingBasis, g1ProofWrapper.TimingBasis...) if !g1Proof.G1ProofComplete || !g1Proof.ThresholdSatisfied { - return nil, fmt.Errorf("G1 governance proof incomplete for intent %s "+ + return nil, fmt.Errorf("%w: G1 governance proof incomplete for intent %s "+ "(complete=%v thresholdSatisfied=%v uniqueKeys=%d)", - certenIntent.IntentID, g1Proof.G1ProofComplete, g1Proof.ThresholdSatisfied, g1Proof.UniqueValidKeys) + ErrGovernanceUnsatisfied, certenIntent.IntentID, g1Proof.G1ProofComplete, g1Proof.ThresholdSatisfied, g1Proof.UniqueValidKeys) } governanceLevel = "G1" bv.logger.Printf("✅ [GOV-PROOF] G1 proof generated: ThresholdSatisfied=%v, UniqueKeys=%d, Complete=%v", @@ -1080,19 +1094,19 @@ func (bv *BFTValidator) executeCanonicalBFTWorkflow( g2ProofWrapper, g2Err := bv.governanceProofGen.GenerateG2(ctx, govRequest) if g2Err != nil { - return nil, fmt.Errorf("G2 governance proof failed for intent %s: %w", certenIntent.IntentID, g2Err) + return nil, fmt.Errorf("%w: G2 governance proof failed for intent %s: %w", ErrGovernanceUnavailable, certenIntent.IntentID, g2Err) } if g2ProofWrapper == nil || g2ProofWrapper.G2 == nil { - return nil, fmt.Errorf("G2 governance proof returned no result for intent %s", certenIntent.IntentID) + return nil, fmt.Errorf("%w: G2 governance proof returned no result for intent %s", ErrGovernanceUnavailable, certenIntent.IntentID) } g2Proof = g2ProofWrapper.G2 govReceipts = append(govReceipts, g2ProofWrapper.Receipts...) govTimingBasis = append(govTimingBasis, g2ProofWrapper.TimingBasis...) if !g2Proof.G2ProofComplete { - return nil, fmt.Errorf("G2 governance proof incomplete for intent %s "+ + return nil, fmt.Errorf("%w: G2 governance proof incomplete for intent %s "+ "(payloadVerified=%v effectVerified=%v): the outcome is not bound, so this is a G1 claim "+ "and must not be recorded as governance", - certenIntent.IntentID, g2Proof.PayloadVerified, g2Proof.EffectVerified) + ErrGovernanceUnsatisfied, certenIntent.IntentID, g2Proof.PayloadVerified, g2Proof.EffectVerified) } governanceLevel = "G2" bv.logger.Printf("✅ [GOV-PROOF] G2 proof generated: PayloadVerified=%v, EffectVerified=%v, Complete=%v", @@ -1103,14 +1117,14 @@ func (bv *BFTValidator) executeCanonicalBFTWorkflow( // authorised and what it did. Attesting with one and not the other // claims more than has been proven. if liteClientProof == nil { - return nil, fmt.Errorf("cannot generate governance proofs for intent %s: "+ - "the L1-L4 lite client proof is not available", certenIntent.IntentID) + return nil, fmt.Errorf("%w: cannot generate governance proofs for intent %s: "+ + "the L1-L4 lite client proof is not available", ErrGovernanceUnavailable, certenIntent.IntentID) } if bv.governanceProofGen == nil { - return nil, fmt.Errorf("cannot generate governance proofs for intent %s: "+ - "the governance proof generator is not configured", certenIntent.IntentID) + return nil, fmt.Errorf("%w: cannot generate governance proofs for intent %s: "+ + "the governance proof generator is not configured", ErrGovernanceUnavailable, certenIntent.IntentID) } - return nil, fmt.Errorf("governance proofs were not generated for intent %s", certenIntent.IntentID) + return nil, fmt.Errorf("%w: governance proofs were not generated for intent %s", ErrGovernanceUnavailable, certenIntent.IntentID) } // Plumb governance proofs + authority URLs onto certenProof so the @@ -1196,8 +1210,8 @@ func (bv *BFTValidator) executeCanonicalBFTWorkflow( return &ExecutionTaskResult{ Success: false, ExecutorID: bv.validatorID, - Error: fmt.Errorf("intent %s refused: principal %q is not entitled to CERTEN execution", - certenIntent.IntentID, principal), + Error: fmt.Errorf("intent %s refused: %w: principal %q has no entitlement evidence", + certenIntent.IntentID, ErrNotEntitled, principal), }, nil } if bv.entitlementMode == EntitlementObserve && entEvidence == nil { diff --git a/pkg/consensus/governance_failure_class_test.go b/pkg/consensus/governance_failure_class_test.go new file mode 100644 index 0000000..190f2cc --- /dev/null +++ b/pkg/consensus/governance_failure_class_test.go @@ -0,0 +1,44 @@ +package consensus + +import ( + "os" + "regexp" + "strings" + "testing" +) + +// RB4-F13: every way the governance block fails the intent says which class the failure is - a verdict on the +// intent (ErrGovernanceUnsatisfied) or a proof that could not be produced (ErrGovernanceUnavailable). +func TestEveryGovernanceFailureCarriesItsClass(t *testing.T) { + src, err := os.ReadFile("bft_integration.go") + if err != nil { + t.Fatal(err) + } + s := string(src) + start := strings.Index(s, "if liteClientProof != nil && bv.governanceProofGen != nil {") + end := strings.Index(s, "certenProof.G0Result = g0Proof") + if start < 0 || end < start { + t.Fatal("the governance block moved; update this test to find it") + } + block := s[start:end] + returns := regexp.MustCompile(`return nil, fmt\.Errorf\((?s:.*?)\)\n`).FindAllString(block, -1) + if len(returns) < 15 { + t.Fatalf("found %d failure returns in the governance block; expected every G0/G1/G2 failure", len(returns)) + } + for _, r := range returns { + if !strings.Contains(r, "ErrGovernanceUnsatisfied") && !strings.Contains(r, "ErrGovernanceUnavailable") && !strings.Contains(r, "class,") { + t.Errorf("a governance failure carries no class:\n%s", r) + } + } +} + +func TestAnUnentitledIntentIsTypedAsSuch(t *testing.T) { + src, err := os.ReadFile("bft_integration.go") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(src), `Error: fmt.Errorf("intent %s refused: %w: principal %q has no entitlement evidence",`) || + !strings.Contains(string(src), "certenIntent.IntentID, ErrNotEntitled, principal)") { + t.Fatal("the entitlement refusal does not carry ErrNotEntitled") + } +} diff --git a/pkg/database/intent_failure_class_test.go b/pkg/database/intent_failure_class_test.go new file mode 100644 index 0000000..ca08e56 --- /dev/null +++ b/pkg/database/intent_failure_class_test.go @@ -0,0 +1,80 @@ +package database + +import ( + "context" + "testing" + + "github.com/google/uuid" +) + +// RB4-F13: a failed intent says why, as a class a client can act on. It was status 'failed' with message text +// only, so an intent's own defect, a governance verdict, a missing entitlement, a chain member that did not settle +// and CERTEN failing to process it all read the same. +func TestAFailedIntentCarriesItsFailureClass(t *testing.T) { + if testDB == nil { + t.Fatal("test database not configured") + } + ctx := context.Background() + repo := NewIntentLifecycleRepository(NewClientFromDB(testDB)) + newIntent := func() string { + id := "f13-" + uuid.NewString() + if _, err := testDB.ExecContext(ctx, `INSERT INTO intent_lifecycle (intent_id, accum_tx_hash, status) VALUES ($1, $2, 'submitted')`, + id, uuid.NewString()[:16]); err != nil { + t.Fatal(err) + } + return id + } + read := func(id string) (status string, class *string) { + if err := testDB.QueryRowContext(ctx, `SELECT status, failure_class FROM intent_lifecycle WHERE intent_id = $1`, id).Scan(&status, &class); err != nil { + t.Fatal(err) + } + return + } + + id := newIntent() + if err := repo.UpdateStatus(ctx, id, IntentLifecycleFailed, WithErrorMessage("boom")); err == nil { + t.Fatal("an intent was failed without saying why") + } + if st, _ := read(id); st != "submitted" { + t.Fatalf("a refused write changed the status to %s", st) + } + if err := repo.UpdateStatus(ctx, id, IntentLifecycleInProcess, WithFailureClass(FailureRefused)); err == nil { + t.Fatal("a failure class was recorded on an intent that did not fail") + } + if err := repo.UpdateStatus(ctx, id, IntentLifecycleFailed, WithErrorMessage("G1 threshold not met"), WithFailureClass(FailureGovernanceUnsatisfied)); err != nil { + t.Fatal(err) + } + if st, class := read(id); st != "failed" || class == nil || *class != "governance_unsatisfied" { + t.Fatalf("recorded %s / %v", st, class) + } + lc, err := repo.GetByIntentID(ctx, id) + if err != nil || lc.FailureClass == nil || *lc.FailureClass != "governance_unsatisfied" { + t.Fatalf("the lifecycle read does not carry the class: %v %+v", err, lc) + } + + // The schema holds it: only a failed intent has a class, and only a known one. + other := newIntent() + if _, err := testDB.ExecContext(ctx, `UPDATE intent_lifecycle SET failure_class = 'refused' WHERE intent_id = $1`, other); err == nil { + t.Fatal("the schema accepted a failure class on an intent that has not failed") + } + if _, err := testDB.ExecContext(ctx, `UPDATE intent_lifecycle SET status = 'failed', failure_class = 'bad luck' WHERE intent_id = $1`, other); err == nil { + t.Fatal("the schema accepted an unknown failure class") + } + + // A chain member that did not settle fails its intent as settlement_failed; settling it after clears the class. + m := newIntent() + if _, err := repo.RecordMemberOutcome(ctx, MemberOutcome{IntentID: m, ChainID: 84532, MemberChains: []int64{84532}, + Settlement: MemberSettlementReverted, ProofCycle: MemberProofCycleWritten, Legs: 1, SettlementTx: "0x" + uuid.NewString()[:8], Reason: "reverted"}); err != nil { + t.Fatal(err) + } + if st, class := read(m); st != "failed" || class == nil || *class != "settlement_failed" { + t.Fatalf("a reverted member left the intent %s / %v", st, class) + } + if _, err := repo.RecordMemberOutcome(ctx, MemberOutcome{IntentID: m, ChainID: 84532, MemberChains: []int64{84532}, + Settlement: MemberSettlementSettled, ProofCycle: MemberProofCycleWritten, Legs: 1, SettlementTx: "0x" + uuid.NewString()[:8]}); err != nil { + t.Fatal(err) + } + if st, class := read(m); st != "complete" || class != nil { + t.Fatalf("a settled intent reads %s / %v", st, class) + } +} diff --git a/pkg/database/intent_lifecycle_types.go b/pkg/database/intent_lifecycle_types.go index ef737e6..251c1b0 100644 --- a/pkg/database/intent_lifecycle_types.go +++ b/pkg/database/intent_lifecycle_types.go @@ -52,6 +52,25 @@ const ( IntentLifecycleFailed IntentLifecycleStatus = "failed" ) +// IntentFailureClass is why a failed intent failed, set from typed errors where the failure is recorded +// (RB4-F13; migration 00014). A failed intent recorded before the class existed has none. +type IntentFailureClass string + +const ( + // FailureRefused: the intent itself cannot be settled; its bytes are final on Accumulate. + FailureRefused IntentFailureClass = "refused" + // FailureNotEntitled: its principal holds no CERTEN entitlement. + FailureNotEntitled IntentFailureClass = "not_entitled" + // FailureGovernanceUnsatisfied: its governance proof shows it lacks the authority it needs. + FailureGovernanceUnsatisfied IntentFailureClass = "governance_unsatisfied" + // FailureGovernanceUnavailable: its governance proof could not be produced - not a verdict on the intent. + FailureGovernanceUnavailable IntentFailureClass = "governance_unavailable" + // FailureSettlementFailed: a chain member did not settle, or was not proven or written back. + FailureSettlementFailed IntentFailureClass = "settlement_failed" + // FailureProcessingFailed: CERTEN could not complete processing it. + FailureProcessingFailed IntentFailureClass = "processing_failed" +) + // IsTerminal returns true if this status represents a final state. // // settling is deliberately NOT terminal: it is the one state whose whole purpose @@ -97,4 +116,7 @@ type IntentLifecycle struct { InProcessAt *time.Time `json:"in_process_at,omitempty" db:"in_process_at"` CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"` FailedAt *time.Time `json:"failed_at,omitempty" db:"failed_at"` + // FailureClass is why a failed intent failed (IntentFailureClass); nil when it has not failed, or failed + // before the class was recorded. + FailureClass *string `json:"failure_class,omitempty" db:"failure_class"` } diff --git a/pkg/database/intent_member_outcomes.go b/pkg/database/intent_member_outcomes.go index e29ec84..f2d6121 100644 --- a/pkg/database/intent_member_outcomes.go +++ b/pkg/database/intent_member_outcomes.go @@ -287,7 +287,7 @@ func (r *IntentLifecycleRepository) RecordMemberOutcome(ctx context.Context, o M derived.Status = IntentLifecycleComplete _, err = tx.ExecContext(ctx, ` UPDATE intent_lifecycle SET status = $1, legs_completed = $2, legs_failed = $3, - completed_at = COALESCE(completed_at, $4), failed_at = NULL, error_message = NULL, + completed_at = COALESCE(completed_at, $4), failed_at = NULL, error_message = NULL, failure_class = NULL, write_back_tx = COALESCE(NULLIF($5, ''), write_back_tx), updated_at = $4 WHERE intent_id = $6`, string(IntentLifecycleComplete), legsDone, legsFailed, now, lastWriteBack, o.IntentID) @@ -296,6 +296,7 @@ func (r *IntentLifecycleRepository) RecordMemberOutcome(ctx context.Context, o M _, err = tx.ExecContext(ctx, ` UPDATE intent_lifecycle SET status = $1, legs_completed = $2, legs_failed = $3, failed_at = COALESCE(failed_at, $4), completed_at = NULL, error_message = $5, + failure_class = 'settlement_failed', write_back_tx = COALESCE(NULLIF($6, ''), write_back_tx), updated_at = $4 WHERE intent_id = $7`, string(IntentLifecycleFailed), legsDone, legsFailed, now, derived.Summary, lastWriteBack, o.IntentID) diff --git a/pkg/database/repository_intent_lifecycle.go b/pkg/database/repository_intent_lifecycle.go index bbf659f..f98de12 100644 --- a/pkg/database/repository_intent_lifecycle.go +++ b/pkg/database/repository_intent_lifecycle.go @@ -31,6 +31,15 @@ type updateOptions struct { errorMessage *string cycleID *string writeBackTx *string + failureClass *string +} + +// WithFailureClass records why a failed intent failed. Only a transition to failed takes one. +func WithFailureClass(class IntentFailureClass) UpdateOption { + return func(o *updateOptions) { + c := string(class) + o.failureClass = &c + } } // WithErrorMessage sets the error message on status update @@ -121,6 +130,13 @@ func (r *IntentLifecycleRepository) UpdateStatus( opt(options) } + if options.failureClass != nil && newStatus != IntentLifecycleFailed { + return fmt.Errorf("update intent lifecycle: a failure class (%s) is recorded only on failed, not %s", *options.failureClass, newStatus) + } + if newStatus == IntentLifecycleFailed && options.failureClass == nil { + return fmt.Errorf("update intent lifecycle: %s fails without a failure class; say why (RB4-F13)", intentID) + } + now := time.Now().UTC() // Build the SET clause dynamically based on the target status @@ -163,13 +179,14 @@ func (r *IntentLifecycleRepository) UpdateStatus( %s = $3, error_message = COALESCE($4, error_message), cycle_id = COALESCE($5, cycle_id), - write_back_tx = COALESCE($6, write_back_tx) + write_back_tx = COALESCE($6, write_back_tx), + failure_class = $8 WHERE intent_id = $7 AND status NOT IN ('complete', 'failed') `, timestampCol) args = []interface{}{ string(newStatus), now, now, - options.errorMessage, options.cycleID, options.writeBackTx, intentID, + options.errorMessage, options.cycleID, options.writeBackTx, intentID, options.failureClass, } } else { query = ` @@ -178,13 +195,14 @@ func (r *IntentLifecycleRepository) UpdateStatus( updated_at = $2, error_message = COALESCE($3, error_message), cycle_id = COALESCE($4, cycle_id), - write_back_tx = COALESCE($5, write_back_tx) + write_back_tx = COALESCE($5, write_back_tx), + failure_class = $7 WHERE intent_id = $6 AND status NOT IN ('complete', 'failed') ` args = []interface{}{ string(newStatus), now, - options.errorMessage, options.cycleID, options.writeBackTx, intentID, + options.errorMessage, options.cycleID, options.writeBackTx, intentID, options.failureClass, } } @@ -218,7 +236,7 @@ func (r *IntentLifecycleRepository) GetByIntentID(ctx context.Context, intentID error_message, block_height, cycle_id, write_back_tx, target_chains, leg_count, execution_mode, legs_completed, legs_failed, created_at, updated_at, submitted_at, authorized_at, - in_process_at, completed_at, failed_at + in_process_at, completed_at, failed_at, failure_class FROM intent_lifecycle WHERE intent_id = $1 ` @@ -230,7 +248,7 @@ func (r *IntentLifecycleRepository) GetByIntentID(ctx context.Context, intentID &lc.CycleID, &lc.WriteBackTx, &lc.TargetChains, &lc.LegCount, &lc.ExecutionMode, &lc.LegsCompleted, &lc.LegsFailed, &lc.CreatedAt, &lc.UpdatedAt, &lc.SubmittedAt, &lc.AuthorizedAt, - &lc.InProcessAt, &lc.CompletedAt, &lc.FailedAt, + &lc.InProcessAt, &lc.CompletedAt, &lc.FailedAt, &lc.FailureClass, ) if err == sql.ErrNoRows { return nil, ErrIntentLifecycleNotFound @@ -248,7 +266,7 @@ func (r *IntentLifecycleRepository) GetByTxHash(ctx context.Context, txHash stri error_message, block_height, cycle_id, write_back_tx, target_chains, leg_count, execution_mode, legs_completed, legs_failed, created_at, updated_at, submitted_at, authorized_at, - in_process_at, completed_at, failed_at + in_process_at, completed_at, failed_at, failure_class FROM intent_lifecycle WHERE accum_tx_hash = $1 ` @@ -260,7 +278,7 @@ func (r *IntentLifecycleRepository) GetByTxHash(ctx context.Context, txHash stri &lc.CycleID, &lc.WriteBackTx, &lc.TargetChains, &lc.LegCount, &lc.ExecutionMode, &lc.LegsCompleted, &lc.LegsFailed, &lc.CreatedAt, &lc.UpdatedAt, &lc.SubmittedAt, &lc.AuthorizedAt, - &lc.InProcessAt, &lc.CompletedAt, &lc.FailedAt, + &lc.InProcessAt, &lc.CompletedAt, &lc.FailedAt, &lc.FailureClass, ) if err == sql.ErrNoRows { return nil, ErrIntentLifecycleNotFound @@ -285,7 +303,7 @@ func (r *IntentLifecycleRepository) ListByStatus(ctx context.Context, status Int error_message, block_height, cycle_id, write_back_tx, target_chains, leg_count, execution_mode, legs_completed, legs_failed, created_at, updated_at, submitted_at, authorized_at, - in_process_at, completed_at, failed_at + in_process_at, completed_at, failed_at, failure_class FROM intent_lifecycle WHERE status = $1 ORDER BY created_at DESC @@ -309,7 +327,7 @@ func (r *IntentLifecycleRepository) ListByUser(ctx context.Context, userID strin error_message, block_height, cycle_id, write_back_tx, target_chains, leg_count, execution_mode, legs_completed, legs_failed, created_at, updated_at, submitted_at, authorized_at, - in_process_at, completed_at, failed_at + in_process_at, completed_at, failed_at, failure_class FROM intent_lifecycle WHERE user_id = $1 ORDER BY created_at DESC @@ -335,7 +353,7 @@ func (r *IntentLifecycleRepository) ListRecentEnriched(ctx context.Context, limi il.cycle_id, il.write_back_tx, il.target_chains, il.leg_count, il.execution_mode, il.legs_completed, il.legs_failed, il.created_at, il.updated_at, il.submitted_at, il.authorized_at, - il.in_process_at, il.completed_at, il.failed_at, + il.in_process_at, il.completed_at, il.failed_at, il.failure_class, bt.from_chain, bt.to_chain, bt.from_address, bt.to_address, bt.amount, bt.token_symbol, bt.account_url FROM intent_lifecycle il @@ -363,7 +381,7 @@ func (r *IntentLifecycleRepository) ListByUserEnriched(ctx context.Context, user il.cycle_id, il.write_back_tx, il.target_chains, il.leg_count, il.execution_mode, il.legs_completed, il.legs_failed, il.created_at, il.updated_at, il.submitted_at, il.authorized_at, - il.in_process_at, il.completed_at, il.failed_at, + il.in_process_at, il.completed_at, il.failed_at, il.failure_class, bt.from_chain, bt.to_chain, bt.from_address, bt.to_address, bt.amount, bt.token_symbol, bt.account_url FROM intent_lifecycle il @@ -393,7 +411,7 @@ func (r *IntentLifecycleRepository) scanEnrichedRows(ctx context.Context, query &e.CycleID, &e.WriteBackTx, &e.TargetChains, &e.LegCount, &e.ExecutionMode, &e.LegsCompleted, &e.LegsFailed, &e.CreatedAt, &e.UpdatedAt, &e.SubmittedAt, &e.AuthorizedAt, - &e.InProcessAt, &e.CompletedAt, &e.FailedAt, + &e.InProcessAt, &e.CompletedAt, &e.FailedAt, &e.FailureClass, &e.FromChain, &e.ToChain, &e.FromAddress, &e.ToAddress, &e.Amount, &e.TokenSymbol, &e.AccountURL, ); err != nil { @@ -423,7 +441,7 @@ func (r *IntentLifecycleRepository) ListRecent(ctx context.Context, limit int) ( error_message, block_height, cycle_id, write_back_tx, target_chains, leg_count, execution_mode, legs_completed, legs_failed, created_at, updated_at, submitted_at, authorized_at, - in_process_at, completed_at, failed_at + in_process_at, completed_at, failed_at, failure_class FROM intent_lifecycle ORDER BY created_at DESC LIMIT $1 @@ -449,7 +467,7 @@ func (r *IntentLifecycleRepository) scanRows(ctx context.Context, query string, &lc.CycleID, &lc.WriteBackTx, &lc.TargetChains, &lc.LegCount, &lc.ExecutionMode, &lc.LegsCompleted, &lc.LegsFailed, &lc.CreatedAt, &lc.UpdatedAt, &lc.SubmittedAt, &lc.AuthorizedAt, - &lc.InProcessAt, &lc.CompletedAt, &lc.FailedAt, + &lc.InProcessAt, &lc.CompletedAt, &lc.FailedAt, &lc.FailureClass, ); err != nil { return nil, fmt.Errorf("scan intent lifecycle row: %w", err) } diff --git a/pkg/intent/discovery.go b/pkg/intent/discovery.go index f44db83..62c5f61 100644 --- a/pkg/intent/discovery.go +++ b/pkg/intent/discovery.go @@ -1500,6 +1500,7 @@ func (id *IntentDiscovery) handleRetryJob(job *intentRetryJob) { if lcErr := id.repos.IntentLifecycle.UpdateStatus(lctx, job.intent.IntentID, database.IntentLifecycleFailed, database.WithErrorMessage(err.Error()), + database.WithFailureClass(failureClassOf(err)), ); lcErr != nil { id.logger.Printf("⚠️ [LIFECYCLE] Failed to mark retry-exhausted intent %s failed: %v", job.intent.IntentID, lcErr) } @@ -2066,5 +2067,23 @@ const lifecycleWriteTimeout = 15 * time.Second func recordLifecycleFailed(w lifecycleStatusWriter, intentID string, cause error) error { ctx, cancel := context.WithTimeout(context.Background(), lifecycleWriteTimeout) defer cancel() - return w.UpdateStatus(ctx, intentID, database.IntentLifecycleFailed, database.WithErrorMessage(cause.Error())) + return w.UpdateStatus(ctx, intentID, database.IntentLifecycleFailed, + database.WithErrorMessage(cause.Error()), database.WithFailureClass(failureClassOf(cause))) +} + +// failureClassOf is why processing an intent failed, read from the typed error the failure carries - never +// from its message (RB4-F13). Its own defect first: a permanently invalid intent is refused whatever else +// went wrong. An error none of the classes types is CERTEN failing to process it. +func failureClassOf(err error) database.IntentFailureClass { + switch { + case errors.Is(err, consensus.ErrIntentPermanentlyInvalid): + return database.FailureRefused + case errors.Is(err, consensus.ErrNotEntitled): + return database.FailureNotEntitled + case errors.Is(err, consensus.ErrGovernanceUnsatisfied): + return database.FailureGovernanceUnsatisfied + case errors.Is(err, consensus.ErrGovernanceUnavailable): + return database.FailureGovernanceUnavailable + } + return database.FailureProcessingFailed } diff --git a/pkg/intent/failure_class_test.go b/pkg/intent/failure_class_test.go new file mode 100644 index 0000000..9b7d760 --- /dev/null +++ b/pkg/intent/failure_class_test.go @@ -0,0 +1,34 @@ +package intent + +import ( + "errors" + "fmt" + "testing" + + "github.com/certen/independant-validator/pkg/consensus" + "github.com/certen/independant-validator/pkg/database" +) + +// RB4-F13: the class comes from the typed error, wrapped as consensus wraps it on its way back to discovery. +func TestAFailureIsClassifiedFromItsTypedError(t *testing.T) { + back := func(err error) error { // the path from the consensus workflow to processIntent's return + return fmt.Errorf("canonical BFT execution failed: %w", fmt.Errorf("canonical BFT workflow failed: %w", err)) + } + for name, c := range map[string]struct { + err error + want database.IntentFailureClass + }{ + "refused by the batch path": {back(fmt.Errorf("intent i refused: %w: %w", consensus.ErrIntentPermanentlyInvalid, errors.New("declared anchor is not live"))), database.FailureRefused}, + "not entitled": {back(fmt.Errorf("intent i refused: %w: principal %q has no entitlement evidence", consensus.ErrNotEntitled, "acc://x.acme")), database.FailureNotEntitled}, + "governance unsatisfied": {back(fmt.Errorf("%w: G1 governance proof incomplete", consensus.ErrGovernanceUnsatisfied)), database.FailureGovernanceUnsatisfied}, + "governance unavailable": {back(fmt.Errorf("%w: G0 governance proof failed: dial tcp: timeout", consensus.ErrGovernanceUnavailable)), database.FailureGovernanceUnavailable}, + "untyped": {back(errors.New("ValidatorBlock admitted but not committed")), database.FailureProcessingFailed}, + "proof unavailable, retries exhausted": {fmt.Errorf("intent i: %w", errChainedProofUnavailable), database.FailureProcessingFailed}, + // Its own defect first: an intent both permanently invalid and unentitled is refused. + "permanently invalid and unentitled": {back(fmt.Errorf("%w: %w", consensus.ErrNotEntitled, consensus.ErrIntentPermanentlyInvalid)), database.FailureRefused}, + } { + if got := failureClassOf(c.err); got != c.want { + t.Errorf("%s: classified %s, want %s", name, got, c.want) + } + } +} diff --git a/pkg/proof/signing_key_page.go b/pkg/proof/signing_key_page.go index aa8a71a..78924d5 100644 --- a/pkg/proof/signing_key_page.go +++ b/pkg/proof/signing_key_page.go @@ -47,6 +47,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "fmt" "sort" "strconv" @@ -122,6 +123,11 @@ func NewChainKeyPageResolver(endpoint string, logf func(string, ...interface{})) // structurally, a page's book is the URL it sits under, whether or not the last // segment is a valid index. With both empty there is no book to look in and the // call fails. +// ErrNoSigningKeyPage is an intent whose signing key page cannot be established from what it declared and how it +// was signed: it declared no key book or page, or no signature on its transaction is from a page of the declared +// book. A fact about the intent, not an outage (RB4-F13). +var ErrNoSigningKeyPage = errors.New("no signing key page") + func (r *ChainKeyPageResolver) ResolveSigningKeyPage( ctx context.Context, principal, txHash, keyBook, declaredPage string, @@ -140,7 +146,7 @@ func (r *ChainKeyPageResolver) ResolveSigningKeyPage( r.logf("[KEYPAGE-RESOLVE] tx %s: %s", scope, n) } if err != nil { - return "", fmt.Errorf("resolve signing key page of %s for %s: %w", book, scope, err) + return "", fmt.Errorf("resolve signing key page of %s for %s: %w: %w", book, scope, ErrNoSigningKeyPage, err) } return page, nil } @@ -154,8 +160,8 @@ func keyBookFor(keyBook, declaredPage string) (string, error) { if i := strings.LastIndex(p, "/"); i > len("acc://") { return p[:i], nil } - return "", fmt.Errorf("neither a key book nor a key page was declared; there is no book to " + - "resolve the signing page in") + return "", fmt.Errorf("%w: neither a key book nor a key page was declared; there is no book to "+ + "resolve the signing page in", ErrNoSigningKeyPage) } // selectSigningKeyPage is the decision, separated from the network read so it can diff --git a/pkg/proof/signing_key_page_class_test.go b/pkg/proof/signing_key_page_class_test.go new file mode 100644 index 0000000..53aa140 --- /dev/null +++ b/pkg/proof/signing_key_page_class_test.go @@ -0,0 +1,15 @@ +package proof + +import ( + "errors" + "testing" +) + +// RB4-F13: an intent that declared no key book or page has no signing key page - a fact about the intent, +// typed as such so its failure is classed governance_unsatisfied rather than as an outage. +func TestNoDeclaredBookIsNoSigningKeyPage(t *testing.T) { + _, err := keyBookFor("", "") + if !errors.Is(err, ErrNoSigningKeyPage) { + t.Fatalf("want ErrNoSigningKeyPage, got %v", err) + } +}