feat(gl): add standalone ledger authority mode
CI/CD Stage / stage (push) Failing after 1m12s

This commit is contained in:
2026-10-06 22:40:07 +03:30
parent 01d13dbbf2
commit 88e227f80c
26 changed files with 2033 additions and 196 deletions
+8
View File
@@ -15,6 +15,10 @@ type SettlementMonitor interface {
Stats(context.Context) (ledger.SettlementStats, error)
}
type SettlementAuthority interface {
SettlementAllowed(context.Context) (bool, error)
}
type Status struct {
Serving bool
DatabaseReady bool
@@ -50,6 +54,10 @@ func (s *Service) Check(ctx context.Context) Status {
if err == nil {
status.SettlementStats = stats
status.SettlementReady = true
if authority, ok := s.settlement.(SettlementAuthority); ok {
allowed, authorityErr := authority.SettlementAllowed(ctx)
status.SettlementReady = authorityErr == nil && allowed
}
}
}
return status
+12
View File
@@ -61,3 +61,15 @@ func TestCheckReportsSettlementQueryFailure(t *testing.T) {
t.Fatalf("unexpected status: %+v", got)
}
}
type pausedSettlementStub struct{ settlementStub }
func (pausedSettlementStub) SettlementAllowed(context.Context) (bool, error) { return false, nil }
func TestGLOnlyKeepsLedgerServingWhileNormalSettlementClientsFailClosed(t *testing.T) {
monitor := pausedSettlementStub{settlementStub{stats: ledger.SettlementStats{Pending: 3}}}
got := NewService(databaseStub{}, monitor).Check(context.Background())
if !got.Serving || !got.DatabaseReady || !got.SettlementEnabled || got.SettlementReady || got.SettlementStats.Pending != 3 {
t.Fatalf("unexpected GL-only readiness: %+v", got)
}
}
+79
View File
@@ -0,0 +1,79 @@
package ledger
import (
"context"
"strings"
domain "gl/domain/ledger"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
)
type AuthorityRepository interface {
OperatingMode(context.Context) (domain.OperatingState, error)
SetOperatingMode(context.Context, domain.OperatingMode, int64, string, string, bool) (domain.OperatingState, error)
UserBalances(context.Context, string, string, *int64) ([]domain.UserAssetBalance, error)
}
func (s *Service) OperatingMode(ctx context.Context) (domain.OperatingState, error) {
repository, ok := s.repository.(AuthorityRepository)
if !ok {
return domain.OperatingState{}, domain.ErrModeRequired
}
return repository.OperatingMode(ctx)
}
func (s *Service) SetOperatingMode(ctx context.Context, mode domain.OperatingMode, revision int64, actor, reason string, reconciled bool) (domain.OperatingState, error) {
if (mode != domain.ModeNormal && mode != domain.ModeGLOnly) || revision < 0 || strings.TrimSpace(actor) == "" || strings.TrimSpace(reason) == "" {
return domain.OperatingState{}, invalid("mode, nonnegative revision, actor, and reason are required")
}
repository, ok := s.repository.(AuthorityRepository)
if !ok {
return domain.OperatingState{}, domain.ErrModeRequired
}
ctx, span := otel.Tracer("gl/ledger").Start(ctx, "ledger.set_operating_mode")
defer span.End()
span.SetAttributes(attribute.String("ledger.mode", string(mode)), attribute.Int64("ledger.expected_revision", revision), attribute.String("ledger.actor_id", actor))
state, err := repository.SetOperatingMode(ctx, mode, revision, actor, reason, reconciled)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "mode change failed")
return domain.OperatingState{}, err
}
if state.Revision != revision {
span.AddEvent("ledger.operating_mode_changed")
}
span.SetAttributes(attribute.Int64("ledger.revision", state.Revision))
return state, nil
}
func (s *Service) UserBalances(ctx context.Context, ownerType, ownerID string, assetID *int64) ([]domain.UserAssetBalance, error) {
if strings.TrimSpace(ownerType) == "" || strings.TrimSpace(ownerID) == "" || (assetID != nil && *assetID <= 0) {
return nil, invalid("owner type, owner id, and a positive optional asset id are required")
}
repository, ok := s.repository.(AuthorityRepository)
if !ok {
return nil, domain.ErrNotFound
}
return repository.UserBalances(ctx, ownerType, ownerID, assetID)
}
// PostInternalJournal executes exact balanced entries without network settlement.
// PostgreSQL rechecks mode and funds under locks in the same journal transaction.
func (s *Service) PostInternalJournal(ctx context.Context, command AppendJournalCommand) (AppendJournalResult, error) {
if strings.TrimSpace(command.ActorID) == "" {
return AppendJournalResult{}, invalid("actor id is required for internal execution")
}
if command.Blockchain != (domain.BlockchainReference{}) {
return AppendJournalResult{}, invalid("internal execution cannot claim blockchain settlement")
}
for _, entry := range command.Entries {
if entry.Account.Class == domain.AccountClassExternalBlockchain {
return AppendJournalResult{}, invalid("external blockchain movements cannot execute internally")
}
}
command.Metadata = cloneMetadata(command.Metadata)
command.Metadata[domain.InternalExecutionMetadata] = string(domain.ModeGLOnly)
return s.appendJournal(ctx, command)
}
+65
View File
@@ -0,0 +1,65 @@
package ledger
import (
"context"
"errors"
domain "gl/domain/ledger"
"testing"
)
func TestInternalPostingRejectsExternalEvidenceAndMissingActor(t *testing.T) {
for _, tc := range []struct {
name string
change func(*AppendJournalCommand)
}{
{"missing actor", func(c *AppendJournalCommand) { c.ActorID = "" }},
{"blockchain hash", func(c *AppendJournalCommand) { c.Blockchain.TransactionHash = "hash" }},
{"external movement", func(c *AppendJournalCommand) { c.Entries[0].Account.Class = domain.AccountClassExternalBlockchain }},
} {
t.Run(tc.name, func(t *testing.T) {
repository := newRepositoryStub()
command := validAppendCommand()
command.ActorID = "operator"
tc.change(&command)
_, err := NewService(repository, fixedID("test")).PostInternalJournal(context.Background(), command)
if !errors.Is(err, ErrInvalidArgument) || repository.appendCalls != 0 {
t.Fatalf("invalid internal execution reached repository: %v", err)
}
})
}
}
func TestInternalExecutionMarksJournalWithoutMutatingCallerMetadata(t *testing.T) {
repository := newRepositoryStub()
service := NewService(repository, fixedID("test"))
command := validAppendCommand()
command.ActorID = "operator"
command.Metadata = map[string]string{"correlation": "value"}
result, err := service.PostInternalJournal(context.Background(), command)
if err != nil {
t.Fatal(err)
}
if result.Journal.Metadata[domain.InternalExecutionMetadata] != "GL_ONLY" {
t.Fatal("internal provenance missing")
}
if _, ok := command.Metadata[domain.InternalExecutionMetadata]; ok {
t.Fatal("caller metadata mutated")
}
command.Metadata[domain.InternalExecutionMetadata] = "GL_ONLY"
_, err = service.AppendJournal(context.Background(), command)
if !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("ordinary API accepted internal marker: %v", err)
}
}
func TestAuthorityValidatesInputs(t *testing.T) {
service := NewService(newRepositoryStub(), nil)
for _, owner := range []string{"", " "} {
if _, err := service.UserBalances(context.Background(), "user", owner, nil); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("invalid owner: %v", err)
}
}
if _, err := service.SetOperatingMode(context.Background(), "AUTO", 0, "operator", "outage", false); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("invalid mode: %v", err)
}
}
+7
View File
@@ -71,6 +71,13 @@ type AppendJournalResult struct {
}
func (s *Service) AppendJournal(ctx context.Context, command AppendJournalCommand) (AppendJournalResult, error) {
if _, reserved := command.Metadata[domain.InternalExecutionMetadata]; reserved {
return AppendJournalResult{}, invalid("gl_execution is reserved for internal execution")
}
return s.appendJournal(ctx, command)
}
func (s *Service) appendJournal(ctx context.Context, command AppendJournalCommand) (AppendJournalResult, error) {
journalID, err := s.newID()
if err != nil {
return AppendJournalResult{}, fmt.Errorf("generate journal id: %w", err)