About this document
This is the canonical design specification for the Isopace core — the
Message model, Schema, the per-field wire engine, the marshal/unmarshal
Codec, struct binding, validation, and alternate renderings. It is rendered
verbatim from
ARCHITECTURE.md
in the repository, so it always matches the source of truth.
New to Isopace? Start with the Getting Started guide, then come back here for the design rationale. The per-package API reference lives on pkg.go.dev.
Isopace Core Architecture¶
Module
github.com/teqpace-services/isopace· Go 1.26 · clean-room, idiomatic Go · dual-licensed AGPLv3 + commercial · © Teqpace Services Ltd.
This document is the implementation-ready specification for the Isopace core: the ISO-8583 in-memory model (Message), the schema (Schema/FieldDef), the per-field wire engine (FieldCodec + LengthCodec registry), the message marshal/unmarshal engine (Codec), struct-binding, validation, and alternate renderings (JSON/protobuf/ISO 20022). It is the single source of truth for implementing iso8583/, fieldcodec/, lengthcodec/, packager/, and render/.
The network/switch layers (Link, Listener, Switch, Runtime, Flow/Stage, Exchange, Space, Vault) reference these types but are out of scope here.
This synthesis is built around four hard requirements distilled from the design review:
- Ergonomics first (the 90% developer): a discoverable, hard-to-misuse dual API — both a dynamic map-style surface and typed struct-binding — over one uniform
FieldPathgrammar ("55.9F26") used identically in the dynamic API, struct tags, validation errors, and JSON keys. - Allocation-free hot path: zero-copy lazy decode (fields as sub-slices of the source buffer), array-indexed field slots (no map lookup),
Value.Bytes()as the zero-copy primitive, append-styleMarshal(dst)that reuses the caller's buffer, and a store-and-forward fast path that returns the original wire verbatim when nothing is dirty. - Structural correctness invariants: the bitmap is always derived from present fields at marshal time (desync is impossible);
Messageis immutable with copy-on-write;Validate()is exhaustive (returns all violations); theSchemaand struct-binding tags are validated at build/init time. - Orthogonal, multi-format extensibility:
LengthCodec×FieldCodeccompose freely (≈6 length × ≈12 value codecs, no combinatorialIF*zoo); a single read-onlyViewfeeds every renderer; profiles are data withDerive/Overrideoverlays.
Table of Contents¶
- 1. Package Layout
- 2. Core Types & Interfaces
- 3. The Dual Field API
- 4. Zero-Copy Decode
- 5. Schema & Validation Model
- 6. FieldCodec / LengthCodec Registry & Catalog
- 7. Packager Profiles
- 8. Alternate Renderings (View)
- 9. How This Beats jPOS
1. Package Layout¶
github.com/teqpace-services/isopace/
├── iso8583/ # THE CORE public API (depends only on stdlib + internal/)
│ ├── message.go # Message, slot, copy-on-write, Seal/Clone
│ ├── value.go # Value (zero-copy view), Kind, typed getters
│ ├── decimal.go # Decimal/Amount fixed-point money (never float64)
│ ├── bitmap.go # Bitmap (primary/secondary/tertiary), popcount, Range
│ ├── path.go # FieldPath grammar ("55.9F26"), parse/format/cache
│ ├── schema.go # Schema, FieldDef, SchemaBuilder, build-time validation
│ ├── codec.go # Codec engine: Unmarshal (lazy) / Marshal (append) / Validate
│ ├── view.go # View interface (read-only projection for renderers)
│ ├── bind.go # Binder[T] struct-binding, cached plan, init-time tag check
│ ├── validate.go # Validator interface + built-in rule combinators
│ └── errors.go # FieldError, Violation, ValidationError aggregate
│
├── fieldcodec/ # the pluggable VALUE FieldCodec catalog
│ ├── registry.go # Registry, FieldCodec, SpanCodec, DefaultRegistry()
│ ├── char.go # ASCII, EBCDIC037, EBCDIC1047, UTF8, BINARY
│ ├── numeric.go # NumASCII, BCD (packed), RBCD, NumBinary
│ ├── amount.go # AmountASCII, AmountBCD, AmountBinary
│ ├── bitmap.go # BitmapBinary, BitmapHex, BitmapEBCDIC
│ └── tlv/ # BER-TLV (EMV field 55) composite sub-codec
│ └── bertlv.go
│
├── lengthcodec/ # the pluggable LENGTH-PREFIX catalog (orthogonal)
│ └── length.go # Fixed, LLVar/LLLVar/LLLLVar × ASCII/BCD/Binary/EBCDIC
│
├── packager/ # named PROFILES = assembled immutable Schemas
│ ├── packager.go # profile registry, Derive/Override helpers
│ ├── iso87.go # ISO 8583:1987 A / B / C
│ ├── iso93.go # ISO 8583:1993 A / B
│ ├── visa.go # Visa Base I (overlay on iso93)
│ ├── mastercard.go # Mastercard (overlay)
│ └── generic.go # YAML/JSON schema loader (codec-by-name)
│
├── render/ # alternate renderings of the SAME Message (consume View)
│ ├── jsonio/ # MarshalJSON / UnmarshalJSON (schema-aware)
│ ├── protobuf/ # descriptor-driven (un)marshal
│ └── iso20022/ # ISO 20022 bridge (pacs/pain/camt)
│
├── schemadef/ # declarative schema sources (embedded via go:embed)
│ ├── iso87a.yaml ...
│
└── internal/
├── ebcdic/ # CP037 / CP1047 256-byte translation tables
└── bcd/ # packed-decimal nibble pack/unpack primitives
Layering rule (one direction only):
render/ ─┐
├─► iso8583/ (core; stdlib + internal only)
packager/ ┤ ▲
└─► fieldcodec/ ─┐
lengthcodec/ ┴─► iso8583/ (interfaces only)
iso8583/(the model + engine + schema + interfaces) depends on nothing in the module exceptinternal/. This is the clean model vs schema vs wire codec separation: aMessageknows the ISO-8583 wire format only through the*Schemait references; the model itself is wire-agnostic and renders to any format.fieldcodec/andlengthcodec/implement interfaces declared iniso8583/and are populated into aRegistryby an explicit builder (DefaultRegistry()), not byinit()side effects — no hidden global state, no import-ordering surprises.render/*consumes only the read-onlyViewinterface, so adding a format never touches the core or any codec.
2. Core Types & Interfaces¶
2.1 FieldPath — one uniform addressing grammar¶
A single grammar addresses a field at any depth: plain DEs (2), ISO subfields (48.2.1), and EMV BER-TLV tags inside DE 55 (55.9F26). The same grammar is used by the dynamic API, struct tags, validation errors, and JSON keys. Numeric elements are decimal; BER-TLV elements are hex tags (matched case-insensitively, canonicalised to uppercase).
package iso8583
// FieldPath addresses a field at any depth. It is an immutable value type and is
// comparable (usable as a map key) because PathElem is comparable.
//
// ParsePath("2") -> [{N:2}]
// ParsePath("48.2.1") -> [{N:48} {N:2} {N:1}]
// ParsePath("55.9F26") -> [{N:55} {Tag:"9F26"}]
type FieldPath struct {
elems [maxPathDepth]PathElem // small inline array; no heap for typical depths
n uint8
}
// PathElem is one step: either a numeric index (N>=0, Tag=="") or a hex BER-TLV
// tag (Tag!="", N==-1). Exactly one is set.
type PathElem struct {
N int32 // numeric DE / subfield index, or -1 when Tag is used
Tag string // BER-TLV tag in canonical uppercase hex, e.g. "9F26"; "" otherwise
}
const maxPathDepth = 6
// DE constructs a top-level numeric path: DE(2) == ParsePath("2").
func DE(n int) FieldPath
// ParsePath parses the textual grammar. A small LRU cache memoises hot literals
// so repeated ParsePath("55.9F26") in routing code does not re-allocate.
func ParsePath(s string) (FieldPath, error)
func MustPath(s string) FieldPath // panics on error; for package-level constants
func (p FieldPath) Len() int
func (p FieldPath) At(i int) PathElem
func (p FieldPath) Head() PathElem // first element
func (p FieldPath) Tail() FieldPath // path without the first element
func (p FieldPath) Push(e PathElem) FieldPath
func (p FieldPath) String() string // canonical "55.9F26"
// Pather is satisfied by FieldPath, int, and string so the dynamic API accepts
// any of them WITHOUT boxing an int through `any`: the int overloads call DE(n)
// directly and string overloads call ParsePath. See §3.1 for the call sites.
type Pather interface{ ~int | ~string | path() }
Design note (judges): Design A's
path anyboxed every hot-path call and lost compile-time safety. We replace it with a typedFieldPathplus thinDE(int)/ParsePath(string)constructors and overloaded entry points (Get/GetP/GetSbelow), recovering type safety and avoidingintboxing while keeping the "just pass2or"55.9F26"" ergonomics.
2.2 Value — the zero-copy decoded view¶
Value is a view into the owning Message's source buffer plus a tag describing interpretation. It carries no heap allocation in the common path: raw aliases the message's src. Decoding to a Go string/int64/Decimal is lazy and explicit.
package iso8583
type Kind uint8
const (
KindInvalid Kind = iota
KindString // text (charset applied lazily on String())
KindNumeric // digit string
KindBytes // raw octets
KindAmount // signed scaled money -> Decimal
KindComposite // has children (subfields / BER-TLV)
)
// Value is a zero-copy view into the source buffer. Copying a Value is cheap
// (it is a small header); it never owns or mutates the underlying bytes.
type Value struct {
raw []byte // sub-slice of the owning Message.src (or owned bytes if built)
kind Kind
codec fieldcodec.FieldCodec // how to interpret raw on demand
off int // absolute byte offset in src (for error reporting)
sub *Message // non-nil for KindComposite (lazy child message)
}
func (v Value) Kind() Kind { return v.kind }
func (v Value) IsZero() bool { return v.kind == KindInvalid }
// Bytes returns the raw view. ZERO allocation, ZERO copy. The returned slice
// ALIASES the source buffer and MUST NOT be mutated. This is the documented
// zero-copy primitive: hot-path routing should compare with bytes.Equal /
// bytes.HasPrefix against Bytes(), never String().
func (v Value) Bytes() []byte { return v.raw }
// BytesCopy returns an owned copy, safe to retain and mutate.
func (v Value) BytesCopy() []byte
// String decodes text lazily. NOTE: Go strings are immutable and cannot alias a
// []byte without a copy, so this is the single unavoidable allocation for text
// (plus transcoding for EBCDIC). Prefer Bytes() on the hot path.
func (v Value) String() (string, error)
// Int / Uint parse digits straight off raw without an intermediate string,
// returning a *FieldError with the byte offset on malformed input.
func (v Value) Int() (int64, error)
func (v Value) Uint() (uint64, error)
// Decimal decodes an amount field to exact fixed-point money (never float64).
func (v Value) Decimal() (Decimal, error)
// Composite returns the lazily-decoded child message for KindComposite fields
// (subfield groups or BER-TLV), enabling nested addressing.
func (v Value) Composite() (*Message, bool)
2.3 Decimal / Amount — mandatory fixed-point money¶
No float64 ever touches an amount. Amount adds currency context for cross-field validation (DE 49/51 vs amount fields).
package iso8583
// Decimal is an exact base-10 fixed-point number: value = Unscaled * 10^-Scale.
type Decimal struct {
Unscaled int64
Scale uint8
}
func NewDecimal(unscaled int64, scale uint8) Decimal
func (d Decimal) String() string // "10.99"
func (d Decimal) Rescale(scale uint8) (Decimal, error)
func (d Decimal) Add(o Decimal) (Decimal, error) // exact; error on scale/overflow
// Amount is a Decimal with an ISO 4217 currency (numeric, e.g. "840").
type Amount struct {
Decimal
Currency string
}
2.4 Bitmap — primary / secondary / tertiary, always derived¶
A fixed 192-bit (3×64) set. Presence test is a single bit op — the per-message hot operation. The continuation bits (1, 65) are managed entirely by the engine, and the wire bitmap is always derived from the set of present fields at marshal time — application code never sets bitmap bits, so bitmap/content desync (a real jPOS footgun) is structurally impossible.
package iso8583
// Bitmap is a 192-bit presence set. Field n in 1..192 maps to words[(n-1)/64],
// bit (n-1)%64 counted from the MSB (big-endian bit order on the wire).
type Bitmap struct {
words [3]uint64
}
func (b Bitmap) IsSet(de int) bool
func (b *Bitmap) Set(de int) // internal/engine use; app code never calls this
func (b *Bitmap) Clear(de int)
func (b Bitmap) Count() int // popcount, excluding continuation bits
func (b Bitmap) Width() int // 64 / 128 / 192 per continuation bits
func (b Bitmap) Range(yield func(de int) bool) // ascending present DEs; range-over-func
func (b Bitmap) String() string // human dump for logs
2.5 Message — lazy, immutable, copy-on-write¶
The model is a schema-driven, array-indexed sparse field set over a retained source buffer. While read-only, every Value aliases the shared src, so a decoded Message is safe to publish to many goroutines with zero locking and zero copy. The first mutation flips the message to owned and clones only the touched field path; everything else keeps aliasing src.
package iso8583
type Message struct {
schema *Schema
src []byte // retained wire buffer; field Values alias INTO this (nil if built fresh)
bm Bitmap // derived presence map
slots []slot // ARRAY-indexed by DE (len == schema.maxDE+1); no map lookup on hot path
subidx map[FieldPath]*Message // memoised composite children (DE48/55…), lazily built
owned bool // true once any field is mutated (copy-on-write engaged)
dirty Bitmap // fields mutated since decode -> drives selective re-marshal
sealed bool // immutable view; mutation goes through Clone()
}
type slot struct {
v Value
present bool // present per bitmap
decoded bool // Value materialised (memoised) on first typed access
}
Dynamic map-style API (read)¶
// Has reports presence WITHOUT decoding (bitmap check only).
func (m *Message) Has(p Pather) bool
// Get returns the zero-copy Value, decoding lazily on first access (memoised).
// Three overloads avoid `any`-boxing: Get(2) takes int, GetS("55.9F26") takes
// string, GetP(path) takes a pre-parsed FieldPath (hottest, no parse).
func (m *Message) Get(de int) (Value, bool)
func (m *Message) GetS(s string) (Value, bool)
func (m *Message) GetP(p FieldPath) (Value, bool)
func (m *Message) MTI() (Value, bool) // DE 0
func (m *Message) Bitmap() Bitmap
func (m *Message) Schema() *Schema
// Fields iterates present fields in ascending order as a range-over-func.
func (m *Message) Fields() iter.Seq2[FieldPath, Value]
Typed generic accessor (read) — no interface boxing¶
// Get[T] is a package-level generic (Go methods cannot have type params). It
// decodes a field directly into a Go type with no boxing, returning a structured
// *FieldError on type mismatch or malformed bytes.
//
// pan, _ := iso8583.Get[string](m, 2)
// stan, _ := iso8583.Get[int64](m, 11)
// amt, _ := iso8583.Get[iso8583.Decimal](m, 4)
// icc, _ := iso8583.Get[[]byte](m, "55.9F26") // zero-copy sub-slice
func Get[T FieldType](m *Message, p Pather) (T, error)
// FieldType is the closed set of Go types a field can decode to. Implemented as
// an interface with an unexported method (NOT a type-set union, which cannot mix
// underlying-type approximations with named struct types like Decimal/Bitmap).
// Concrete adapters are provided for string, []byte, int64, uint64, Decimal,
// Amount, time.Time, Bitmap, and *Message.
type FieldType interface{ isFieldType() }
Design note (judges): Design A's
FieldType ~string | ~int64 | time.Time | Money | Bitmapwould not compile — a Go type-set union cannot mix~-approximation elements with named struct types. We model the closed set as a sealed interface with provided adapters, which compiles and is still fully type-checked at the call site throughGet[T]'s inference.
Dynamic map-style API (write, copy-on-write)¶
// Set stores a value, triggering copy-on-write on a sealed/decoded message. It
// routes the value to the field's codec by Kind and fails fast with a
// *FieldError on type mismatch. The accepted value types mirror FieldType.
func (m *Message) Set(de int, v any) error
func (m *Message) SetS(s string, v any) error
func (m *Message) SetP(p FieldPath, v any) error
func (m *Message) Unset(p Pather)
// Seal returns an immutable view; subsequent Set on the sealed message clones
// only the touched path (structural sharing). Clone is an explicit deep-ish copy.
func (m *Message) Seal() *Message
func (m *Message) Clone() *Message
2.6 Schema & FieldDef¶
A Schema is immutable, shareable, and validated at build time (every referenced codec exists, no overlapping subfields, length rules sane). It is built once and reused across all goroutines and messages — never copied per message.
package iso8583
// Schema describes one message format. Immutable; concurrency-safe.
type Schema struct {
id string
mti *FieldDef
bitmap BitmapSpec
defs []*FieldDef // ARRAY indexed by DE (nil = undefined); maxDE+1 long
resolved []resolvedField // codec+length pre-resolved per DE (no map lookup at runtime)
bindCache sync.Map // reflect.Type -> *bindPlan (struct-binding plans)
maxDE int
}
// FieldDef defines one DE (or subfield). It references codecs from the registry
// by interface (already resolved at Schema build time), not by string at runtime.
type FieldDef struct {
Path FieldPath
Name string // "Primary Account Number"
Kind Kind // canonical model kind
Codec fieldcodec.FieldCodec // VALUE codec
Length lengthcodec.LengthCodec // LENGTH-PREFIX codec (orthogonal); Fixed for fixed-len
MaxLen int // max value length (digits/chars/octets)
Pad PadRule
Validate []Validator // ordered field-level rules
Required RequiredRule // always | conditional-on-MTI | optional
Sub *Schema // non-nil for composites (DE 48/55/60/63…)
}
// BitmapSpec selects bitmap wire encoding + supported levels for the schema.
type BitmapSpec struct {
Codec fieldcodec.BitmapCodec // BitmapBinary | BitmapHex | BitmapEBCDIC
Levels int // 1=primary, 2=+secondary, 3=+tertiary
}
func (s *Schema) ID() string
func (s *Schema) Field(de int) (*FieldDef, bool)
func (s *Schema) Lookup(p FieldPath) (*FieldDef, bool) // walks composites
Schema builder (build-time validated)¶
type SchemaBuilder struct{ /* ... */ }
func NewSchema(id string) *SchemaBuilder
func (b *SchemaBuilder) MTI(c fieldcodec.FieldCodec) *SchemaBuilder
func (b *SchemaBuilder) Bitmap(spec BitmapSpec) *SchemaBuilder
// Field wires a DE to a (value codec, length codec) pair plus options. The two
// codecs compose orthogonally: "BCD digits behind an ASCII LLVAR prefix" is just
// Field(2, ..., fieldcodec.BCD, lengthcodec.LLVarASCII, ...).
func (b *SchemaBuilder) Field(
de int, name string,
value fieldcodec.FieldCodec, length lengthcodec.LengthCodec,
opts ...FieldOpt,
) *SchemaBuilder
func (b *SchemaBuilder) Composite(
de int, name string, sub *Schema, length lengthcodec.LengthCodec, opts ...FieldOpt,
) *SchemaBuilder
func (b *SchemaBuilder) Build(reg *fieldcodec.Registry) (*Schema, error) // validates everything
func (b *SchemaBuilder) MustBuild(reg *fieldcodec.Registry) *Schema
// Derive/Override support card-scheme overlays as deltas over a base profile.
func (s *Schema) Derive(id string) *SchemaBuilder // clone defs, then Override/Field
func (b *SchemaBuilder) Override(de int, opts ...FieldOpt) *SchemaBuilder
// FieldOpt configures a FieldDef.
func MaxLen(n int) FieldOpt
func Pad(p PadRule) FieldOpt
func Optional() FieldOpt
func RequiredOn(mtiClasses ...string) FieldOpt
func Validate(v ...Validator) FieldOpt
2.7 Codec — the message engine¶
package iso8583
// Codec drives a full message through a Schema. Stateless and concurrency-safe;
// one instance serves all goroutines. Field codecs are pre-resolved by the Schema
// into an array, so the hot path is an array index, not a registry map lookup.
type Codec struct {
schema *Schema
opts CodecOptions
}
func NewCodec(s *Schema, opts ...CodecOption) *Codec
// Unmarshal does a ZERO-COPY structural parse: read MTI + bitmap, then record each
// present field as a (offset,len) span into wire. Field bodies are NOT decoded;
// they decode lazily on first Get and memoise. wire is RETAINED by the Message
// (Values alias into it) — the caller must not mutate wire while the Message lives.
func (c *Codec) Unmarshal(wire []byte) (*Message, error)
// Marshal renders a Message to wire bytes, APPENDING to dst (reuses caller cap).
// The bitmap is DERIVED from present fields here (never hand-set). Fast paths:
// - if the message is unmodified since Unmarshal (nothing dirty), the original
// src slice is returned verbatim — a store-and-forward switch hop pays ZERO
// re-encode/allocation cost (the returned slice ALIASES src; see contract).
// - otherwise only dirty fields re-encode; clean fields copy straight from src.
func (c *Codec) Marshal(m *Message, dst []byte) ([]byte, error)
// Validate runs every present field's validators plus required/MTI rules and
// returns ALL violations (non-fail-fast) as a *ValidationError, or nil.
func (c *Codec) Validate(m *Message) error
type CodecOptions struct {
Immutable bool // Unmarshal yields a sealed (copy-on-write) Message
Strict bool // fail on unknown DEs / length overruns vs. lenient skip
}
type CodecOption func(*CodecOptions)
Marshal aliasing contract. When
Marshalreturns the originalsrcverbatim (clean fast path), the returned slice aliases the message's retained buffer. Callers that need an independent buffer must copy, or pass a non-overlappingdstand useCodecOptions/Clonesemantics. This is documented prominently because it is the single most important performance lever for a store-and-forward switch.
3. The Dual Field API¶
Both APIs operate on the same *Message. Struct-binding is a thin, cached reflection layer over the same Get/Set machinery and the same Value zero-copy decode — there is no second model.
3.1 Dynamic map-style API¶
Read (zero-copy, lazy):
c := iso8583.NewCodec(packager.ISO87A()) // profile = assembled Schema
msg, err := c.Unmarshal(wire) // structural only; no field bodies decoded yet
if err != nil { return err }
mti, _ := iso8583.Get[string](msg, 0) // "0200"
pan, _ := iso8583.Get[string](msg, 2) // decoded on demand from a src sub-slice
amt, _ := iso8583.Get[iso8583.Decimal](msg, 4) // exact fixed-point money
icc, _ := iso8583.Get[[]byte](msg, "55.9F26") // BER-TLV tag, still a zero-copy sub-slice
// Hot-path routing WITHOUT allocating (compare on the raw view, never String()):
if v, ok := msg.Get(3); ok && bytes.HasPrefix(v.Bytes(), []byte("00")) {
// purchase
}
if msg.Has(38) { /* approval code present — bitmap check only, no decode */ }
for path, v := range msg.Fields() { // iter.Seq2, ascending
log.Printf("%s = % x", path, v.Bytes())
}
Write (copy-on-write or fresh build):
out := iso8583.New(packager.ISO87A()) // empty mutable message
out.Set(0, "0210")
out.Set(2, "4111111111111111")
out.Set(4, iso8583.NewDecimal(1099, 2)) // $10.99
out.SetS("55.9F26", emvCryptogram) // nested BER-TLV by tag
wire, err := c.Marshal(out, buf[:0]) // bitmap auto-derived; reuses buf
Edit-and-forward (copy-on-write):
resp := msg.Clone() // shares src; no field bytes copied yet
resp.Set(0, "0210") // flips owned=true
resp.Set(39, "00") // only DE 39 allocates new bytes + marks dirty
resp.Unset(52) // drop PIN block
out, _ := c.Marshal(resp, buf[:0]) // untouched fields copied straight from src; bitmap re-derived
3.2 Typed struct-binding API¶
A struct's iso:"..." tags use the same FieldPath grammar, so flat paths (iso:"55.9F26") and nested structs both work and the developer chooses per use. A Binder[T] builds the reflection plan once per type and validates every tag against the schema at construction time (NewBinder fails fast on a tag that names a field the schema does not define or whose Kind is incompatible) — never per message.
type AuthRequest struct {
MTI string `iso:"0"`
PAN string `iso:"2"`
Proc string `iso:"3"`
Amount iso8583.Decimal `iso:"4"`
STAN int64 `iso:"11"`
Local time.Time `iso:"12,format=hhmmss"`
Term string `iso:"41"`
Track2 []byte `iso:"35"` // binds directly to a src sub-slice (zero copy)
EMV EMVData `iso:"55"` // composite -> nested struct
}
type EMVData struct {
Cryptogram []byte `iso:"9F26"` // addressed by BER-TLV tag inside DE 55
ATC int64 `iso:"9F36"`
TVR []byte `iso:"95"`
}
binder := iso8583.NewBinder[AuthRequest](schema) // builds + caches plan; validates tags now
Read:
var req AuthRequest
if err := binder.Unmarshal(msg, &req); err != nil {
var fe *iso8583.FieldError
if errors.As(err, &fe) {
log.Printf("bad field %s @byte %d: %v", fe.Path, fe.Offset, fe.Err)
}
return err
}
// req.Amount, req.EMV.Cryptogram are fully typed. []byte fields alias src (zero copy);
// only string/Decimal/time fields pay their unavoidable conversion.
Write:
resp := AuthResponse{ MTI: "0210", PAN: req.PAN, Amount: req.Amount, RespCode: "00", STAN: req.STAN }
m, _ := binder.Marshal(&resp) // *Message
wire, _ := c.Marshal(m, buf[:0])
type Binder[T any] struct{ plan *bindPlan; schema *Schema }
func NewBinder[T any](s *Schema) *Binder[T] // panics/returns error on tag↔schema mismatch
func (b *Binder[T]) Unmarshal(m *Message, dst *T) error // Message -> struct (lazy: only bound fields)
func (b *Binder[T]) Marshal(src *T) (*Message, error) // struct -> Message
// Convenience one-shots on Codec that build+cache the binder internally:
func (c *Codec) Bind(wire []byte, dst any) error // wire -> struct
func (c *Codec) Pack(src any) ([]byte, error) // struct -> wire
A struct may be partial (bind only the DEs you care about); untagged exported fields are ignored. Missing-but-required schema fields surface as a structured FieldError.
4. Zero-Copy Decode¶
The cornerstone of the hot path. Three layers cooperate:
-
Structural-only
Unmarshal. One pass reads the MTI and bitmap, then for each present DE recordsslot{present:true, decoded:false}with the field's byte span intosrc. No transcoding, no parsing, no per-field allocation. A switch that routes on DE 0/11/41 records ~35 spans but decodes 0 field bodies. -
Lazy, memoised field decode. The first
Get(n)slicessrc[off:off+len]into aValuewhoserawaliasessrc(no copy), invokes the field'sFieldCodec.DecodeSpanto validate/shape it, and memoises the result in the slot. Subsequent reads are free. -
SpanCodecfast path with graceful fallback. AFieldCodecmay implement the optionalSpanCodecextension to return just the[start,end)byte span of a field without decoding its body — used byUnmarshalto find field boundaries cheaply. Codecs that cannot span (rare) fall back to fullDecode. This is the most honest zero-copy mechanism: it does not force every codec to implement spanning.
package fieldcodec
// FieldCodec encodes/decodes ONE field's BODY. The length prefix is handled
// separately by a LengthCodec (see §6), so codecs compose orthogonally.
type FieldCodec interface {
// DecodeBody interprets body (already length-delimited by the engine) into a
// Value. It MUST return sub-slices of body for raw/binary kinds (no copy); only
// charset transcoding or digit parsing may allocate, and only lazily.
DecodeBody(body []byte, def *iso8583.FieldDef) (iso8583.Value, error)
// EncodeBody appends the wire body of v to dst and returns the grown slice
// (append-style; reuses dst capacity).
EncodeBody(dst []byte, v iso8583.Value, def *iso8583.FieldDef) ([]byte, error)
// Kind is the natural Go kind this codec yields (drives binding + rendering).
Kind() iso8583.Kind
// Name is the registry key, e.g. "char.ascii", "num.bcd", "tlv.ber".
Name() string
}
// SpanCodec is an OPTIONAL extension: codecs that can locate a field's body span
// in src without decoding it. The engine prefers Span; codecs that do not
// implement it fall back to DecodeBody during Unmarshal.
type SpanCodec interface {
FieldCodec
Span(body []byte, def *iso8583.FieldDef) (start, end int, err error)
}
Why text cannot be truly zero-copy: Go strings are immutable and cannot alias a []byte without a copy. We are explicit about this: Value.Bytes() is the zero-copy primitive and the documented hot-path tool (bytes.Equal/bytes.HasPrefix routing); Value.String() is the single, clearly-labelled allocation (plus EBCDIC transcoding). []byte struct-binding fields and Get[[]byte] are fully zero-copy.
Copy-on-write & store-and-forward. While read-only, all Values alias the shared src; a decoded Message is published to many goroutines with no locks and no defensive copy. The first Set/Unset sets owned=true, allocates new bytes for that field only, and marks it dirty. Marshal returns src verbatim when dirty is empty (pure pass-through), and otherwise re-encodes only dirty fields while copying clean fields straight from src.
5. Schema & Validation Model¶
5.1 Build-time schema validation¶
SchemaBuilder.Build rejects a malformed schema before any message is processed: unknown/unregistered codec, overlapping subfield definitions, a MaxLen incompatible with the length codec, a composite without a Sub schema, or a BER-TLV path element used under a non-TLV field. This turns whole classes of configuration error into startup failures.
5.2 Structured errors with path + byte offset¶
Every error carries the uniform FieldPath and, where known, the absolute byte offset into the wire buffer — invaluable for an operator staring at a hex dump.
package iso8583
// FieldError: a single field could not be decoded or typed.
type FieldError struct {
Path FieldPath // "55.9F26"
Offset int // absolute byte offset in the source buffer, or -1
Name string // "Primary Account Number"
Err error // underlying cause (luhn failed, truncated, length, type mismatch)
}
func (e *FieldError) Error() string // "field 55.9F26 @byte 214: luhn check failed"
func (e *FieldError) Unwrap() error { return e.Err }
// Violation: a single semantic/validation failure (from Validate).
type Violation struct {
Path FieldPath
Offset int
Rule string // "luhn", "len", "required", "currency", "mti"
Msg string
}
// ValidationError aggregates ALL violations from one Validate pass (non-fail-fast)
// so a declined message reports every problem at once with precise locations.
type ValidationError struct{ Violations []Violation }
func (e *ValidationError) Error() string
func (e *ValidationError) Unwrap() []error // each Violation as an error, for errors.Is/As
5.3 Validators¶
package iso8583
type Validator interface {
Validate(v Value, def *FieldDef) *Violation // nil == ok
}
type ValidatorFunc func(Value, *FieldDef) *Violation
func (f ValidatorFunc) Validate(v Value, d *FieldDef) *Violation { return f(v, d) }
// Built-in, pure, composable rule combinators:
func Required() Validator
func Digits() Validator
func Luhn() Validator // PAN check digit (DE 2)
func LenBetween(min, max int) Validator
func MaxLength(n int) Validator
func OneOf(allowed ...string) Validator
func Regexp(pattern string) Validator
func CurrencyAmount() Validator // DE 4 well-formed vs DE 49
func MTIClass(allowed ...string) Validator
func All(vs ...Validator) Validator // AND-combine
Codec.Validate(m) runs every present field's Validate rules plus Required/RequiredOn(MTI) rules and any schema-level cross-field rules, collecting all Violations. This exhaustive, non-fail-fast behaviour is what makes the framework fuzz/conformance-friendly and lets a switch emit precise ISO response/reason codes for every malformed field in a single pass.
6. FieldCodec / LengthCodec Registry & Catalog¶
6.1 Two orthogonal codec axes¶
The defining structural improvement over jPOS: length handling is a separate, independently-registered LengthCodec, composed orthogonally with the value FieldCodec. A field's wire behaviour is the pair (LengthCodec, FieldCodec), not a monolithic class. This collapses jPOS's combinatorial IF* zoo (IFA_LLLNUM, IFB_LLLBINARY, IFE_LLLCHAR, …) into ≈6 length codecs × ≈12 value codecs that compose freely.
package lengthcodec
// LengthCodec handles the length prefix (fixed / LL / LLL / LLLL) in a chosen
// representation (ASCII / BCD / Binary / EBCDIC). Stateless singleton.
type LengthCodec interface {
// ReadLen consumes the length prefix from src at off, returning the number of
// BODY bytes that follow and the offset just past the prefix.
ReadLen(src []byte, off int, def *iso8583.FieldDef) (bodyLen, next int, err error)
// WriteLen appends a length prefix for a body of n bytes.
WriteLen(dst []byte, n int, def *iso8583.FieldDef) ([]byte, error)
Name() string
}
6.2 Registries (explicit population — no init() side effects)¶
package fieldcodec
type Registry struct {
values map[string]FieldCodec
lengths map[string]lengthcodec.LengthCodec
bitmaps map[string]BitmapCodec
}
func (r *Registry) Register(c FieldCodec)
func (r *Registry) RegisterLength(l lengthcodec.LengthCodec)
func (r *Registry) RegisterBitmap(b BitmapCodec)
func (r *Registry) Lookup(name string) (FieldCodec, bool)
func (r *Registry) LookupLength(name string) (lengthcodec.LengthCodec, bool)
// DefaultRegistry returns a freshly-built registry pre-populated with the FULL
// catalog below. Explicit construction (not import-for-side-effects) keeps global
// state visible and avoids import-ordering surprises. Callers may add custom codecs.
func DefaultRegistry() *Registry
Custom codecs (e.g. a scheme-specific date) are added by name and referenced from schemas/YAML — no fork of the engine:
reg := fieldcodec.DefaultRegistry()
reg.Register(myMMDDHHMMSS{}) // Name() == "date.mmddhhmmss.ascii"
6.3 Catalog — jPOS IF* interpreters → Isopace codecs¶
Isopace splits each jPOS IF* class into its two orthogonal parts: a value FieldCodec (charset/representation) and a LengthCodec (prefix). The table shows the mapping. "—" in the LengthCodec column means the jPOS class is itself a value interpreter with no length semantics (length comes from a separate IF*LLNUM/IF*LLLCHAR wrapper, which here is simply a different LengthCodec).
Value FieldCodecs (charset / numeric / binary)¶
| jPOS interpreter (representative) | Isopace FieldCodec |
Registry name | Kind | Notes |
|---|---|---|---|---|
IFA_CHAR, IF_CHAR (ASCII char) |
ASCII |
char.ascii |
String | ASCII text |
IFE_CHAR (EBCDIC char) |
EBCDIC037 |
char.ebcdic.cp037 |
String | CP037 table |
IFEB_CHAR / CP1047 usage |
EBCDIC1047 |
char.ebcdic.cp1047 |
String | CP1047 table |
| (UTF-8 text, jPOS lacks) | UTF8 |
char.utf8 |
String | extra |
IFB_BINARY, IF_ECHO/raw |
BINARY |
b.raw |
Bytes | zero-copy sub-slice |
IFA_BINARY (hex-text binary) |
HexBINARY |
b.hex |
Bytes | octets as ASCII hex (2 chars/octet) |
IFA_NUMERIC (ASCII digits) |
NumASCII |
num.ascii |
Numeric | digit string |
IFE_NUMERIC (EBCDIC digits) |
NumEBCDIC |
num.ebcdic |
Numeric | EBCDIC digits |
IFB_NUMERIC (BCD / packed) |
BCD |
num.bcd |
Numeric | 2 digits/byte, left-aligned |
IFB_NUMERIC right-aligned (rBCD) |
RBCD |
num.rbcd |
Numeric | right-aligned packed |
IFB_BINARY numeric / binary int |
NumBinary |
num.bin |
Numeric | big-endian integer |
IFA_AMOUNT, IF_AMOUNT |
AmountASCII |
amount.ascii |
Amount | signed, → Decimal |
IFB_AMOUNT (packed amount) |
AmountBCD |
amount.bcd |
Amount | → Decimal |
| (binary amount, extra) | AmountBinary |
amount.bin |
Amount | → Decimal |
Bitmap codecs¶
| jPOS interpreter | Isopace BitmapCodec |
Registry name | Notes |
|---|---|---|---|
IFB_BITMAP (binary bitmap) |
BitmapBinary |
bitmap.bin |
8/16/24 bytes, continuation-bit driven |
IFA_BITMAP (ASCII-hex bitmap) |
BitmapHex |
bitmap.hex |
16/32/48 hex chars |
IFE_BITMAP (EBCDIC-hex bitmap) |
BitmapEBCDIC |
bitmap.ebcdic |
EBCDIC hex |
Length-prefix codecs (the orthogonal axis)¶
jPOS prefix style (embedded in IF*LL*) |
Isopace LengthCodec |
Registry name | Notes |
|---|---|---|---|
| fixed length | Fixed |
len.fixed |
length from FieldDef.MaxLen |
LLNUM/LLCHAR ASCII 2-digit |
LLVarASCII |
len.ll.ascii |
LLVAR |
LLLNUM/LLLCHAR ASCII 3-digit |
LLLVarASCII |
len.lll.ascii |
LLLVAR |
| 4-digit ASCII (LLLLVAR) | LLLLVarASCII |
len.llll.ascii |
LLLLVAR |
| 5-digit ASCII (LLLLLVAR) | LLLLLVarASCII |
len.lllll.ascii |
LLLLLVAR |
| 6-digit ASCII (LLLLLLVAR) | LLLLLLVarASCII |
len.llllll.ascii |
LLLLLLVAR (e.g. DE 127 container) |
IFB_LLNUM BCD 2-digit prefix |
LLVarBCD |
len.ll.bcd |
packed length |
IFB_LLLNUM BCD 3-digit prefix |
LLLVarBCD |
len.lll.bcd |
packed length |
| binary 1-byte length | LLVarBinary |
len.ll.bin |
binary count |
| binary 2-byte length | LLLVarBinary |
len.lll.bin |
binary count |
IFE_LLNUM EBCDIC 2-digit |
LLVarEBCDIC |
len.ll.ebcdic |
EBCDIC length |
IFE_LLLNUM EBCDIC 3-digit |
LLLVarEBCDIC |
len.lll.ebcdic |
EBCDIC length |
Composition example. jPOS IFB_LLLNUM (BCD digits, BCD 3-digit prefix) = (value: BCD, length: LLLVarBCD). jPOS IFE_LLLCHAR (EBCDIC char, EBCDIC 3-digit prefix) = (value: EBCDIC037, length: LLLVarEBCDIC). Neither needs a bespoke class.
Composite & extended formats¶
| Capability | Isopace component | Registry name | Notes |
|---|---|---|---|
| Subfield group (bitmap + positional subfields, e.g. DE 127) | subfield.Packager + headerless Sub *Schema |
subfield.iso |
nested sub-bitmap; 127.2 addressing |
| EMV BER-TLV (DE 55) | bertlv.Codec |
tlv.ber |
hex-tag addressing (55.9F26); recursive |
| Visa Base I dialect | packager/visa (schema overlay) |
— | Derive/Override on iso93 |
| Mastercard dialect | packager/mastercard (overlay) |
— | DE 48/124 overrides |
| JSON rendering | render/jsonio |
— | consumes View |
| protobuf rendering | render/protobuf |
— | descriptor-driven, consumes View |
| ISO 20022 bridge | render/iso20022 |
— | DE→pacs/pain/camt map, consumes View |
7. Packager Profiles¶
A profile is just an assembled, immutable *Schema — there is no special "packager" type, which keeps profiles composable. Each profile wires FieldDefs from the two codec axes; scheme dialects are deltas via Derive/Override.
7.1 ISO 8583:1987 — variants A / B / C¶
| Variant | Charset / value codecs | Length prefixes | Bitmap | Typical use |
|---|---|---|---|---|
| A (ASCII) | char.ascii, num.ascii, amount.ascii |
len.*.ascii |
bitmap.hex |
text-oriented links |
| B (binary/BCD) | num.bcd, b.raw, amount.bcd |
len.*.bcd, len.fixed |
bitmap.bin |
compact binary links |
| C (EBCDIC) | char.ebcdic.cp037, num.ebcdic |
len.*.ebcdic |
bitmap.ebcdic |
mainframe/EBCDIC hosts |
package packager
func ISO87A() *iso8583.Schema {
reg := fieldcodec.DefaultRegistry()
return iso8583.NewSchema("iso87-a").
MTI(must(reg, "num.ascii")).
Bitmap(iso8583.BitmapSpec{Codec: mustBM(reg, "bitmap.hex"), Levels: 2}).
Field(2, "PAN", fc(reg, "num.ascii"), ln(reg, "len.ll.ascii"), iso8583.MaxLen(19), iso8583.Validate(iso8583.Luhn())).
Field(3, "Proc Code", fc(reg, "num.ascii"), ln(reg, "len.fixed"), iso8583.MaxLen(6)).
Field(4, "Amount", fc(reg, "amount.ascii"), ln(reg, "len.fixed"), iso8583.MaxLen(12), iso8583.Validate(iso8583.CurrencyAmount())).
Field(11, "STAN", fc(reg, "num.ascii"), ln(reg, "len.fixed"), iso8583.MaxLen(6)).
Field(35, "Track2", fc(reg, "b.raw"), ln(reg, "len.ll.ascii"), iso8583.MaxLen(37)).
Composite(55, "EMV", emvSchema(reg), ln(reg, "len.lll.ascii")).
MustBuild(reg)
}
// Variant B reuses the SAME field defs, swapping only the codec/length/bitmap axes:
func ISO87B() *iso8583.Schema {
reg := fieldcodec.DefaultRegistry()
return ISO87A().Derive("iso87-b").
Bitmap(iso8583.BitmapSpec{Codec: mustBM(reg, "bitmap.bin"), Levels: 2}).
Override(2, withCodec(reg, "num.bcd"), withLength(reg, "len.ll.bcd")).
Override(4, withCodec(reg, "amount.bcd")).
Override(11, withCodec(reg, "num.bcd")).
MustBuild(reg)
}
func emvSchema(reg *fieldcodec.Registry) *iso8583.Schema {
return iso8583.NewSchema("emv-55").
Bitmap(iso8583.BitmapSpec{Codec: mustBM(reg, "tlv.ber.index")}). // tag-addressed
Tag("9F26", "App Cryptogram", fc(reg, "b.raw")).
Tag("9F36", "ATC", fc(reg, "num.bin")).
Tag("95", "TVR", fc(reg, "b.raw")).
MustBuild(reg)
}
7.2 ISO 8583:1993 — variants A / B¶
| Variant | Value codecs | Length prefixes | Bitmap | Notes |
|---|---|---|---|---|
| A (ASCII) | char.ascii, num.ascii |
len.*.ascii |
bitmap.hex |
adds DE structure changes vs 87 (response codes, etc.) |
| B (binary) | num.bcd, b.raw |
len.*.bcd |
bitmap.bin |
compact 1993 |
iso93 schemas are independent definitions (the 1993 DE catalogue differs from 1987), built the same way; they are the base for card-scheme overlays.
7.3 Card-scheme overlays (deltas)¶
// Visa Base I = iso93 variant A with field 62/63 redefined.
func VisaBaseI() *iso8583.Schema {
reg := fieldcodec.DefaultRegistry()
return ISO93A().Derive("visa-base1").
Override(62, withComposite(visaF62(reg)), withLength(reg, "len.lll.ascii")).
Override(63, withComposite(visaF63(reg)), withLength(reg, "len.lll.ascii")).
MustBuild(reg)
}
7.4 Declarative profiles (YAML, codec-by-name)¶
The same composition is expressible as config for ops teams; packager/generic resolves each codec:/length: name against the registry, producing an identical *Schema. Loaded via go:embed from schemadef/.
id: iso87-b
mti: num.bcd
bitmap: { codec: bitmap.bin, levels: 2 }
fields:
2: { name: PAN, codec: num.bcd, length: len.ll.bcd, max: 19, validate: [luhn] }
4: { name: Amount, codec: amount.bcd, length: len.fixed, max: 12, validate: [currencyAmount] }
55: { name: EMV, composite: emv-55, length: len.lll.bcd }
7.5 Concrete site packager (postilion)¶
Unlike the representation-independent iso87/iso93 directories (which swap whole
codec sets via a rep), a deployment often pins each field to one concrete codec
and length discipline. packager.Postilion() is such a site profile: an ISO
8583:1987 Postilion layout (ASCII MTI, binary primary+secondary bitmap, ASCII
fixed and LL/LLL variable fields) — a table of (DE, name, value, length, max)
rows over the two orthogonal axes. Its DE 127 is a nested reserved-private
subfield group — a headerless binary sub-bitmap plus positional subfields
under a 6-digit length prefix, wired with the subfield composite codec — so
127.2, 127.22, … are addressable under the uniform path grammar, just like
55.9F26. The profile was validated end-to-end against a live switch (a 0800
sign-on answered 0810, and a 0200 cashout answered 0210); on the wire such
links use a 2-byte length prefix and a network sign-on, both handled by the
link layer rather than the schema.
8. Alternate Renderings (View)¶
Because the model is decoupled from the wire codec, rendering to another format is a visitor over the schema-typed Value tree — it never re-parses the wire. Every renderer consumes one read-only interface, so adding a format never touches the core or any codec.
package iso8583
// View is the read-only projection renderers consume. *Message implements it.
type View interface {
Schema() *Schema
Bitmap() Bitmap
Fields() iter.Seq2[FieldPath, Value] // ascending, present fields only
Get(de int) (Value, bool)
}
package jsonio
// Marshal renders a Message to JSON using schema names as keys and Kind to choose
// the JSON shape. Numerics stay strings to preserve leading zeros and amount scale.
func Marshal(v iso8583.View, opts ...Option) ([]byte, error)
func Unmarshal(data []byte, s *iso8583.Schema) (*iso8583.Message, error)
type Option func(*options) // UseNames(), MaskPAN(), etc.
{
"mti": "0200",
"fields": {
"2": { "name": "PAN", "value": "411111******1111" },
"4": { "name": "Amount", "value": { "currency": "840", "scale": 2, "unscaled": 1099 } },
"55": { "name": "EMV", "tlv": {
"9F26": "A1B2C3D4E5F60718",
"9F36": 42
}}
}
}
The same *Message round-trips through every backend: Codec.Marshal → ISO-8583 wire (any profile), jsonio.Marshal → JSON, protobuf.Marshal → protobuf (DE→field-number via descriptor), iso20022.To → ISO 20022 (DE→pacs.008/pain.001/camt.05x via a declarative map[FieldPath]xpath table). No re-modelling, no second source of truth. PAN masking, amount formatting, and tag naming are render options, not baked into the model.
9. How This Beats jPOS¶
1. Type-safe access instead of a stringly-typed mutable Map<Integer,ISOComponent>.
jPOS hands you msg.getString(4) (a String you must parse) and getComponent(4) (an ISOComponent you downcast), with mistakes surfacing as runtime ClassCastException/NumberFormatException. Isopace gives Get[iso8583.Decimal](m, 4) and tag-bound structs (Amount iso8583.Decimal \iso:"4"`): the type is known at compile time, money is exact fixed-point (never a float or a re-parsed string), and a mismatch is a generic-constraint error or a structuredFieldErrorcarryingPath+Offset`. The 90% developer never casts.
2. Zero-copy lazy decode vs eager full-message unpack.
jPOS unpack() eagerly decodes every present field into an ISOField/String and stuffs them into a map — O(all fields) allocations regardless of what you read. Isopace's Unmarshal walks only MTI + bitmap and records (offset,len) spans aliasing the original buffer; a field decodes on first Get and memoises, Value.Bytes() is a true zero-copy view, and []byte struct fields bind with no copy at all. A high-TPS switch routing on 4 of ~35 fields skips decoding the rest, and Marshal returns the original buffer verbatim when nothing is dirty — a store-and-forward hop is allocation-free end to end.
3. Composable (LengthCodec × FieldCodec) instead of a combinatorial IF* class zoo.
jPOS encodes every length×encoding combination as its own packager class (IFA_LLLNUM, IFB_LLLBINARY, IFE_LLLCHAR, …). Isopace makes the length prefix and the value codec orthogonal, independently-registered components, so ≈6 length × ≈12 value codecs compose into the full catalogue with zero bespoke classes — all resolvable by name from Go, YAML, or a derived profile. Adding EBCDIC to a numeric field is swapping one codec, not authoring a new class.
4. Structural correctness invariants jPOS lacks.
The bitmap is always derived from present fields at marshal time, so the classic jPOS bitmap/content desync footgun is impossible. Message is immutable with copy-on-write, so a decoded request is safely shared across the Switch, Flow stages, and async logging with zero locking and no defensive copies. Validate() is exhaustive — it returns all Violations in one pass, each with Path, Offset, and Rule, versus jPOS's first-failure ISOException with an opaque string — which is exactly what makes the framework fuzz/conformance-friendly and lets a switch emit precise ISO response codes. The Schema and every struct-binding tag are validated at build/init time, turning config mistakes into startup failures.
5. One model, many renderings — clean model/schema/codec separation.
In jPOS the packager is the format and ISOMsg is hard-wired to ISO-8583 wire; producing JSON or another encoding means bespoke traversal per format with no single immutable schema object. Isopace splits Message (model) / Schema (definitions) / FieldCodec+LengthCodec (wire), and every renderer consumes one read-only View, so the identical *Message projects losslessly to ISO-8583 (any profile), JSON, protobuf, and ISO 20022 — and a new acquirer dialect is a generic.LoadFile config or a Derive/Override overlay, not a new packager class.
6. First-class nested + BER-TLV addressing under one grammar.
Subfields (DE 48/60/63) and EMV DE 55 are addressed by a single uniform FieldPath grammar ("55.9F26", with hex BER-TLV tags as first-class path elements) used identically in the dynamic API, struct tags, validation errors, and JSON keys — versus jPOS's separate sub-ISOMsg packagers and manual TLV handling.
10. Implementation Corrections (post-synthesis review)¶
The synthesis is sound, but four items in §2–§4 have compile-level defects. The corrected forms below are authoritative for implementation and override the earlier sections where they conflict.
C1 — Codec contracts live in iso8583; fieldcodec/lengthcodec only implement them (import-cycle fix).
As written, iso8583 references fieldcodec.FieldCodec (§2.2, §2.6) while
fieldcodec.FieldCodec's methods take iso8583.Value / *iso8583.FieldDef (§4) —
a cyclic import. Resolution (this is what the §1 layering prose actually intends):
- Declare the interfaces
FieldCodec,LengthCodec,BitmapCodeciniso8583. ThereforeValue.codec,FieldDef.Codec,FieldDef.Length, andBitmapSpec.Codecare the localiso8583.*interface types. fieldcodec/andlengthcodec/import iso8583and provide the concrete implementations, theRegistry, andDefaultRegistry().SchemaBuilder.Fieldalready receives resolved codec instances, soBuild/MustBuildtake no registry argument (Build() (*Schema, error)); theRegistryis used only inpackager/to resolve codec names → instances. Thusiso8583never importsfieldcodec, and the dependency graph is acyclic.
C2 — Get[T] uses a union constraint, not a sealed isFieldType() interface.
FieldType interface{ isFieldType() } (§2.5) makes Get[string](m, 2)
uncompilable — a built-in string cannot carry the method. The §2.5 design note's
premise is also wrong: a Go union constraint may mix ~-approximation terms with
exact named-struct terms. Use:
type FieldType interface {
~string | ~int64 | ~uint64 | []byte | Decimal | Amount | time.Time | Bitmap | *Message
}
([]byte is an exact term; ~[]byte is invalid because []byte is unnamed.)
Dispatch inside Get[T] via switch any(*new(T)).(type). Ergonomic
Get[string] / Get[int64] / Get[[]byte] / Get[Decimal] then compile.
C3 — Replace the Pather union with explicit overloads.
Pather interface{ ~int | ~string | path() } is not legal: a union term cannot be
a method, and a union/~ interface cannot be an ordinary parameter type, so
Has(p Pather) / Unset(p Pather) will not compile. Drop Pather; mirror the
existing read overloads wherever a path is accepted: Has / HasS / HasP,
Unset / UnsetS / UnsetP (alongside the existing Get/GetS/GetP and
Set/SetS/SetP).
C4 — Add SchemaBuilder.Tag for BER-TLV composites.
§7.1's emvSchema calls .Tag(...) (and a tlv.ber.index bitmap) that §2.6 does
not declare. Add:
A TLV composite addresses children by canonical hex tag (presence by tag, not a positional bitmap).