Two developers reviewing code at a shared workstation while colleagues work in a bright open-plan office.

One Go Error Can Match Two Categories. Which Policy Wins?

Fri, Oct 30, 2026

A Go error tree can make two category predicates true at the same time. In the reference fixture, errors.Is(err, temporary) is true and errors.Is(err, permanent) is also true for the same joined value. The standard library can answer both membership questions, but it does not answer the caller’s next question: which application action wins when categories overlap? Go documents error-tree membership and traversal, while action priority remains a contract authored by the application. The errors package explicitly describes a tree that may contain one or multiple wrapped children and says Is examines that tree for a requested target; it does not define business precedence among targets.

This playbook fixes that ambiguity before implementation review. Its local rule is STOP before CHECK: literal nil is SUCCESS; any presence of the permanent sentinel yields STOP; temporary without permanent yields CHECK_RETRY_PREREQUISITES; and no declared match yields HOLD_UNCLASSIFIED. CHECK_RETRY_PREREQUISITES is deliberately not RETRY. It says only that classification has reached a gate where additional evidence would be required before any later action.

The evidence is a fifteen-case, standard-library-only laboratory prepared for Go 1.27.3 on linux/amd64. It compares errors.Join in both argument orders, an outer %w, multiple %w operands, ordinary single wrapping, erased text-only wrapping and identity controls. The review question throughout is narrow: does the classification layer preserve the causes it promises to expose and implement the declared STOP-before-CHECK policy over the same error tree?

Declare which category wins when both match

Start by writing the application policy before looking at branch order. The fixture owns two independent sentinel categories:

var temporary = errors.New("temporary dependency failure")
var permanent = errors.New("permanent validation failure")

The words “temporary” and “permanent” are labels in this synthetic contract, not universal Go meanings. The caller decides what those categories imply. Here, the contract is explicit: permanent dominates temporary when both are discoverable in one error tree. That makes the classifier’s ordered decision nil → success, permanent → stop, temporary → check prerequisites, otherwise → hold.

That ordering matters because errors.Is is a membership predicate, not a policy engine. The Go 1.27.3 documentation says Is examines an error tree looking for a target and reports whether it finds a match; it also documents preorder depth-first tree traversal. Nothing there assigns a higher operational priority to whichever target a caller happens to test first.

Keep two verdict domains separate. SUCCESS, STOP, CHECK_RETRY_PREREQUISITES and HOLD_UNCLASSIFIED are outcomes for one classified error value. ACCEPT, REPAIR and HOLD, by contrast, are review dispositions for the implementation and its evidence. A classifier can be accepted even though a particular input correctly returns STOP; conversely, a temporary-first implementation can need repair even when its output sounds operationally plausible.

The policy owner should be the application boundary that converts semantic error membership into a caller action. That responsibility fits naturally among broader backend API responsibilities, but this fixture does not assign the rule to the Go runtime or to a generic library. During review, identify the exact caller that turns errors.Is results into an action label. If ownership is unclear, precedence is probably implicit, and implicit precedence is difficult to qualify.

Separate no error, matching evidence and an action

Three layers must stay distinct: absence, membership and action. For this fixture, the contract is compact:

Layer

Question

Evidence

Meaning here

No error

Is the returned interface literally nil?

err == nil

SUCCESS

Membership

Is a declared sentinel discoverable in the tree?

errors.Is(err, target)

Category evidence only

Classification

What should this caller do with those memberships?

Ordered application branches

STOP, CHECK_RETRY_PREREQUISITES, or HOLD_UNCLASSIFIED

The nil row is intentionally ordinary. This article does not reopen typed-nil interface representation. That separate producer-return problem is covered under Go nil error return contracts. Here, the fixture uses ordinary non-nil sentinel errors built with errors.New, standard wrapping, and joined trees. It exercises no custom Is, no As, no AsType, and no custom Unwrap implementation.

A non-nil error also does not automatically belong to either declared category. G15 constructs errors.New("unclassified failure"); it is real failure evidence but matches neither sentinel, so the policy says HOLD_UNCLASSIFIED. The standard library reinforces the identity point: each errors.New call returns a distinct error value even if text is identical.

Keep the nil baseline narrow

G01 is literal nil, so both membership booleans are false and the result is SUCCESS. G14 is errors.Join(nil, nil). The documented Join contract discards nil operands and returns nil when every operand is nil, so G14 reaches the same outcome through a constructor control rather than through direct assignment.

Those two rows establish absence and nothing more. They do not test interface values containing nil pointers, reflection, nil-safe methods or normalization. The article’s actual decision begins when a non-nil tree can preserve more than one matching cause.

Treat CHECK as a pending decision

Write the label exactly as CHECK_RETRY_PREREQUISITES because abbreviation changes the contract. A temporary-only classification says that the permanent stop category was not found and the temporary category was found. It does not establish completion state, idempotency, authorization, retry budget, absence of side effects, safe compensation or any other prerequisite for re-execution.

Accordingly, the fixture neither retries nor simulates retry logic. A reviewer can accept the classification layer while still requiring a separate owner to establish whatever evidence a future action needs. Converting CHECK_RETRY_PREREQUISITES into RETRY would claim more than this laboratory measures.

Pin the toolchain and author the expected ledger

The supplied preparation reference is dated 2026-10-30 and records Go go1.27.3, linux/amd64, status PASS_15_CASES, and source SHA-256 0f4379b5151f92fbf4223d9f6996e0738be2981b77552a8d45c0e8e5384d4c23. Treat those as provenance for that reference run, not as a floating promise about every later toolchain. A fresh run should record its own runtime.Version(), OS, architecture, timestamp and source digest rather than inheriting the old label.

Save the listing as go_error_policy_fixture.go with LF newlines and the final newline, then build and run it as follows:

GOTOOLCHAIN=local GOPROXY=off GOSUMDB=off go build -o go-error-policy go_error_policy_fixture.go
./go-error-policy go_error_policy_fixture.go > go_error_policy_results.json

The source path is passed to the executable so the program can hash the exact bytes it was given. Do not pass the same .go path twice through go run; that would make both occurrences source inputs rather than one source input plus one program argument.

The fixture authors its expected results literally in a []expected before computing observations. That separation is important. Each row fixes expected booleans for temporary membership, permanent membership, direct equality with temporary, the deliberately incomplete single-unwrapping control, the declared classifier, and the temporary-first counterexample. The loop then computes actual fields and asserts equality with those expectations.

Those fields answer different questions. err == temporary tests direct identity at the outer value. singleUnwrapMatch tests a one-cause assumption. errors.Is tests semantic membership over the supported error tree. classify applies application priority. A green result matters only if those distinctions remain intact; collapsing them into one “matched” field would erase the controls that make G03, G05, G09 and G13 useful.

Version pinning also prevents a subtle reporting error: documented behavior and an observed run are not the same kind of evidence. The versioned package pages describe the Go 1.27.3 API contract; the reference JSON records what this exact source observed under one Go 1.27.3 linux/amd64 execution. If a future runtime still documents the same contract but a fixture row changes, the discrepancy deserves investigation rather than automatic trust in either side. Conversely, a green old JSON file does not establish what a modified source or different toolchain will do. Keeping source digest, runtime identity and literal expectations together makes the review reproducible without pretending that a hash certifies semantic correctness.

Run the complete fifteen-case classification fixture

The complete source below is the reproducible core of the review. It uses only the standard library, constructs all errors locally, performs no network operations and executes no retry. The finite inventory is intentionally explicit rather than generated: the reviewer can see every case ID, every expected membership and every expected action in one table.

package main

import (
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "errors"
    "fmt"
    "os"
    "runtime"
    "time"
)

var temporary = errors.New("temporary dependency failure")
var permanent = errors.New("permanent validation failure")

type expected struct {
    ID                                string
    Err                               error
    Temp, Perm, EqualTemp, SingleTemp bool
    Policy, Flawed                    string
}

type evidence struct {
    ID                    string json:"id"
    Text                  string json:"text"
    Temporary             bool   json:"matches_temporary"
    Permanent             bool   json:"matches_permanent"
    EqualTemporary        bool   json:"equals_temporary"
    SingleUnwrapTemporary bool   json:"single_unwrap_matches_temporary"
    Policy                string json:"policy"
    Flawed                string json:"temporary_first_policy"
}

func require(ok bool, message string) {
    if !ok {
        panic("HOLD: " + message)
    }
}

// This is an application-owned policy over the two declared categories.
// It never authorizes or performs a retry.
func classify(err error) string {
    if err == nil {
        return "SUCCESS"
    }
    if errors.Is(err, permanent) {
        return "STOP"
    }
    if errors.Is(err, temporary) {
        return "CHECK_RETRY_PREREQUISITES"
    }
    return "HOLD_UNCLASSIFIED"
}

func temporaryFirst(err error) string {
    if err == nil {
        return "SUCCESS"
    }
    if errors.Is(err, temporary) {
        return "CHECK_RETRY_PREREQUISITES"
    }
    if errors.Is(err, permanent) {
        return "STOP"
    }
    return "HOLD_UNCLASSIFIED"
}

// Deliberately incomplete for errors with Unwrap() []error.
func singleUnwrapMatch(err, target error) bool {
    for err != nil {
        if err == target {
            return true
        }
        err = errors.Unwrap(err)
    }
    return false
}

func main() {
    require(len(os.Args) == 2, "supply the path to this exact source file")
    source, err := os.ReadFile(os.Args[1])
    require(err == nil, "source could not be read")
    sum := sha256.Sum256(source)
    wrapped := fmt.Errorf("operation: %w", temporary)
    erased := fmt.Errorf("operation: %v", temporary)
    require(wrapped.Error() == erased.Error(), "text controls must be identical")
    joined := errors.Join(temporary, permanent)
    require(errors.Unwrap(joined) == nil, "single Unwrap control changed")
    cases := []expected{
        {"G01_nil", nil, false, false, false, false, "SUCCESS", "SUCCESS"},
        {"G02_direct_temporary", temporary, true, false, true, true, "CHECK_RETRY_PREREQUISITES", "CHECK_RETRY_PREREQUISITES"},
        {"G03_wrapped_temporary", wrapped, true, false, false, true, "CHECK_RETRY_PREREQUISITES", "CHECK_RETRY_PREREQUISITES"},
        {"G04_nested_temporary", fmt.Errorf("handler: %w", wrapped), true, false, false, true, "CHECK_RETRY_PREREQUISITES", "CHECK_RETRY_PREREQUISITES"},
        {"G05_erased_chain", erased, false, false, false, false, "HOLD_UNCLASSIFIED", "HOLD_UNCLASSIFIED"},
        {"G06_same_text_new_identity", errors.New(temporary.Error()), false, false, false, false, "HOLD_UNCLASSIFIED", "HOLD_UNCLASSIFIED"},
        {"G07_direct_permanent", permanent, false, true, false, false, "STOP", "STOP"},
        {"G08_wrapped_permanent", fmt.Errorf("operation: %w", permanent), false, true, false, false, "STOP", "STOP"},
        {"G09_join_temporary_permanent", joined, true, true, false, false, "STOP", "CHECK_RETRY_PREREQUISITES"},
        {"G10_join_permanent_temporary", errors.Join(permanent, temporary), true, true, false, false, "STOP", "CHECK_RETRY_PREREQUISITES"},
        {"G11_wrapped_join", fmt.Errorf("handler: %w", joined), true, true, false, false, "STOP", "CHECK_RETRY_PREREQUISITES"},
        {"G12_multiple_w", fmt.Errorf("read: %w; validate: %w", temporary, permanent), true, true, false, false, "STOP", "CHECK_RETRY_PREREQUISITES"},
        {"G13_join_single_temporary", errors.Join(nil, temporary), true, false, false, false, "CHECK_RETRY_PREREQUISITES", "CHECK_RETRY_PREREQUISITES"},
        {"G14_join_all_nil", errors.Join(nil, nil), false, false, false, false, "SUCCESS", "SUCCESS"},
        {"G15_unknown", errors.New("unclassified failure"), false, false, false, false, "HOLD_UNCLASSIFIED", "HOLD_UNCLASSIFIED"},
    }
    rows := make([]evidence, 0, len(cases))
    ids := make(map[string]bool)
    for , c := range cases {
        require(!ids[c.ID], "duplicate case ID")
        ids[c.ID] = true
        message := ""
        if c.Err != nil {
            message = c.Err.Error()
        }
        r := evidence{c.ID, message, errors.Is(c.Err, temporary), errors.Is(c.Err, permanent), c.Err == temporary, singleUnwrapMatch(c.Err, temporary), classify(c.Err), temporaryFirst(c.Err)}
        require(r.Temporary == c.Temp && r.Permanent == c.Perm, c.ID+" category mismatch")
        require(r.EqualTemporary == c.EqualTemp && r.SingleUnwrapTemporary == c.SingleTemp, c.ID+" comparison control mismatch")
        require(r.Policy == c.Policy && r.Flawed == c.Flawed, c.ID+" policy mismatch")
        rows = append(rows, r)
    }
    require(len(rows) == 15, "incomplete case ledger")
    result := map[string]any{
        "status": "PASS15_CASES", "checked_at": time.Now().UTC().Format(time.RFC3339),
        "go": runtime.Version(), "os": runtime.GOOS, "arch": runtime.GOARCH,
        "source_sha256": hex.EncodeToString(sum[:]), "cases": rows,
        "same_text_controls": []string{"G03_wrapped_temporary", "G05_erased_chain"},
        "scope":              "Owned standard-library errors; no custom Is/As methods, typed nil, network calls, or retry execution.",
    }
    encoder := json.NewEncoder(os.Stdout)
    encoder.SetIndent("", "  ")
    require(encoder.Encode(result) == nil, "evidence could not be written")
}

The source-path argument exists only to bind the emitted digest to the source bytes the executable reads. That digest is useful for change detection, but it does not prove semantic correctness and it does not identify the executable binary. Build provenance still belongs to the normal build process.

The JSON rows carry both category booleans and both policy outputs. Assertions ensure that missing IDs, duplicate IDs, changed category outcomes or changed decision outcomes fail loudly rather than silently producing a plausible-looking report. The same_text_controls field identifies G03 and G05 so a reader can inspect the text-equality counterexample without mistaking it for category equality.

The supplied preparation run reports the following compact interpretation ledger. It is reference evidence from the stated 2026-10-30 Go 1.27.3 run, not a newly executed observation in this article.

Case

Constructed value

Temporary / permanent

Declared policy

Crucial control

G01

nil

false / false

SUCCESS

Absence is explicit

G02

Direct temporary sentinel

true / false

CHECK_RETRY_PREREQUISITES

Equality and single unwrapping find it

G03

One %w wrapper

true / false

CHECK_RETRY_PREREQUISITES

Direct equality fails; membership survives

G04

Nested %w wrappers

true / false

CHECK_RETRY_PREREQUISITES

Chain traversal survives

G05

Matching text through %v

false / false

HOLD_UNCLASSIFIED

Same text as G03, different evidence

G06

New error with identical sentinel text

false / false

HOLD_UNCLASSIFIED

New identity is not the original sentinel

G07

Direct permanent sentinel

false / true

STOP

Permanent baseline

G08

Wrapped permanent sentinel

false / true

STOP

Preserved cause reaches caller

G09

Join(temporary, permanent)

true / true

STOP

Temporary-first control selects CHECK

G10

Join(permanent, temporary)

true / true

STOP

Reversing children does not change policy conflict

G11

%w around joined value

true / true

STOP

Single-cause loop still misses children

G12

fmt.Errorf with two %w operands

true / true

STOP

Same overlap through a second constructor

G13

Join(nil, temporary)

true / false

CHECK_RETRY_PREREQUISITES

Single-cause loop misses joined child

G14

Join(nil, nil)

false / false

SUCCESS

All-nil operands return nil

G15

Unclassified error

false / false

HOLD_UNCLASSIFIED

Do not guess from failure presence

The recorded PASS_15_CASES means the observations matched this authored oracle, including four deliberately deficient temporary-first outputs. It does not certify a deployment, a retry path or every possible Go error graph. The fixture explicitly excludes custom matching methods, cycles, typed nil and arbitrary untrusted structures.

Make the overlapping memberships visible

G09 is the central row. The same error value satisfies errors.Is(err, temporary) == true and errors.Is(err, permanent) == true. Under the declared policy, the action is STOP; under temporaryFirst, it is CHECK_RETRY_PREREQUISITES. Nothing about either successful membership test resolves that conflict. The branch order does.

That is the operational distinction reviewers should preserve: membership is descriptive evidence about the tree; precedence is normative application policy. Go’s documentation says Is looks for the target supplied to that call. Running it twice with two different targets can therefore produce two true answers on a multiply wrapped error.

The passing fixture intentionally retains the wrong temporary-first result as an expected diagnostic control. A green status therefore cannot be summarized as “both classifiers are correct.” It means the harness observed the contrast it was designed to observe.

Reverse the Join operands without changing the contract

G10 reverses the constructor to errors.Join(permanent, temporary). Both memberships remain true and the declared result remains STOP. The standard Join result wraps the provided non-nil errors, and Is inspects multiply wrapped error trees.

That makes child ordering a poor substitute for caller policy. Reordering operands may affect representation details or traversal order, but the application contract here is not “trust whichever category appears first in the tree.” Each errors.Is call asks about one explicit target, and the caller decides what to do if both answers are true.

Follow the same categories through outer and multiple wrappers

G11 wraps the joined error once more: fmt.Errorf("handler: %w", joined). A single %w preserves the wrapped error through an Unwrap() error relationship, so the outer context does not erase the joined children. G11 therefore retains temporary=true, permanent=true, and the declared STOP outcome.

G12 reaches the same category overlap through another standard constructor: fmt.Errorf("read: %w; validate: %w", temporary, permanent). The Go 1.27.3 fmt reference states that multiple %w verbs produce an error with Unwrap() []error containing the error operands in argument order. The errors package then supplies semantic tree membership for Is.

Historically, support for wrapping multiple errors with errors.Join and multiple %w operands arrived in Go 1.20; this is established behavior, not a 2026 novelty. The review point is current policy qualification: two constructors preserve the same pair of categories, and the caller still owes an explicit precedence rule.

Do not turn exact error strings or unexported concrete wrapper names into the oracle. Those are representation details. The contract under review is preserved category membership plus the application’s ordered action.

Expose the blind spot in single-cause unwrapping

The fixture’s singleUnwrapMatch helper is deliberately incomplete. It repeatedly compares the current error by equality and then calls errors.Unwrap(err). That pattern works for G02–G04 because those paths are direct or single-cause wrappers. It fails for joined values because errors.Unwrap has a narrower method-shape contract than errors.Is.

The Go 1.27.3 documentation is explicit: errors.Unwrap calls only a method of the form Unwrap() error; in particular, it does not unwrap errors returned by Join. G09 therefore has single_unwrap_matches_temporary=false even though errors.Is(err, temporary) is true. G13 does the same with errors.Join(nil, temporary): the joined value contains a discoverable temporary child, yet the one-cause loop misses it. G11 shows that adding an outer single %w does not rescue the algorithm; one unwrap reaches the join node, and the next errors.Unwrap returns nil.

That failure is a control against a common assumption, not an invitation to write a bespoke graph walker. Manual traversal, custom Unwrap implementations, cycle defenses and third-party aggregation semantics are out of scope. For these standard-library values, errors.Is already owns the membership question.

A nil Unwrap result does not certify an empty error tree

The fixture asserts errors.Unwrap(joined) == nil and then separately observes successful errors.Is matches for both joined children. There is no contradiction. The two functions operate over different documented interfaces: Unwrap only calls Unwrap() error, while the error tree recognized by Is includes both Unwrap() error and Unwrap() []error children.

So “errors.Unwrap returned nil” is not equivalent to “this error has no discoverable causes.” It means only that the error did not expose a single-cause Unwrap() error result to that function. Preserve that precise boundary; broader wording would teach the wrong inference.

Separate readable text from preserved cause identity

G03 and G05 deliberately render the same text. G03 uses %w; G05 uses %v. The source asserts wrapped.Error() == erased.Error(), yet their semantic evidence differs: G03 matches the temporary sentinel and G05 does not. The fmt documentation explains the mechanism: %w with an error operand creates an unwrap relationship, while %w is otherwise formatted like %v.

G06 adds a second identity control. errors.New(temporary.Error()) has exactly the sentinel’s text but is a distinct error value. The errors.New contract says each call returns a distinct error even when the text is identical. As a result, G06 matches neither declared sentinel and remains HOLD_UNCLASSIFIED.

These rows separate diagnostics from machine-readable cause identity. String equality can help a person recognize similar messages, but it cannot reconstruct this sentinel contract. Once a boundary has erased the cause and retained only text, the classifier should not invent identity from the message.

That does not make every %v boundary defective. Go’s error-wrapping guidance says the choice to wrap is an API decision: wrapping exposes the underlying error to callers and can commit the API to that observable cause, while declining to wrap can intentionally preserve abstraction. The defect exists only where the reviewed boundary promised to preserve a cause needed by this policy.

Reconcile all fifteen rows with the declared policy

A complete review should reconcile every case without turning all fifteen into “unsafe outcomes.” Most rows are controls. The four mixed-category rows are the only places where the permanent-first and temporary-first classifiers disagree.

Case

Temp

Perm

Equality / single-unwrap temp

Declared policy

Temporary-first

Review meaning

G01

F

F

F / F

SUCCESS

SUCCESS

Literal nil baseline

G02

T

F

T / T

CHECK_RETRY_PREREQUISITES

same

Direct temporary

G03

T

F

F / T

CHECK_RETRY_PREREQUISITES

same

%w preserves identity

G04

T

F

F / T

CHECK_RETRY_PREREQUISITES

same

Nested single wrappers

G05

F

F

F / F

HOLD_UNCLASSIFIED

same

%v erased chain

G06

F

F

F / F

HOLD_UNCLASSIFIED

same

Same text, new identity

G07

F

T

F / F

STOP

STOP

Direct permanent

G08

F

T

F / F

STOP

STOP

Wrapped permanent

G09

T

T

F / F

STOP

CHECK_RETRY_PREREQUISITES

Joined overlap

G10

T

T

F / F

STOP

CHECK_RETRY_PREREQUISITES

Reversed joined overlap

G11

T

T

F / F

STOP

CHECK_RETRY_PREREQUISITES

Outer %w over overlap

G12

T

T

F / F

STOP

CHECK_RETRY_PREREQUISITES

Multiple %w overlap

G13

T

F

F / F

CHECK_RETRY_PREREQUISITES

same

Joined single temporary

G14

F

F

F / F

SUCCESS

SUCCESS

All-nil join

G15

F

F

F / F

HOLD_UNCLASSIFIED

same

Unknown error

The rows line up with three documented mechanisms. Single %w preserves a cause; multiple %w preserves multiple operands; and errors.Is examines the resulting error tree. The reference run then supplies the observed booleans and policy labels for these particular constructions.

There is no source/result discrepancy to smooth over in the supplied ledger: the assertions are defined to fail if those observed fields diverge from the literal expectations. But that does not mean any future divergence should be “fixed” by weakening expectations. If a later toolchain, source edit or contract change produces a disagreement, report HOLD for review until the cause is understood and the oracle is deliberately re-authored.

The four bold rows are especially valuable because they make the policy defect measurable rather than rhetorical. temporaryFirst is allowed to run against the exact same independently constructed values as classify; there is no alternative fixture, no reordered dataset and no separate message parser helping one candidate. The only relevant implementation difference is branch priority. That isolates the review claim: when both memberships are true, this application’s declared contract requires permanent-first evaluation. The test harness passes because it expects the counterexample to be wrong in exactly those four rows.

Keep classification approval separate from the operation

An accepted classifier is one whose implementation matches the reviewed policy and whose evidence is complete for the tested scope. That status can coexist with a row whose operational label is STOP or HOLD_UNCLASSIFIED. A passing expected rejection is good test evidence.

Conversely, PASS_15_CASES does not approve a retry, validate a request payload or prove whether some remote action completed. It only confirms that the reference fixture observed its authored classification and diagnostic controls. Keep implementation approval, error classification and operation outcome in separate columns during review.

Preserve causes only at boundaries that promise them

Cause preservation is an API commitment, not a mechanical cleanup rule. The Go 1.13 guidance explains that wrapping with %w exposes an underlying error to callers and may make that error part of the API contract; using non-wrapping formatting can intentionally hide an implementation detail. That tradeoff matters before changing any adapter merely because G05 fails the temporary membership test.

For this classifier, first identify the boundary that is supposed to expose the temporary or permanent category. If the contract says callers may rely on those sentinels, repair that specific boundary so the cause remains machine-discoverable while retaining useful context. A focused %v→%w change may be correct there. If the boundary intentionally hides an implementation error, exposing it globally could create a new compatibility obligation instead.

This is the same maintenance discipline that applies when preserving a bounded-read failure: preserve the evidence a boundary has promised, but do not conflate that with unrelated policy. The byte-limit and read-error experiment on that page is separate from this joined-error precedence decision.

Review the public contract, not just the log text. G03 and G05 prove that identical diagnostics can carry different machine-readable evidence. If callers need category identity, the boundary must preserve category identity. If callers are intentionally denied that identity, the classifier should stop claiming it can recover it.

A blanket conversion of every %v to %w is therefore too broad. It can expose dependency-specific errors that were previously private. The repair target is the smallest adapter whose documented contract actually promises the sentinel category needed by this caller.

Repair the policy without reconstructing lost identities

The classification repair is small because the contract is small. Check the permanent category first, then the temporary category, and retain an explicit fallback:

func classify(err error) string {
    if err == nil {
        return "SUCCESS"
    }
    if errors.Is(err, permanent) {
        return "STOP"
    }
    if errors.Is(err, temporary) {
        return "CHECK_RETRY_PREREQUISITES"
    }
    return "HOLD_UNCLASSIFIED"
}

Keep G09–G12 as regression controls. They are not redundant variants: together they show overlap under both Join argument orders, under an outer single %w, and under multiple %w operands. Removing those rows would make it easier for a later refactor to preserve single-category behavior while silently reintroducing temporary-first precedence.

Do not “repair” G05 or G06 by comparing Error() strings. In G05 the cause has been intentionally erased by %v; in G06 the message was independently reproduced by a distinct sentinel value. Neither row supplies the identity required by this contract. The safe result is therefore HOLD_UNCLASSIFIED unless an authoritative upstream record supplies the original structured category.

That historical recovery, if it exists, belongs outside this classifier. A log line by itself is not authoritative cause identity. The classifier also does not reverse side effects, correct business records, infer whether an earlier operation completed or execute a retry. Its repair is limited to the decision function and the cause-preserving boundary contract.

If the organization later changes the policy (for example, if some permanent category should no longer dominate temporary), that is not a bug fix to this implementation. It is a contract change that requires a newly authored oracle and a new review disposition.

Issue a bounded accept, repair or hold decision

Operational review becomes clearer when evidence, implementation disposition and operation label are separated:

Situation

Evidence

Implementation disposition

Operation label

Owner

Mixed tree matches temporary and permanent; classifier checks permanent first

Complete G09–G12 evidence and unchanged policy

ACCEPT classifier

STOP

Caller policy owner

Same tree; classifier checks temporary first

Four mixed rows produce CHECK instead of required STOP

REPAIR classifier

Do not reinterpret; expected label is STOP

Caller maintainer

Source digest, runtime identity or case ledger is missing

Provenance incomplete

HOLD review

Unknown from missing evidence

Reviewer/build owner

Cause was erased before classification

Membership evidence absent

HOLD or repair promised boundary

HOLD_UNCLASSIFIED

Boundary owner

Application category contract changed

Old oracle no longer authoritative

HOLD, then re-author expectations

Depends on new contract

Product/application owner

This matrix is intentionally bounded. Classification does not perform request admission, mutation control or payload validation. Those concerns can be reviewed separately. For example, complete JSON request admission establishes a different boundary before mutation. Linking the two reviews may be useful operationally, but one does not substitute for the other.

A missing result is not permission to guess. If the source identity differs, the toolchain differs, a required row is absent or an observed membership conflicts with its authored expectation, hold classifier acceptance until the discrepancy is explained. Preserve negative controls rather than editing them to make the current implementation appear green.

Require the evidence that the next action actually needs

Classifier acceptance requires the complete case ledger, the source identity, the recorded runtime/toolchain identity and the unchanged category-priority contract. For the local question, that is enough to decide whether the implementation follows STOP-before-CHECK.

A later retry review would need additional evidence that this fixture does not provide. Completion state and idempotency are obvious examples, but they are only examples; the relevant prerequisites depend on the operation. Their absence here is why CHECK_RETRY_PREREQUISITES remains a pending classification label rather than permission to re-execute anything.

Do not solve that missing layer by inventing a generic retry algorithm inside the classifier. Its job is to preserve and reconcile the category evidence it actually owns.

Assign ownership when libraries and callers evolve

Multi-error maintenance crosses three ownership lines. Library authors own what causes their API intentionally exposes. Callers own the precedence that converts those exposed categories into actions. Reviewers own reconciliation of source, oracle and observed results against the declared contract.

That division should survive routine evolution. Requalify the boundary if a library changes from %v to %w, adds or removes aggregation, changes documented sentinel categories, or otherwise changes what callers can discover. Requalify the caller if the application changes precedence. Re-run and relabel the evidence if the source or Go toolchain changes, rather than carrying forward a stale PASS_15_CASES marker.

The Go 1.20 release notes are useful historical context here: multiple-error wrapping via errors.Join and multiple %w has been supported since Go 1.20. The maintenance task in 2026 is not adoption theater; it is making the caller’s policy explicit over an established tree model.

Keep adjacent ownership topics separate. Go buffer payload ownership concerns a different boundary and should not be folded into this error experiment merely because both involve review discipline.

Finally, distinguish rollback of a classifier change from reversal of an operation. If a new classifier violates STOP-before-CHECK, reverting that code can restore the reviewed decision function. It cannot retroactively undo an external operation that may already have occurred. This fixture measures classification only, so it makes no claim about side-effect rollback or distributed execution.

Build stronger backend review habits with Refonte Learning

The practical lesson is review discipline: define an API contract, preserve the evidence that contract promises, write negative controls, and keep diagnostic text separate from machine-readable identity. Those habits transfer beyond Go even though this fixture is intentionally Go-specific.

Refonte Learning’s Backend Development program currently describes a three-month curriculum with a 10–12 hour/week commitment, covering RESTful APIs and microservices, testing and debugging, databases, authentication/authorization, Docker/cloud deployment and a capstone. It lists basic programming knowledge as a requirement and separately states that applicants must be working toward a bachelor’s or higher-level degree. The page frames Backend Developer, API Developer and Database Administrator as career directions rather than guaranteed outcomes.

The published curriculum names Node.js and Express, not this errors.Join laboratory, so do not infer Go-specific instruction from the program page. What is directly relevant is the emphasis on API work, testing/debugging and practical project foundations.

A concrete next step is smaller than a migration project: pick one caller that classifies wrapped errors, write down what should happen when two categories match, and retain a case ledger that proves the implementation follows that declared order. That habit is useful whether the code is Go today or another backend stack tomorrow.