ring:using-assert
ring:using-assert
This skill serves two distinct purposes. Choose the correct mode before proceeding.
Mode Selection
MUST choose mode based on the request shape:
| Request Shape | Mode |
|---|---|
| "Sweep / audit the codebase for assert opportunities" | Sweep |
| "Find panic()/log.Fatal() violations" | Sweep |
| "Replace DIY invariant checks with commons/assert" | Sweep |
| "What's the signature for assert.DebitsEqualCredits?" | Reference |
| "How do I initialize assertion metrics?" | Reference |
| "Should I panic, assert, or return an error here?" | Reference |
| "How do I unwrap AssertionError in a Fiber error handler?" | Reference |
- Sweep Mode (primary): Active orchestration. Executes a 4-phase protocol that scans a
target Lerian Go codebase, identifies DIY invariant patterns that should be replaced
with
commons/assert, and emits tasks forring:dev-cycleconsumption. - Reference Mode: Passive catalog. Read sections 1–14 below for API discovery, predicate catalog, and composition patterns.
Sweep Mode uses Reference Mode content as explorer context — each explorer receives the relevant API and predicate entries for its angle so it knows the target surface.
Table of Contents
| # | Section | Mode | What You'll Find |
|---|---|---|---|
| — | Mode Selection | Both | How to choose sweep vs reference |
| — | Sweep Protocol | Sweep | 4-phase scan orchestration |
| — | Explorer Angle Specs | Sweep | 6 DIY patterns and replacements |
| — | Report Template | Sweep | Findings format |
| — | Task Generation | Sweep | ring:dev-cycle handoff format |
| 1 | API Surface | Reference | Exported symbols, signatures, one-line purposes |
| 2 | Asserter Lifecycle | Reference | Scoping, component/operation naming, anti-patterns |
| 3 | Instance Methods | Reference | When to use That / NotNil / NotEmpty / NoError / Never / Halt |
| 4 | Full Domain Predicate Catalog | Reference | Numeric / financial / state-machine / network / time |
| 5 | Composition Pattern | Reference | Pure predicates + observable asserter |
| 6 | The Observability Trident | Reference | Log + span event + metric on every failure |
| 7 | AssertionError Unwrapping | Reference | Error boundary patterns, sentinel check |
| 8 | Decision Tree — panic vs assert vs error | Reference | Three-way distinction with worked examples |
| 9 | Testing Patterns | Reference | Proving assertions fire, production-drift detection |
| 10 | Anti-Pattern Catalog | Reference | Six anti-patterns with consequence narrative |
| 11 | Bootstrap Order | Reference | Where InitAssertionMetrics fits |
| 12 | Cross-References | Reference | Pointers into using-lib-commons, using-runtime |
| 13 | Cross-Cutting Patterns | Reference | Nil-safety, production mode, runtime interaction, performance |
| 14 | Breaking Changes | Reference | v4 → v5 delta for commons/assert |
SWEEP MODE
MANDATORY: When invoked in Sweep Mode, the orchestrator MUST execute all four phases in order. MUST NOT skip phases. MUST NOT shortcut Phase 3 by reducing explorer count. FORBIDDEN: Producing a report based on the orchestrator's own code inspection — the 6 explorers are the source of truth for findings.
Sweep Protocol
The sweep runs in four sequential phases. Each phase has a HARD GATE — MUST NOT proceed to the next phase until the current phase produces its required artifact.
Phase 1: Version Reconnaissance → version-report.json
Phase 2: CHANGELOG Delta Analysis → delta-report.json
Phase 3: Multi-Angle DIY Sweep → 6 × assert-sweep-{N}-{angle}.json
Phase 4: Consolidated Report → assert-sweep-report.md + assert-sweep-tasks.json
★ Insight ─────────────────────────────────────
The sweep's value is not that it catches panic() — grep does that. The sweep's value
is that it catches asymmetric drift: invariants enforced only in tests, defensive
checks that return silent errors, hand-rolled predicates that duplicate canonical ones.
These patterns pass code review because each individual instance is "not obviously wrong"
— the wrongness is systemic and only visible when you aggregate across the codebase.
─────────────────────────────────────────────────
Phase 1: Version Reconnaissance
MANDATORY steps (orchestrator executes directly):
-
Read
go.modat the target project root.- Extract the line matching
github.com/LerianStudio/lib-commons/vN. - Capture the exact pinned version (e.g.,
v5.1.0,v4.2.0). - If the dependency is absent, STOP and report: "Target is not a lib-commons consumer. Assert sweep not applicable."
- Extract the line matching
-
WebFetch latest release:
https://api.github.com/repos/LerianStudio/lib-commons/releases/latestExtract
tag_name(the latest v5.x release) andpublished_at. -
Compare versions and flag drift:
Condition Classification Pinned == Latest Up-to-date Pinned is patch behind Minor drift Pinned is minor behind Moderate drift Pinned is major behind (v4.x → v5.x) Major upgrade Module path mismatch (no /vNsuffix)Module mismatch -
If v4.x detected: Add a "major upgrade advisory" flag to the report. Phase 3 explorers MUST receive this flag. The
commons/assertAPI surface is source-compatible v4 → v5, but the module path bump (v4 → v5) is a prerequisite for every recommendation in the report, so it must lead the task list. -
Emit
/tmp/assert-version-report.jsonwith fields:pinned_version(string)latest_version(string)drift_classification(one of: up-to-date, minor-drift, moderate-drift, major-upgrade, module-mismatch)major_upgrade_required(bool)module_path(string, e.g.,github.com/LerianStudio/lib-commons/v5)
Phase 2: CHANGELOG Delta Analysis
MANDATORY steps:
-
WebFetch CHANGELOG:
https://raw.githubusercontent.com/LerianStudio/lib-commons/main/CHANGELOG.md -
Extract entries between
pinned_version(exclusive) andlatest_version(inclusive) that affectcommons/assert. Parse the standard Keep-a-Changelog sections:Added,Changed,Fixed,Security,Deprecated,Removed. -
Filter to assert-scoped changes. Look for entries referencing:
commons/assertorassertpackage- Asserter methods (
That,NotNil,NotEmpty,NoError,Never,Halt) - Domain predicates (
PositiveDecimal,DebitsEqualCredits,ValidTransactionStatus, etc.) AssertionError,ErrAssertionFailedInitAssertionMetrics
-
Classify each entry into one of:
new-predicate— a new domain predicate addednew-method— a new Asserter methodbreaking-change— backward-incompatible changesecurity-fix— fix the consumer benefits from by upgradingbugfix— consumer-facing bug resolved
-
Emit
/tmp/assert-delta-report.jsonwith a list of entries. If no assert-scoped changes exist between the pinned and latest versions, emit an empty array — this is a valid outcome and the Unadopted Features section of the report will be empty.
Phase 3: Multi-Angle DIY Sweep
MANDATORY: Dispatch all 6 explorer angles in ONE batch of 6 parallel dispatches. MUST NOT skip any angle. MUST wait for the batch to complete before proceeding to Phase 4.
Per-explorer dispatch contract:
Each explorer MUST be dispatched with subagent_type: ring:codebase-explorer. The
prompt MUST contain exactly these sections:
## Target
<absolute path to target repo root>
## Your Angle
<angle number + name, e.g., "Angle 1: panic() in non-test code">
## Severity Calibration
<CRITICAL | HIGH | MEDIUM | LOW — from angle spec>
## What to Detect (DIY patterns)
<bullet list of grep patterns, import paths, code signatures>
## Replacement
<commons/assert API — method + predicate composition>
## Migration Complexity
<trivial | moderate | complex>
## Version Context
Pinned: <from Phase 1>
Latest: <from Phase 1>
Major upgrade required: <bool from Phase 1>
## Output
Write findings to: /tmp/assert-sweep-{N}-{angle-slug}.json
Schema: see below.
Explorer output schema (/tmp/assert-sweep-{N}-{angle-slug}.json):
{
"angle_number": 1,
"angle_name": "panic() in non-test code",
"severity": "CRITICAL",
"migration_complexity": "moderate",
"findings": [
{
"file": "internal/ledger/posting.go",
"line": 84,
"diy_pattern": "panic(\"amount must be positive\")",
"replacement": "asserter.That(ctx, assert.PositiveDecimal(amount), ...)",
"evidence_snippet": "if amount.IsNegative() { panic(\"amount must be positive\") }",
"notes": "Zero-panic policy violation — crashes service on bad input"
}
],
"summary": "3 files contain panic() in non-test code (zero-panic policy violation)",
"requires_major_upgrade": false
}
If an explorer finds nothing for its angle, it MUST still write a file with an
empty findings array and a summary stating "No DIY patterns detected for this angle".
This lets the synthesizer distinguish "checked and clean" from "not checked".
Phase 4: Consolidated Report + Task Generation
MANDATORY: Dispatch a synthesizer agent (subagent_type: ring:codebase-explorer or
general-purpose) with this contract:
## Inputs
- /tmp/assert-version-report.json
- /tmp/assert-delta-report.json
- /tmp/assert-sweep-*.json (6 files)
## Outputs
1. /tmp/assert-sweep-report.md (human-readable report — see Report Template)
2. /tmp/assert-sweep-tasks.json (ring:dev-cycle task array — see Task Generation)
## Your Job
MUST read all 6 explorer files. MUST aggregate findings by severity. MUST produce the
report following the exact template below. MUST generate one task per DIY pattern cluster
(group findings in the same file/package into one task).
MUST NOT invent findings not present in explorer outputs.
MUST NOT omit findings that explorers flagged.
MUST NOT reclassify severity without explicit justification in the task description.
After synthesis completes, the orchestrator surfaces the report path + task count to the
user and offers handoff to ring:dev-cycle.
Explorer Angle Specifications
MANDATORY: All 6 angles run on every sweep. The catalog below is the source of truth for what each explorer looks for. MUST NOT edit angle specs at dispatch time — copy verbatim into the explorer prompt.
Angle 1: panic() / log.Fatal / log.Panic / Must* in non-test code
Severity: CRITICAL
DIY Patterns to Detect:
panic(in any.gofile that is not_test.golog.Fatal(/log.Fatalf(/log.Fatalln(in non-test codelog.Panic(/log.Panicf(/log.Panicln(in non-test code- Custom
Must*helper calls (e.g.,MustParseDecimal,MustConnect) in non-test code - Exception (allowed):
regexp.MustCompilewith a compile-time string literal constant is the only sanctioned exception to the zero-panic policy. If the argument is a variable or computed at runtime, it is a violation.
commons/assert Replacement:
asserter.That(ctx, condition, "message", keysAndValues...)— returns error, observability trident firesasserter.NotNil(ctx, value, "message", keysAndValues...)— for nil-receiver guardsasserter.NoError(ctx, err, "message", keysAndValues...)— when wrapping an existing errorasserter.Never(ctx, "message", keysAndValues...)— for unreachable branches
Migration Complexity: moderate (callers must be updated to accept returned error)
Example Transformation:
// BEFORE:
func postEntry(amount decimal.Decimal) {
if amount.IsNegative() {
panic("amount must be positive")
}
if amount.IsZero() {
log.Fatal("amount cannot be zero")
}
// ...
}
// AFTER:
func postEntry(ctx context.Context, a *assert.Asserter, amount decimal.Decimal) error {
if err := a.That(ctx, assert.PositiveDecimal(amount),
"amount must be positive",
"amount", amount.String()); err != nil {
return err
}
// ...
return nil
}
Explorer Dispatch Prompt Template:
Sweep the target repo for zero-panic policy violations. MUST find every
panic(,log.Fatal(,log.Fatalf(,log.Fatalln(,log.Panic(,log.Panicf(,log.Panicln(, and.Must*(helper call in any.gofile that is NOT a_test.gofile. The only allowed exception isregexp.MustCompile(whose argument is a compile-time string literal — if the argument is a variable, that is a violation. For each finding record file:line, the exact call, and whether the surrounding context is bootstrap/init, runtime, or hot-path. Severity CRITICAL — these are production crashes waiting to happen and each one is a zero-panic policy violation. Write/tmp/assert-sweep-1-panic.json.
Angle 2: Defensive nil/empty checks without metric emission
Severity: HIGH
DIY Patterns to Detect:
if x == nil { return err }/if x == nil { return fmt.Errorf(...) }in code paths where the nil represents an invariant violation (internal state that should never be nil), not an expected input-validation failureif s == "" { return err }on fields that are invariants (tenant ID on authenticated request, transaction ID on internal call), not user inputif err != nil { return err }at invariant boundaries (internal service calls where error "should never happen" but propagates silently with no observability)- Any pattern that silently propagates an invariant failure without emitting metric / log / span event
Important distinction: NOT every if err != nil { return err } is wrong. Expected failure paths — user input validation, external I/O failure, context cancellation — are normal error returns and do NOT need asserter coverage. This angle targets invariant paths: internal state that the caller/callee contract guarantees will be valid, where a violation is a bug, not a user error.
commons/assert Replacement:
asserter.NotNil(ctx, x, "x must not be nil at this boundary", keys...)— for nil-at-invariantasserter.NotEmpty(ctx, s, "tenant ID required at this boundary")— for empty-string invariantsasserter.NoError(ctx, err, "internal call must not fail", keys...)— for "should never error" paths
Migration Complexity: moderate — requires deciding which checks are invariants vs expected failures
Example Transformation:
// BEFORE — invariant check silently returns error, no observability:
func postToLedger(ctx context.Context, txnID string, account *Account) error {
if account == nil {
return errors.New("account is nil") // silent — never surfaces in metrics
}
if txnID == "" {
return errors.New("transaction ID empty") // silent
}
// ...
}
// AFTER — asserter fires the observability trident on invariant violation:
func postToLedger(ctx context.Context, a *assert.Asserter, txnID string, account *Account) error {
if err := a.NotNil(ctx, account, "account required at ledger boundary",
"transaction_id", txnID); err != nil {
return err
}
if err := a.NotEmpty(ctx, txnID, "transaction ID required at ledger boundary"); err != nil {
return err
}
// ...
return nil
}
Explorer Dispatch Prompt Template:
Sweep the target repo for defensive invariant checks that return silently without observability. Search for
if x == nil { return,if s == "" { return, andif err != nil { return err }patterns. For each finding, assess whether the check is on an invariant path (internal state that should never be invalid — e.g., tenant ID on an authenticated request, non-nil DB connection after successful init, non-empty transaction ID on internal call) or an expected-failure path (user input validation, external I/O, context cancellation). Flag only invariant-path checks — expected failures are normal error returns and do NOT need asserter coverage. For each flagged finding record file:line, the invariant being checked, and why it is an invariant (not expected failure). Severity HIGH — silent invariant violations corrupt state and never appear in dashboards. Write/tmp/assert-sweep-2-defensive.json.
Angle 3: Hand-rolled domain predicates duplicating assert.*
Severity: HIGH
DIY Patterns to Detect:
- Functions named
isPositive,isValidAmount,debitsEqualCredits,validateTransactionStatus,isValidUUID,isPortValid,isValidSSLMode,inRange, and their typo/case variants - Inline equivalents that duplicate canonical predicates:
if amount.IsPositive() && !amount.IsZero()(equivalent toassert.PositiveDecimal),if d.Cmp(c) == 0 && !d.IsZero()(equivalent toassert.DebitsEqualCredits+NonZeroTotals) - Custom transaction-status switch statements exhaustively enumerating CREATED/APPROVED/PENDING/CANCELED/NOTED (duplicates
assert.ValidTransactionStatus) - Hand-rolled status-transition lookup tables (duplicates
assert.TransactionCanTransitionTo)
commons/assert Replacement: Delete the DIY predicate, compose asserter with the canonical predicate:
| DIY Predicate | Canonical Replacement |
|---|---|
isPositive(n int64) |
assert.Positive(n) |
isNonNegative(n int64) |
assert.NonNegative(n) |
isInRange(n, min, max) |
assert.InRange(n, min, max) |
isPositiveAmount(d decimal) |
assert.PositiveDecimal(d) |
isNonNegativeAmount(d) |
assert.NonNegativeDecimal(d) |
isValidAmount(d) |
assert.ValidAmount(d) |
isValidScale(s int) |
assert.ValidScale(s) |
debitsEqualCredits(d, c) |
assert.DebitsEqualCredits(d, c) |
isValidTxStatus(s) |
assert.ValidTransactionStatus(s) |
canTransitionTo(from, to) |
assert.TransactionCanTransitionTo(from, to) |
isValidUUID(s) |
assert.ValidUUID(s) |
isValidPort(p) |
assert.ValidPort(p) |
isValidSSLMode(m) |
assert.ValidSSLMode(m) |
Migration Complexity: trivial (delete + swap)
Example Transformation:
// BEFORE — hand-rolled predicate reinvents the canonical one:
func isValidTransactionStatus(s string) bool {
switch s {
case "CREATED", "APPROVED", "PENDING", "CANCELED", "NOTED":
return true
}
return false
}
func process(ctx context.Context, txnID, status string) error {
if !isValidTransactionStatus(status) {
return fmt.Errorf("invalid status: %s", status)
}
// ...
}
// AFTER — canonical predicate + asserter:
func process(ctx context.Context, a *assert.Asserter, txnID, status string) error {
if err := a.That(ctx, assert.ValidTransactionStatus(status),
"invalid transaction status",
"transaction_id", txnID, "status", status); err != nil {
return err
}
// ...
return nil
}
Explorer Dispatch Prompt Template:
Sweep the target repo for hand-rolled domain predicates that duplicate
commons/assertcanonical predicates. Search for functions namedisPositive*,isValidAmount*,isValidTransactionStatus*,canTransition*,isValidUUID*,isValidPort*,debitsEqualCredits*,isInRange*, and similar patterns. Also search for inline equivalents — switch statements enumerating CREATED/APPROVED/PENDING/CANCELED/NOTED, inlined.Cmp(c) == 0 && !d.IsZero()constructions, and range checks (x >= min && x <= max) that could beassert.InRange. Cross-reference each hand-rolled predicate against the canonical catalog in the "Full Domain Predicate Catalog" section ofring:using-assert. For each finding record file:line, the DIY predicate name or inline construction, and the exact canonical replacement. Severity HIGH — divergence between DIY predicates and canonical ones causes subtle bugs (e.g., DIY forgetting a status, accepting zero amounts, losing sign checks). Write/tmp/assert-sweep-3-predicates.json.
Angle 4: Missing InitAssertionMetrics at startup
Severity: MEDIUM
DIY Patterns to Detect:
- The service uses
assert.New(or asserter methods somewhere in the codebase - But
main.go,cmd/*/main.go,internal/app/, orinternal/bootstrap/packages do NOT containassert.InitAssertionMetrics( - Symptom:
assertis imported and used, but theassertion_failed_totalmetric counter is never emitted, so assertion failures are invisible in dashboards even when they fire
Grep pattern:
Presence: grep -r 'assert.New\|assertion.That\|a.That\|a.NotNil\|a.NotEmpty\|a.NoError'
Absence: grep -r 'assert.InitAssertionMetrics' → returns no results
commons/assert Replacement:
Add to the bootstrap sequence, AFTER telemetry is set up, ALONGSIDE runtime.InitPanicMetrics:
// After: tl, _ := opentelemetry.NewTelemetry(...)
runtime.InitPanicMetrics(tl.MetricsFactory, logger)
assert.InitAssertionMetrics(tl.MetricsFactory) // add this
Migration Complexity: trivial (one-line bootstrap addition)
Example Transformation:
// BEFORE — metrics factory wired, panic metrics initialized, assert metrics forgotten:
tl, _ := opentelemetry.NewTelemetry(telemetryConfig)
runtime.InitPanicMetrics(tl.MetricsFactory, logger)
// assertion_failed_total counter will never fire even when assertions do
// AFTER:
tl, _ := opentelemetry.NewTelemetry(telemetryConfig)
runtime.InitPanicMetrics(tl.MetricsFactory, logger)
assert.InitAssertionMetrics(tl.MetricsFactory)
Explorer Dispatch Prompt Template:
Sweep the target repo to determine whether
commons/assertis used withoutInitAssertionMetricsbeing called during bootstrap. First, grep for any usage ofassert.New(or asserter methods (.That(,.NotNil(,.NotEmpty(,.NoError(,.Never() across the codebase — this establishes that the service DOES use the assert package. Then grepmain.go,cmd/*/main.go,internal/app/*, andinternal/bootstrap/*forassert.InitAssertionMetrics(— its ABSENCE is the finding. If assert is used but InitAssertionMetrics is never called, record the file:line where asserter usage begins (to establish "yes, used") and the bootstrap file:line where the call should be added. If assert is not used at all in the service, emit an empty findings array. Severity MEDIUM — missing this call does not break functionality but causes invisible failures (trident emits log + span event but the metric counter stays at zero, so dashboards never alert on assertion spikes). Write/tmp/assert-sweep-4-initmetrics.json.
Angle 5: Financial invariants enforced only in tests, not in production
Severity: HIGH
DIY Patterns to Detect:
_test.gofiles that assert on financial invariants usingassert.DebitsEqualCredits,assert.TransactionCanTransitionTo,assert.BalanceSufficientForRelease,assert.PositiveDecimal,assert.NonZeroTotals, or equivalent hand-rolled checks- But the corresponding production code path (the function the test exercises) does NOT enforce the same invariant via an asserter call
Detection technique:
- Grep test files for invariant assertions:
require.True.*DebitsEqualCredits,require.NoError.*asserter.That.*DebitsEqualCredits, or hand-rolled equivalents - For each hit, identify the function under test (e.g., test
TestPostTransactionexercisesPostTransaction) - Read the production function and check: does it call
asserter.That(ctx, assert.DebitsEqualCredits(...), ...)itself? If not → finding
Why this matters: The test confirms the invariant holds under the test's inputs. The production code path can still receive inputs that violate the invariant (e.g., a bug upstream produces unbalanced debits) and silently corrupt ledger state. The invariant lives in the test, not in the production code, so CI stays green while production drifts.
commons/assert Replacement:
Mirror the test assertion into the production code path:
// Production code — add the same invariant check the test makes:
func PostTransaction(ctx context.Context, a *assert.Asserter, txn *Transaction) error {
// Invariant: debits must equal credits (double-entry)
if err := a.That(ctx, assert.DebitsEqualCredits(txn.TotalDebits(), txn.TotalCredits()),
"double-entry invariant violated at posting",
"transaction_id", txn.ID,
"debits", txn.TotalDebits().String(),
"credits", txn.TotalCredits().String()); err != nil {
return err
}
// ... actual posting logic
return nil
}
Migration Complexity: moderate — requires understanding test intent and mirroring into production
Example Transformation:
// BEFORE — test asserts invariant, production does not:
// ledger_test.go
func TestPostTransaction_BalancesEqual(t *testing.T) {
txn := buildTransaction(100, 100)
require.True(t, assert.DebitsEqualCredits(txn.TotalDebits(), txn.TotalCredits()))
err := PostTransaction(ctx, txn)
require.NoError(t, err)
}
// ledger.go — production code has NO invariant check:
func PostTransaction(ctx context.Context, txn *Transaction) error {
return repo.Insert(ctx, txn) // trusts input blindly
}
// AFTER — invariant mirrored into production, asserter fires trident if violated:
// ledger.go:
func PostTransaction(ctx context.Context, a *assert.Asserter, txn *Transaction) error {
if err := a.That(ctx, assert.DebitsEqualCredits(txn.TotalDebits(), txn.TotalCredits()),
"double-entry invariant violated",
"transaction_id", txn.ID,
"debits", txn.TotalDebits().String(),
"credits", txn.TotalCredits().String()); err != nil {
return err
}
return repo.Insert(ctx, txn)
}
★ Insight ───────────────────────────────────── This is the highest-leverage angle in the sweep for a ledger codebase. An invariant that lives only in tests passes CI and deploys to production, where it becomes a silent assumption. Production code then trusts upstream callers, and the first time an upstream bug produces unbalanced debits, the ledger writes the corruption straight to disk. Mirroring test invariants into production is not redundancy — it's defense-in-depth against the limits of test coverage. ─────────────────────────────────────────────────
Explorer Dispatch Prompt Template:
Sweep the target repo for financial invariants enforced only in tests and not in production. Walk
_test.gofiles and find every assertion referencingassert.DebitsEqualCredits,assert.TransactionCanTransitionTo,assert.BalanceSufficientForRelease,assert.PositiveDecimal,assert.NonZeroTotals, or hand-rolled equivalents (e.g.,require.Equal(t, debits, credits)). For each test assertion, identify the production function under test (typically inferred from test name —TestPostTransaction→PostTransaction). Read the production function and check: does it call an asserter with the SAME invariant? If not → finding. For each finding record the test file:line, the production file:line (function definition), and the specific invariant that is test-only. Severity HIGH — these are silent ledger-corruption risks. Write/tmp/assert-sweep-5-test-only.json.
Angle 6: AssertionError not unwrapped in error boundaries
Severity: MEDIUM
DIY Patterns to Detect:
- Fiber error handlers (
fiber.Config{ErrorHandler: ...}orFiberErrorHandleroverrides) that log errors withouterrors.As(err, &assertErr) - gRPC unary/stream interceptors that log errors returned by service methods without AssertionError unwrap
- RabbitMQ consumer callbacks / message-handler error paths that log errors without AssertionError unwrap
- HTTP middleware error-logging paths that lose the structured AssertionError context
- Any
logger.Error("handler failed", "error", err)at an error boundary — whenerrmay be an AssertionError, the structural context (Component, Operation, Assertion) is flattened into the opaque error string
commons/assert Replacement:
At error boundaries, unwrap and log the structural context:
func logHandlerError(ctx context.Context, logger log.Logger, err error) {
var assertErr *assert.AssertionError
if errors.As(err, &assertErr) {
logger.Error("assertion violated",
"component", assertErr.Component,
"operation", assertErr.Operation,
"assertion", assertErr.Assertion,
"message", assertErr.Message,
"context", assertErr.Context,
)
return
}
logger.Error("handler error", "error", err)
}
Migration Complexity: trivial (one unwrap block added at each error boundary)
Example Transformation:
// BEFORE — error boundary flattens AssertionError:
app := fiber.New(fiber.Config{
ErrorHandler: func(c *fiber.Ctx, err error) error {
logger.Error("request failed", "error", err.Error())
// Component / Operation / Assertion labels lost in err.Error() string
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
},
})
// AFTER — unwrap AssertionError at the boundary:
app := fiber.New(fiber.Config{
ErrorHandler: func(c *fiber.Ctx, err error) error {
var assertErr *assert.AssertionError
if errors.As(err, &assertErr) {
logger.Error("assertion violated at boundary",
"component", assertErr.Component,
"operation", assertErr.Operation,
"assertion", assertErr.Assertion,
"message", assertErr.Message,
)
// Assertion failures are internal invariant violations → 500, not 400
return c.Status(500).JSON(fiber.Map{"error": "internal invariant violated"})
}
logger.Error("request failed", "error", err.Error())
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
},
})
Consequence of missing this pattern: When an assertion fires, the trident emits the metric and span event correctly, but the log line at the error boundary shows only the flattened message string. Operators reading the log cannot tell which Component + Operation + Assertion failed without correlating by timestamp to the span event — painful during incident triage.
Explorer Dispatch Prompt Template:
Sweep the target repo for error boundaries that do not unwrap
*assert.AssertionError. Search for Fiber error handlers (ErrorHandler:,FiberErrorHandler), gRPC interceptors (grpc.UnaryInterceptor,grpc.StreamInterceptor), RabbitMQ consumer callbacks, and HTTP middleware error-logging paths. For each boundary, check whether the error-handling code containserrors.As(err, &<assertErrVar>)where the variable is of type*assert.AssertionError. Boundaries that log the error without this unwrap are findings — they flatten Component/Operation/Assertion into an opaque string and lose structural observability in logs. For each finding record file:line, the boundary type (Fiber / gRPC / AMQP / HTTP middleware), and the log statement that should unwrap. Severity MEDIUM — the trident (metric + span event) still fires correctly; only the log layer loses structure. Write/tmp/assert-sweep-6-unwrap.json.
Report Template
MANDATORY: The synthesizer MUST produce /tmp/assert-sweep-report.md following this
exact structure. MUST NOT add sections. MUST NOT reorder sections. MUST populate every
section even if empty (use "None detected" placeholders).
# commons/assert Sweep Report
**Target:** <absolute path to target repo>
**Generated:** <ISO-8601 timestamp>
**Sweep duration:** <seconds>
---
## Version Status
| Field | Value |
| ------------------------ | ----------------- |
| Pinned version | <v5.0.0> |
| Latest stable | <resolved at runtime> |
| Drift classification | <minor-drift> |
| Major upgrade required | <yes / no> |
| Module path | <.../v5> |
**Assessment:** <one-paragraph narrative — "project is up-to-date on commons/assert,
all recommendations apply to pinned version" or "project pinned to v4.2.0, v5 migration
required before adopting recommendations below">
---
## Unadopted Features
Changes to `commons/assert` between the pinned version and latest stable that the
target has not yet adopted:
| Version | Feature | Classification | Relevant Finding Angle |
| ------- | --------------------------- | --------------- | ---------------------- |
| <ver> | <feature> | <classification>| <angle> |
(If no assert-scoped changes exist in the delta, write "No unadopted features — the
commons/assert API surface is unchanged between pinned and latest versions.")
---
## Quick Wins
Severity LOW–MEDIUM, migration complexity trivial. Low-risk, high-leverage fixes
batchable in a single dev-cycle task.
<bulleted list of findings grouped by angle — each bullet: "Angle N: <summary>, <file count> files, trivial">
---
## Strategic Migrations
Severity HIGH–CRITICAL, migration complexity moderate–complex. High-value, multi-task
efforts that MUST go through the full dev-cycle.
<bulleted list of findings grouped by angle — each bullet: "Angle N: <summary>, <file count> files, complexity, expected impact">
---
## Full Findings
| Angle | Severity | File | Line | DIY Pattern | Replacement | Complexity |
| ----------------------------- | --------- | --------------------------- | ---- | ------------------------------------------ | ---------------------------------------- | ---------- |
| 1 panic() in non-test | CRITICAL | internal/ledger/posting.go | 84 | panic("amount must be positive") | asserter.That + assert.PositiveDecimal | moderate |
| 5 invariant test-only | HIGH | internal/ledger/ledger.go | 112 | DebitsEqualCredits only in test | asserter.That + DebitsEqualCredits | moderate |
| 3 hand-rolled predicate | HIGH | internal/txn/validate.go | 47 | isValidTransactionStatus(s) | assert.ValidTransactionStatus | trivial |
| ... | ... | ... | ... | ... | ... | ... |
---
## Summary Statistics
| Severity | Findings | Files affected | Estimated effort |
| -------- | -------- | -------------- | ---------------- |
| CRITICAL | N | N | N days |
| HIGH | N | N | N days |
| MEDIUM | N | N | N days |
| LOW | N | N | N days |
| **Total**| **N** | **N** | **N days** |
**Angles clean:** <list of angles where no DIY was detected — signals codebase health>
---
## Recommended Next Step
`ring:dev-cycle` consuming `/tmp/assert-sweep-tasks.json` — N tasks generated,
grouped by severity, CRITICAL first. Angle 1 (zero-panic policy) and Angle 5
(test-only invariants) MUST land before other tiers — both are production-safety
risks.
Task Generation for ring:dev-cycle
MANDATORY: The synthesizer MUST also emit /tmp/assert-sweep-tasks.json — a JSON
array of tasks shaped for ring:dev-cycle consumption. The format matches what
ring:dev-refactor produces.
Task grouping rules:
- MUST group findings by severity — CRITICAL first, then HIGH, MEDIUM, LOW.
- Within a severity tier, MUST group findings from the same file or tightly-related files (same package, same bounded context) into a single task.
- CRITICAL findings (Angle 1 — zero-panic violations) MUST be standalone tasks (no batching across concerns) — each gets its own dev-cycle pass.
- MUST include dependency references when one task's correctness depends on another (e.g., "Add InitAssertionMetrics" depends on "Upgrade lib-commons to v5" when the pinned version is v4.x).
Task schema:
{
"id": "assert-sweep-001",
"title": "Replace panic() in ledger posting with asserter",
"severity": "CRITICAL",
"description": "Target service violates zero-panic policy in internal/ledger/posting.go (3 call sites). panic() on invariant violations crashes the service instead of firing the observability trident and returning a recoverable error. Replace each panic with asserter.That + the appropriate domain predicate (PositiveDecimal, DebitsEqualCredits, ValidTransactionStatus). The assertion_failed_total metric becomes an observable signal on these invariants.",
"files_affected": [
"internal/ledger/posting.go:84",
"internal/ledger/posting.go:127",
"internal/ledger/posting.go:201"
],
"acceptance_criteria": [
"All panic() calls in internal/ledger/posting.go removed",
"Each invariant enforced via asserter.That + canonical assert predicate",
"Callers updated to accept returned error",
"Unit tests verify asserter fires on invariant violation (metric counter + span event)",
"No regexp.MustCompile exception abused — any remaining panic() is justified and minimal"
],
"estimated_complexity": "moderate",
"depends_on": [],
"angle": 1,
"replacement_api": "commons/assert.Asserter + domain predicates"
}
Task emission verbatim example:
[
{
"id": "assert-sweep-001",
"title": "Upgrade lib-commons from v4.2.0 to latest v5.x",
"severity": "HIGH",
"description": "Target service pins github.com/LerianStudio/lib-commons/v4 at v4.2.0. The commons/assert API surface is source-compatible across v4 → v5, but the module path bump requires updating all imports. All recommendations below assume v5 APIs are available. This task MUST complete before any other assert-sweep task lands.",
"files_affected": ["go.mod", "go.sum", "<all Go files importing lib-commons/v4>"],
"acceptance_criteria": [
"go.mod declares github.com/LerianStudio/lib-commons/v5 at latest v5.x tag",
"All imports updated from /v4 to /v5",
"go build ./... passes",
"go test ./... passes"
],
"estimated_complexity": "complex",
"depends_on": [],
"angle": "version",
"replacement_api": "lib-commons/v5"
},
{
"id": "assert-sweep-002",
"title": "Replace panic() in ledger posting with asserter",
"severity": "CRITICAL",
"description": "<as above>",
"files_affected": ["internal/ledger/posting.go:84", "..."],
"acceptance_criteria": ["..."],
"estimated_complexity": "moderate",
"depends_on": ["assert-sweep-001"],
"angle": 1,
"replacement_api": "commons/assert.Asserter + domain predicates"
},
{
"id": "assert-sweep-003",
"title": "Mirror test-only DebitsEqualCredits invariant into production PostTransaction",
"severity": "HIGH",
"description": "Tests assert the double-entry invariant in TestPostTransaction_BalancesEqual, but the production PostTransaction function does not. A caller that produces unbalanced debits will silently corrupt the ledger. Mirror the invariant into production via asserter.That + assert.DebitsEqualCredits so every posting enforces the same check the test does.",
"files_affected": ["internal/ledger/ledger.go:112", "internal/ledger/ledger_test.go:34"],
"acceptance_criteria": [
"PostTransaction calls asserter.That(ctx, assert.DebitsEqualCredits(...), ...) before persisting",
"Existing tests pass unchanged",
"New test: calling PostTransaction with unbalanced debits returns AssertionError and increments assertion_failed_total"
],
"estimated_complexity": "moderate",
"depends_on": ["assert-sweep-001"],
"angle": 5,
"replacement_api": "commons/assert + DebitsEqualCredits"
}
]
Handoff message template (orchestrator surfaces to user after Phase 4):
commons/assert sweep complete. Findings: <N> across <M> of 6 angles.
- CRITICAL: <N> HIGH: <N> MEDIUM: <N> LOW: <N>
Report: /tmp/assert-sweep-report.md
Tasks: /tmp/assert-sweep-tasks.json (<N> tasks)
Next: Invoke ring:dev-cycle with the task file to execute fixes. CRITICAL tasks
(Angle 1 zero-panic violations) and HIGH tasks (Angle 5 test-only invariants, Angle 3
hand-rolled predicates) MUST be addressed before MEDIUM/LOW tiers.
REFERENCE MODE
Sections 1–14 below catalog the commons/assert package (latest v5.x). Resolve the
actual version at runtime via gh api repos/LerianStudio/lib-commons/releases/latest --jq .tag_name.
Read the sections relevant to your current task. Sweep Mode explorers receive extracts from these sections
as context for their angle.
1. API Surface
Full catalog of exported symbols in github.com/LerianStudio/lib-commons/v5/commons/assert.
Constructor
func New(ctx context.Context, logger log.Logger, component, operation string) *Asserter
Creates an asserter scoped to a specific component and operation. component and
operation become metric labels and span-event attributes on every assertion failure.
Asserter methods
func (a *Asserter) That(ctx context.Context, condition bool, msg string, keysAndValues ...any) error
func (a *Asserter) NotNil(ctx context.Context, value any, msg string, keysAndValues ...any) error
func (a *Asserter) NotEmpty(ctx context.Context, s string, msg string, keysAndValues ...any) error
func (a *Asserter) NoError(ctx context.Context, err error, msg string, keysAndValues ...any) error
func (a *Asserter) Never(ctx context.Context, msg string, keysAndValues ...any) error
func (a *Asserter) Halt(err error)
Each method (except Halt) returns error on failure and nil on success. Halt
returns no value — it calls runtime.Goexit() when err != nil.
Bootstrap
func InitAssertionMetrics(factory metrics.Factory)
Registers the assertion_failed_total counter with the provided metrics factory. MUST
be called once during service bootstrap AFTER telemetry is initialized. Without this
call, the log and span-event layers of the trident still fire, but the metric layer
stays silent.
Error types
type AssertionError struct {
Component string
Operation string
Assertion string // method name: "That", "NotNil", "NotEmpty", "NoError", "Never"
Message string
Context map[string]any // keysAndValues flattened into a map
}
func (e *AssertionError) Error() string
func (e *AssertionError) Is(target error) bool
var ErrAssertionFailed = errors.New("assertion failed") // sentinel for errors.Is
Domain predicates
See Section 4 for the full catalog. Signatures summarized:
// Numeric
func Positive(n int64) bool
func NonNegative(n int64) bool
func InRange(n, min, max int64) bool
// Financial
func PositiveDecimal(amount decimal.Decimal) bool
func NonNegativeDecimal(amount decimal.Decimal) bool
func ValidAmount(amount decimal.Decimal) bool
func ValidScale(scale int) bool
func DebitsEqualCredits(debits, credits decimal.Decimal) bool
func NonZeroTotals(debits, credits decimal.Decimal) bool
func BalanceSufficientForRelease(onHold, releaseAmount decimal.Decimal) bool
// Transaction state machine
func ValidTransactionStatus(status string) bool
func TransactionCanTransitionTo(current, target string) bool
func TransactionCanBeReverted(status string, hasParent bool) bool
func TransactionHasOperations(ops []Operation) bool
func TransactionOperationsContain(ops []Operation, allowed []string) bool
// Network / infrastructure
func ValidUUID(s string) bool
func ValidPort(port string) bool
func ValidSSLMode(mode string) bool
// Time
func DateNotInFuture(date time.Time) bool
func DateAfter(date, reference time.Time) bool
2. Asserter Lifecycle
Scoping
One asserter per operation, not per service. Reuse the asserter across all
invariant checks within the same request handler, message consumer callback, or
bounded operation. The component + operation labels are the axis along which
metrics and traces are grouped — making the labels too coarse (component="app")
destroys metric granularity; making them too fine (operation="post-transaction-step-4")
explodes label cardinality.
Context propagation
Pass ctx into both the constructor and every method. The context is used to attach
span events to the active trace, so operators can jump from an assertion-failure
span event straight to the full request waterfall.
Component naming conventions
Lowercase, hyphenated, bounded-context-shaped. Examples:
| Component | Bounded context |
|---|---|
ledger |
Double-entry ledger core |
transaction |
Transaction lifecycle |
auth |
Authentication / authorization |
ingest |
Inbound event ingestion |
outbox-dispatcher |
Transactional outbox dispatcher |
posting-engine |
Balance posting |
webhook-deliverer |
Outbound webhook delivery |
dlq-consumer |
Dead-letter queue consumer |
Operation naming conventions
Action-shaped, lowercase, hyphenated. Examples:
| Operation | What it covers |
|---|---|
post-transaction |
Full posting workflow |
create-account |
Account creation |
release-hold |
Release held funds |
approve-pending |
PENDING → APPROVED transition |
revert-transaction |
Reverse an approved transaction |
deliver-webhook |
Send one outbound webhook |
Anti-pattern: per-service singleton asserter
// DON'T: one asserter for the whole service
var serviceAsserter = assert.New(ctx, logger, "my-service", "all")
// DO: one asserter per operation
func postTransaction(ctx context.Context, ...) error {
a := assert.New(ctx, logger, "ledger", "post-transaction")
// ... invariant checks
}
A per-service singleton collapses all assertion metrics into a single
{component=my-service, operation=all} label pair, destroying the signal dashboards
rely on.
3. Instance Methods — When to Use Each
| Method | When to use | Example |
|---|---|---|
That |
General boolean condition — compose with any predicate | a.That(ctx, assert.DebitsEqualCredits(d, c), "...", ...) |
NotNil |
Reflect-based nil check — catches typed nils stored in any interface |
a.NotNil(ctx, account, "...") |
NotEmpty |
String-only empty check | a.NotEmpty(ctx, tenantID, "...") |
NoError |
Shortcut for err != nil with auto error-type injection |
a.NoError(ctx, dbErr, "...", "query", "SELECT ...") |
Never |
Unreachable branch — exhaustive switch default, impossible sentinel | a.Never(ctx, "impossible status", "status", status) |
Halt |
Goroutine-level halt via runtime.Goexit() |
a.Halt(err) — only halts if err != nil |
That — general condition
if err := a.That(ctx, amount.IsPositive(), "amount must be positive",
"amount", amount.String(), "account_id", accountID); err != nil {
return err
}
Use when composing domain predicates. The condition is evaluated by the caller, so predicates stay pure and the asserter handles observability.
NotNil — typed-nil aware
NotNil uses reflect.ValueOf(v).IsNil() so it catches the common bug of a typed nil
stored in an any interface:
var p *MyStruct // p is typed nil
var v any = p // v is not untyped nil — v == nil is FALSE
// a.NotNil correctly detects v is effectively nil
if err := a.NotNil(ctx, v, "MyStruct required"); err != nil {
return err
}
NotEmpty — strings only
NotEmpty only checks strings. For slices, use That:
if err := a.That(ctx, len(ops) > 0, "operations required"); err != nil {
return err
}
NoError — auto error-type injection
NoError automatically appends the original error's type and message to the context,
so the caller does not need to pass them explicitly:
if err := a.NoError(ctx, dbErr, "database query failed",
"query", "SELECT balance", "account_id", accountID); err != nil {
return err
}
// Context map also includes: "error_type", "<type>", "error", "<dbErr.Error()>"
Never — for unreachable code
Use at exhaustive-switch defaults and impossible-state sentinels:
switch status {
case "APPROVED":
return approvePath(ctx)
case "CANCELED":
return cancelPath(ctx)
case "PENDING":
return pendingPath(ctx)
default:
return a.Never(ctx, "unreachable status",
"status", status, "transaction_id", txnID)
}
Halt — goroutine-level halt
Halt(err) when err != nil calls runtime.Goexit():
- Defers in the current goroutine still run
- Other goroutines are unaffected
- Process does not crash
Use only when continuing this goroutine is unsafe but the rest of the process is
fine (e.g., a background worker whose state is irrecoverable but whose peers are
healthy). runtime.Goexit() is preferred over panic because it respects the
zero-panic policy while still stopping the goroutine.
4. Full Domain Predicate Catalog
Predicates are pure bool-returning functions. Zero observability. Compose with an
asserter via a.That(ctx, predicate, ...) to add the trident.
Numeric (int64)
| Predicate | What it validates | Scenario |
|---|---|---|
Positive(n int64) bool |
n > 0 |
Line counts, retry budgets, TTLs |
NonNegative(n int64) bool |
n >= 0 |
Queue depths, active connection count |
InRange(n, min, max) bool |
min <= n <= max |
Bounded tunables, pagination limits |
Composition:
a := assert.New(ctx, logger, "posting-engine", "allocate-remainder")
if err := a.That(ctx, assert.Positive(entryCount),
"entry count must be positive",
"transaction_id", txnID, "entry_count", entryCount); err != nil {
return err
}
Financial (shopspring/decimal)
| Predicate | What it validates |
|---|---|
PositiveDecimal(amount decimal.Decimal) bool |
amount > 0 |
NonNegativeDecimal(amount decimal.Decimal) bool |
amount >= 0 |
ValidAmount(amount decimal.Decimal) bool |
Exponent in [-18, 18] — within ledger precision |
ValidScale(scale int) bool |
0 <= scale <= 18 |
DebitsEqualCredits(debits, credits) bool |
debits == credits — double-entry invariant |
NonZeroTotals(debits, credits) bool |
Both sides non-zero |
BalanceSufficientForRelease(onHold, release) bool |
onHold >= release — sufficient held funds |
Composition — double-entry enforcement at posting time:
a := assert.New(ctx, logger, "ledger", "post-transaction")
if err := a.That(ctx, assert.DebitsEqualCredits(debits, credits),
"double-entry violation: debits != credits",
"debits", debits.String(),
"credits", credits.String(),
"transaction_id", txnID); err != nil {
return err
}
if err := a.That(ctx, assert.NonZeroTotals(debits, credits),
"double-entry violation: zero-sum posting",
"debits", debits.String(),
"credits", credits.String()); err != nil {
return err
}
Composition — sufficient-balance check on hold release:
a := assert.New(ctx, logger, "posting-engine", "release-hold")
if err := a.That(ctx, assert.BalanceSufficientForRelease(holdAmount, releaseAmount),
"insufficient held funds for release",
"account_id", accountID,
"on_hold", holdAmount.String(),
"release", releaseAmount.String()); err != nil {
return err
}
Transaction state machine
| Predicate | What it validates |
|---|---|
ValidTransactionStatus(status string) bool |
One of CREATED, APPROVED, PENDING, CANCELED, NOTED |
TransactionCanTransitionTo(current, target string) bool |
Transition from current to target is legal |
TransactionCanBeReverted(status string, hasParent bool) |
Only APPROVED transactions without a parent can be reverted |
TransactionHasOperations(ops []Operation) bool |
len(ops) > 0 |
TransactionOperationsContain(ops, allowed []string) bool |
All operation types in ops are members of allowed |
The legal-transition graph (enforced by TransactionCanTransitionTo):
CREATED → APPROVED
CREATED → PENDING
CREATED → CANCELED
PENDING → APPROVED
PENDING → CANCELED
APPROVED → (terminal, except for NOTED reversal under explicit revert)
Composition — status transition at approval time:
a := assert.New(ctx, logger, "transaction", "approve-pending")
if err := a.That(ctx, assert.ValidTransactionStatus(currentStatus),
"unknown transaction status",
"transaction_id", txnID, "status", currentStatus); err != nil {
return err
}
if err := a.That(ctx, assert.TransactionCanTransitionTo(currentStatus, "APPROVED"),
"illegal status transition",
"transaction_id", txnID,
"from", currentStatus, "to", "APPROVED"); err != nil {
return err
}
Composition — revert guard:
a := assert.New(ctx, logger, "transaction", "revert-transaction")
if err := a.That(ctx, assert.TransactionCanBeReverted(status, hasParent),
"transaction cannot be reverted",
"transaction_id", txnID,
"status", status,
"has_parent", hasParent); err != nil {
return err
}
Network / infrastructure
| Predicate | What it validates |
|---|---|
ValidUUID(s string) bool |
Well-formed UUID (v1–v7 accepted) |
ValidPort(port string) bool |
"1" to "65535" as decimal string |
ValidSSLMode(mode string) bool |
PostgreSQL SSL modes |
Composition — guarding configuration at bootstrap:
a := assert.New(ctx, logger, "bootstrap", "parse-config")
if err := a.That(ctx, assert.ValidPort(cfg.Port),
"invalid port in config",
"port", cfg.Port); err != nil {
return err
}
Time
| Predicate | What it validates |
|---|---|
DateNotInFuture(date time.Time) bool |
date <= time.Now() |
DateAfter(date, reference time.Time) bool |
date > reference |
Composition — guard against clock-skew / bad input:
a := assert.New(ctx, logger, "ingest", "accept-event")
if err := a.That(ctx, assert.DateNotInFuture(event.Timestamp),
"event timestamp in the future",
"event_id", event.ID,
"timestamp", event.Timestamp.Format(time.RFC3339)); err != nil {
return err
}
5. Composition Pattern
The division of labor is deliberate and strict:
| Layer | Responsibility | Observability |
|---|---|---|
| Predicates | Pure domain logic — bool return |
None |
| Asserter | Observability trident on failure (log + span + metric) | All |
This separation means:
- Predicates are cheap to test (pure functions, no mocks)
- Predicates are cheap to compose (any Boolean combination)
- Predicates are cheap to share across packages (no dependency on logger/metrics)
- Asserter carries the observability weight — a single call site, three outputs
★ Insight ─────────────────────────────────────
The predicates ARE the business rules. assert.DebitsEqualCredits is not "defensive
code that happens to check the accounting invariant" — it IS the accounting invariant,
expressed as executable code. A codebase that composes canonical predicates with
asserters has its regulatory rulebook encoded as runtime-enforced contracts. A codebase
that hand-rolls predicates has its rulebook encoded as prose-in-code-review, which
drifts the moment someone's pattern is a little different than canon.
─────────────────────────────────────────────────
Canonical composition sites
| Site | Composition |
|---|---|
| Posting engine entry | DebitsEqualCredits + NonZeroTotals + PositiveDecimal per entry |
| Status transition handler | ValidTransactionStatus + TransactionCanTransitionTo |
| Hold release | BalanceSufficientForRelease + PositiveDecimal |
| Revert guard | TransactionCanBeReverted |
| Config bootstrap | ValidPort + ValidSSLMode + ValidUUID (for static IDs) |
| Inbound event acceptance | DateNotInFuture + NotEmpty (ids) + ValidTransactionStatus (payload) |
6. The Observability Trident
Every assertion failure produces three outputs. They are emitted unconditionally when the asserter is correctly initialized — the consumer cannot opt into "log only" or "metric only" without modifying the asserter itself.
Layer 1 — Structured log
ERROR assertion failed: double-entry violation: debits != credits
component=ledger
operation=post-transaction
assertion=That
debits=150.00
credits=149.50
transaction_id=abc-123
Emitted via the log.Logger passed to assert.New. Fields: component, operation,
assertion (method name), message, and all keysAndValues the caller provided.
Layer 2 — OTel span event
Event name: assertion.failed, attached to the active span on the context.
Attributes:
| Attribute | Value |
|---|---|
assertion.type |
Method name (That, NotNil, ...) |
assertion.message |
The message string |
assertion.component |
From assert.New |
assertion.operation |
From assert.New |
All keysAndValues |
As span attributes |
The span's status is NOT automatically set to Error — the asserter only adds the event. If the caller wants to mark the span failed, it must do so explicitly.
Layer 3 — Metric
Counter assertion_failed_total, incremented by 1 on each failure.
Labels:
| Label | Source |
|---|---|
component |
From assert.New |
operation |
From assert.New |
assertion |
Method name (That, NotNil, ...) |
Canonical PromQL for operator dashboards:
# Rate of assertion failures per component
sum by (component) (rate(assertion_failed_total[5m]))
# Top failing operations
topk(10, sum by (component, operation) (rate(assertion_failed_total[1h])))
# Alert: sustained double-entry violations (ledger-scoped)
sum(rate(assertion_failed_total{component="ledger", operation="post-transaction"}[10m])) > 0
★ Insight ─────────────────────────────────────
assertion_failed_total{assertion="DebitsEqualCredits"} is a regulatory-grade metric,
not a debugging metric. A spike is a potential accounting event — auditors will
ask for the trace. The fact that this signal has three redundant channels (log for
grep, span event for trace correlation, metric for alerting) means no operator can
plausibly claim "we didn't know" after a ledger drift. That's the point.
─────────────────────────────────────────────────
Production mode behavior
When runtime.SetProductionMode(true) is active (or ENV=production), the asserter:
- Suppresses stack traces in both the log and the span event
- Keeps all other fields identical
In development mode, stack traces are included to help debugging. The choice is binary — there is no "include N frames" tuning.
7. AssertionError Unwrapping
Every asserter method returns *assert.AssertionError on failure. Error boundaries
MUST unwrap the structural context to preserve observability in logs.
Canonical unwrap pattern
var assertErr *assert.AssertionError
if errors.As(err, &assertErr) {
logger.Error("assertion violated",
"component", assertErr.Component,
"operation", assertErr.Operation,
"assertion", assertErr.Assertion,
"message", assertErr.Message,
"context", assertErr.Context, // map[string]any of keysAndValues
)
}
Sentinel check
if errors.Is(err, assert.ErrAssertionFailed) {
// This is an assertion failure (any type). Use for flow-control
// decisions without needing the structural context.
}
Where to unwrap
| Boundary | What to do |
|---|---|
Fiber ErrorHandler |
Unwrap, log structured, return 500 |
| gRPC unary/stream interceptor | Unwrap, log structured, convert to codes.Internal |
| RabbitMQ consumer callback | Unwrap, log structured, Nack with requeue=false (invariant bug) |
| HTTP middleware error-handler | Unwrap, log structured, do not leak internal invariants to response |
Outer main defer |
Unwrap, log structured, exit non-zero |
HTTP status code mapping
AssertionError → HTTP 500, never 400. Assertion failures represent internal invariant violations, not user-input errors. Returning 400 implies the client made a bad request, which misleads both the caller and the operator triaging the incident.
// WRONG:
if assertErr != nil {
return c.Status(400).JSON(fiber.Map{"error": "bad input"}) // misleading
}
// RIGHT:
if assertErr != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal invariant violated"})
}
The one exception is when the assertion wraps a clearly-user-caused validation (e.g.,
NotEmpty on a user-provided field at a public API boundary) — in that case, validate
at the edge with a normal error path before the invariant check, so the assertion is
reserved for actual invariants.
8. Decision Tree — panic vs assert vs error
Three-way decision every Go engineer makes, every day, in a Lerian codebase:
| Choice | When | Observability |
|---|---|---|
panic |
Never — except regexp.MustCompile with string literal |
Crash |
assert |
Invariant that SHOULD always hold — violation is a bug | Trident |
error |
Expected failure mode — user input, I/O, external system | Normal |
panic — effectively banned
Zero-panic policy. The ONLY accepted panic in Lerian code is regexp.MustCompile(...)
where the argument is a compile-time string literal constant. That specific call is
acceptable because the panic can only fire if the regex literal is malformed at
compile time, which surfaces during development, not in production.
Everything else returns an error. No exceptions. A panic that reaches production
crashes the service and loses the request; the trident explicitly exists so that
production never needs panic to be heard.
assert — for invariants
An invariant is something that the code path's caller/callee contract guarantees will be true. A violation is a bug, not a user error or external failure. When an invariant is violated, the right response is:
- Trident fires (visible to operators)
- Error propagates (callers can decide how to respond)
- Process keeps running (no crash)
error — for expected failures
An expected failure is a normal, anticipated outcome of calling the function:
- User input that does not validate
- External system that returned 503
- Database connection that refused
- Context that was cancelled
Expected failures use normal error returns. They do NOT need the trident because:
- They are not bugs (no operator action needed for each occurrence)
- They are already handled by domain-level metrics (e.g., HTTP error rate, DB error counter)
Worked examples
| Scenario | Choice | Reasoning |
|---|---|---|
ctx.Done() fires during a DB query |
error | Expected — request was cancelled or timeout reached |
Database returns connection refused |
error | Expected external failure |
User POSTs amount = "not a number" |
error | Expected input failure — validate at edge |
DB query returns success but rows is nil |
assert | Invariant — the driver contract says nil is impossible on success |
Transaction status read from own DB is "BANANA" |
assert | Invariant — we wrote it, we control the enum |
| Tenant ID missing from request context after JWT middleware | assert | Invariant — the middleware guarantees it; if missing, middleware has a bug |
| Account balance goes negative after hold release | assert | Invariant — accounting laws |
| RabbitMQ broker down during publish | error | Expected external failure |
regexp.MustCompile("[a-z]+") at package init |
panic | Accepted exception — compile-time constant |
regexp.MustCompile(userProvidedPattern) |
error | NOT an exception — user input; validate and return error |
Impossible default: branch in exhaustive switch over enum |
assert | Invariant via a.Never(ctx, ...) |
| JWT signature invalid | error | Expected failure — auth boundary rejection |
| Double-entry debits != credits at posting time | assert | Invariant — accounting law |
★ Insight ───────────────────────────────────── The question "panic, assert, or error?" has a simple diagnostic: who is responsible when this fires? If a user is responsible (bad input), it's an error. If an operator is responsible (bug, unexpected state), it's an assert. If nobody can act (compile-time wrong regex), it's the one accepted panic. Anything that doesn't fit is an error. ─────────────────────────────────────────────────
9. Testing Patterns
Proving assertions fire correctly is part of the test suite, not a manual exercise.
Inject a test metrics factory
import "github.com/LerianStudio/lib-commons/v5/commons/opentelemetry/metrics"
func TestPostTransaction_FiresAssertionOnUnbalanced(t *testing.T) {
testFactory := metrics.NewTestFactory()
assert.InitAssertionMetrics(testFactory)
a := assert.New(ctx, testLogger, "ledger", "post-transaction")
err := PostTransaction(ctx, a, buildUnbalancedTransaction())
require.Error(t, err)
require.True(t, errors.Is(err, assert.ErrAssertionFailed))
// Verify the counter incremented with the expected labels
require.Equal(t, int64(1), testFactory.CounterValue("assertion_failed_total",
map[string]string{
"component": "ledger",
"operation": "post-transaction",
"assertion": "That",
}))
}
Verify span event via in-memory OTel exporter
import "go.opentelemetry.io/otel/sdk/trace/tracetest"
func TestPostTransaction_EmitsSpanEvent(t *testing.T) {
exporter := tracetest.NewInMemoryExporter()
// ... set up test tracer provider with this exporter
_ = PostTransaction(ctx, a, buildUnbalancedTransaction())
spans := exporter.GetSpans()
require.Len(t, spans, 1)
events := spans[0].Events
require.Len(t, events, 1)
require.Equal(t, "assertion.failed", events[0].Name)
attrs := events[0].Attributes
requireAttr(t, attrs, "assertion.component", "ledger")
requireAttr(t, attrs, "assertion.operation", "post-transaction")
requireAttr(t, attrs, "assertion.type", "That")
}
AssertionError field-equality tests
func TestPostTransaction_ReturnsStructuredAssertionError(t *testing.T) {
err := PostTransaction(ctx, a, buildUnbalancedTransaction())
var assertErr *assert.AssertionError
require.True(t, errors.As(err, &assertErr))
require.Equal(t, "ledger", assertErr.Component)
require.Equal(t, "post-transaction", assertErr.Operation)
require.Equal(t, "That", assertErr.Assertion)
require.Contains(t, assertErr.Message, "double-entry violation")
}
Production-drift mirror check
For every test that uses an assert.<Predicate>, verify the production code under test
ALSO uses the same predicate via an asserter. This is the systematic check for Angle 5:
// For each test hitting a predicate:
func TestPostTransaction_BalancesEqual(t *testing.T) {
txn := buildTransaction(100, 100)
require.True(t, assert.DebitsEqualCredits(txn.Debits(), txn.Credits()))
require.NoError(t, PostTransaction(ctx, a, txn))
}
// The production PostTransaction MUST also call a.That(ctx,
// assert.DebitsEqualCredits(...), ...) — otherwise the invariant is test-only.
One manual or automated cross-reference pass per PR catches the drift that Angle 5 sweeps surface across a whole codebase.
10. Anti-Pattern Catalog
Six anti-patterns with consequences. Each is a one-way door — once in production, the damage compounds.
1. panic() for invariants
// BEFORE:
if amount.IsNegative() {
panic("amount must be positive")
}
Consequence: Crashes the service on an invariant that the asserter would have reported non-fatally. Request is lost. Operators find a stack trace in logs but no metric signal. Zero-panic policy violated.
2. Silent error return on invariant violation
// BEFORE:
if debits.Cmp(credits) != 0 {
return errors.New("unbalanced") // no metric, no trace
}
Consequence: Debit/credit mismatch is returned as a plain error. Log at the
boundary shows "unbalanced" but no assertion_failed_total metric, so dashboards
never alert. A silent, slow-motion ledger drift.
3. Reinvented predicates
// BEFORE:
func debitsEqualCredits(d, c decimal.Decimal) bool {
return d.Cmp(c) == 0
}
Consequence: DIY diverges from canon. The canonical assert.DebitsEqualCredits
may include non-zero-total checks, tolerance handling, or invariant upgrades in
future versions — the DIY version stays frozen. Cross-service behavior inconsistency.
4. Missing InitAssertionMetrics
// Bootstrap:
runtime.InitPanicMetrics(tl.MetricsFactory, logger)
// assert.InitAssertionMetrics missing
a := assert.New(ctx, logger, "ledger", "post")
_ = a.That(ctx, false, "test")
Consequence: Log and span event fire. Metric counter does not exist in the registry, so Prometheus never scrapes it. Dashboards show flat lines even as assertions fire thousands of times per minute during an incident.
5. Assertion only in tests
// Test:
require.True(t, assert.DebitsEqualCredits(d, c))
// Production PostTransaction — no invariant check.
Consequence: Test suite passes. Production code blindly writes whatever debits and credits the caller provides. First upstream bug that produces unbalanced input writes corruption directly to the ledger. Silent drift until the first audit.
6. Opaque AssertionError in boundary logs
// BEFORE — Fiber error handler:
ErrorHandler: func(c *fiber.Ctx, err error) error {
logger.Error("request failed", "error", err.Error())
return c.Status(500).JSON(...)
}
Consequence: Error is stringified. Component, Operation, Assertion labels are
embedded in the string, not as log fields. Operators cannot filter/group logs by these
dimensions. Incident triage requires correlating by timestamp to the span event to
recover the structure.
11. Bootstrap Order
The assert.InitAssertionMetrics call has a specific position in service bootstrap:
- AFTER logger initialization (assertions emit via the logger)
- AFTER telemetry initialization (uses the metrics factory)
- ALONGSIDE
runtime.InitPanicMetrics(both read from the sametl.MetricsFactory) - BEFORE any code path that might fire an assertion (i.e., before any handler / consumer / worker starts)
For the complete bootstrap sequence (logger → telemetry → runtime → assert →
infrastructure clients → server), see ring:using-lib-commons Section 2.
The two-line addition at the right point:
// After opentelemetry.NewTelemetry(...):
runtime.InitPanicMetrics(tl.MetricsFactory, logger)
assert.InitAssertionMetrics(tl.MetricsFactory)
12. Cross-References
This skill does not duplicate material available elsewhere. Use these pointers:
| For | See |
|---|---|
| Full lib-commons package catalog (22 packages) | ring:using-lib-commons Section 1 |
| Full bootstrap sequence with all clients | ring:using-lib-commons Section 2 |
| Observability overview including panic recovery coverage | ring:using-lib-commons Section 5 |
| Single-angle assertions sweep (lower-detail version) | ring:using-lib-commons Angle 16 |
Panic recovery + SafeGo + error reporter integration |
ring:using-runtime |
| Running a full codebase standards sweep | ring:dev-refactor |
| Consuming sweep tasks into a development cycle | ring:dev-cycle |
ring:using-runtime is the natural companion to this skill — runtime protects
against panics that would otherwise silently kill a goroutine; assert protects
against invariant violations that would otherwise silently corrupt state. Together,
they close both halves of the invisible-failure problem in Go services.
13. Cross-Cutting Patterns
Patterns that apply across all commons/assert usage.
Nil-receiver safety
Every exported Asserter method is nil-receiver safe. Calling a method on a nil
*Asserter returns ErrNilAsserter (or equivalent) rather than panicking. This means
code paths that conditionally construct an asserter (e.g., when the logger is optional
in bootstrap) do not need a separate nil check at every call site.
Production mode effects
runtime.SetProductionMode(true) affects the assertion output:
- Stack traces suppressed in logs and span events
- All other fields preserved
- Metric emission unchanged (production mode does not affect counters)
Development mode includes stack traces to aid debugging. The setting is global (per-process) and is normally set once at bootstrap.
Interaction with runtime
Asserter methods return errors. They do NOT panic. Therefore they do NOT trigger
runtime.SafeGo or runtime.RecoverWithPolicy recovery paths. This is intentional:
runtimehandles panics (goroutine-death prevention)asserthandles invariant violations (state-corruption prevention)- The two mechanisms are orthogonal and both are needed
A goroutine launched via runtime.SafeGo that fires an assertion: the assertion
returns an error; the goroutine decides what to do with it (return, log, retry); if the
goroutine then panics separately, SafeGo recovers. No overlap.
Performance
Assertion hot-path cost:
- Success path: predicate evaluation + one
ifcomparison — effectively free - Failure path: one
*AssertionErrorheap allocation + log line + span event + metric increment
Failure is expected to be rare (invariants should hold nearly always), so the allocation on failure is acceptable. The success path has zero allocations — safe to place in tight loops, request handlers, and message consumers.
14. Breaking Changes
v4.x → v5.x (commons/assert)
No API-breaking changes in commons/assert across v4.2.0 → v5.x. All method
signatures, predicate signatures, and error types are source-compatible.
The module path bump from github.com/LerianStudio/lib-commons/v4/... to
github.com/LerianStudio/lib-commons/v5/... applies. See ring:using-lib-commons
Section 15 for the full module-bump migration checklist.
v5.0.1+
Patch releases — no API changes to commons/assert. Check the latest v5.x tag for current patch level.