Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions db/migrations/00014_intent_failure_class.sql
Original file line number Diff line number Diff line change
@@ -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.';
2 changes: 1 addition & 1 deletion db/schema.fingerprint
Original file line number Diff line number Diff line change
@@ -1 +1 @@
a0a6a21d55a394e3c1210ef70b16178c3f930f951b7c41d83476bcf47f6fc80d
b9a4c39c2196b0ad4816a0842c22f7c487f932330771820f835d4650ec9c63c2
62 changes: 38 additions & 24 deletions pkg/consensus/bft_integration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand All @@ -1030,69 +1044,69 @@ 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",
g0Proof.TXID, g0Proof.ExecMBI, g0Proof.G0ProofComplete)

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",
g1Proof.ThresholdSatisfied, g1Proof.UniqueValidKeys, g1Proof.G1ProofComplete)

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",
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
44 changes: 44 additions & 0 deletions pkg/consensus/governance_failure_class_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
80 changes: 80 additions & 0 deletions pkg/database/intent_failure_class_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
22 changes: 22 additions & 0 deletions pkg/database/intent_lifecycle_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"`
}
3 changes: 2 additions & 1 deletion pkg/database/intent_member_outcomes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Loading
Loading