Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog. This project adheres to Semantic Versioning and Conventional Commits.

[Unreleased]

[7.1.0] - 2026-07-15

Added

  • suggest — a checksum-anchored diagnostic-repair verb that turns the checksum from a gatekeeper into a repair engine. For a structurally-valid but checksum-failing identifier, SecID::<Type>.suggest(str) and SecID.suggest(str, types:) enumerate the plausible single-character human errors — visual/OCR homoglyph substitutions (O0, I1, 5S, 8B, …) and adjacent transpositions — keep only those that re-validate (valid? is the oracle, so no false candidate ever escapes), and return them as confidence-ranked SecID::Suggestion value objects reporting what changed (edit kind, position, from/to characters, confidence tier). Available for all 9 checksum types (ISIN, CUSIP, SEDOL, FIGI, LEI, IBAN, CEI, DTI, UPI) through one shared Suggestable concern. Candidates rank :high (homoglyph) then :medium (transposition) then a :checksum recompute fallback last; there is no :low tier (coincidental substitutions are never generated), keeping results small and high-precision. suggest never mutates its input and never presents a candidate as an authoritative correction. Non-checksum types (CIK, OCC, WKN, Valoren, CFI, FISN, BIC) have no checksum oracle and are unsupported; SecID.suggest silently skips them. Known limitations: the mistyped character must be in the type's charset to be reachable (the vowel-free SEDOL/FIGI/DTI/UPI can't repair an O-for-0/I-for-1 typo), only a single body error is in scope, and wrong-length input returns []. In a seeded simulation (benchmark/suggest_precision.rb), every reachable single-error homoglyph or transposition that yields a checksum-failing identifier is recovered (the correct identifier is always among the returned candidates), the top-ranked body candidate ~89% of the time for homoglyph errors

[7.0.0] - 2026-07-14

Added

  • UPI (ISO 4914, Unique Product Identifier) support via SecID::UPI — the gem's 16th identifier type, and the first offline UPI validator in any language. Validates the 12-character code (fixed QZ prefix, 9-character body, 1 check character) issued by the ANNA Derivatives Service Bureau for OTC-derivatives reporting (CFTC, EMIR, and other global mandates). The check character is computed fully offline via ISO 7064 hybrid MOD 31,30 over the same 30-symbol alphabet as DTI (digits plus consonants; vowels and Y never appear), pinned empirically against DSB-issued vectors — no DSB registry lookup or paywalled ISO 4914 spec required. Like DTI, checksum/calculate_checksum return a String rather than an Integer, since UPI check characters can be letters. A UPI shares the 12-character length bucket with ISIN and can double-detect (ISIN ranked first) when its digit check character also satisfies ISIN's Luhn checksum.

Changed

  • BREAKING: Renamed the check-digit concept to checksum across the entire public API, because the old name was wrong on two axes — DTI's and UPI's check value is a String (it can be a letter, not a digit), and LEI and IBAN carry a two-character check value. The instance and class methods check_digitchecksum, calculate_check_digitcalculate_checksum, and has_check_digit?has_checksum?; the error class SecID::InvalidCheckDigitErrorSecID::InvalidChecksumError; the error code :invalid_check_digit:invalid_checksum (a hard flip with no dual emission — dual would duplicate errors.details entries, so update any errors.details/explain/ActiveModel details: true matcher immediately); and the :check_digit components key → :checksum in to_h/deconstruct_keys across all nine checkable types. restore/restore! and the Checkable concern name are unchanged. No validation or checksum-arithmetic behavior changed: every identifier that was valid stays valid and every computed value is byte-identical. The old method names, the InvalidCheckDigitError constant, and the :check_digit components key remain as deprecated bridges through v7 (removed in v8); the error code is the only surface with no bridge. See MIGRATION.md for the full upgrade guide

Deprecated

  • The pre-rename check-digit names, kept as v7 bridges and removed in v8: the check_digit / calculate_check_digit / has_check_digit? methods (instance and class level) — each warns via Kernel#warn on every call, visible at Ruby's default verbosity and silenceable with -W0 / $VERBOSE = nil or an app-level Warning override; the SecID::InvalidCheckDigitError constant, a same-object alias of InvalidChecksumError so rescue under either name keeps working; and the :check_digit key, still present alongside :checksum in components / to_h / deconstruct_keys

[6.1.0] - 2026-07-10

Added

  • SecID::Base#deconstruct_keys — every identifier now destructures in case/in patterns, exposing the same parsed fields #to_h reports under :components. The keys argument is ignored, no new keys are introduced, and validity is not part of the protocol: SecID.parse returning nil remains the validity guard, and an instance built from unparseable input binds nil for each component. No deconstruct (array-pattern) method is defined
  • SecID::Base.type_key — the memoized class-level registry symbol for an identifier type, joining the existing short_name / full_name / id_length metadata surface. It round-trips through the registry: SecID[SecID::ISIN.type_key] == SecID::ISIN. It is now the single authority for that symbol; the registry, #to_h, SecID.explain, the ActiveModel validator, the detector, and the scanner all read it instead of each deriving it from the class name

[6.0.0] - 2026-07-08

Added

  • Hand-written RBS type signatures under sig/, shipped in the gem, so consumers running Steep or an RBS-aware editor resolve sec_id's types on install. The core library is checked by Steep in strict mode with zero errors and verified at runtime against the specs via RBS::Test. New dev/test tooling (rbs and steep, require: false — the gem stays zero-runtime-dependency) and rake tasks: rake steep (strict type check), rake steep:coverage (untyped-call gate), rake rbs:test (runtime signature verification), rake rbs (validate signatures, in the default task), and rake sig:cfi (regenerate the CFI dynamic-method signatures from SecID::CFI::Tables, guarded by a drift spec). CI gains a types job on Ruby 4.0. The optional ActiveModel adapter and Railtie are excluded from the typed scope
  • SecID::CFI#decode returns a frozen SecID::CFI::Classification value object for any valid CFI (nil for invalid). Its category, group, and each attribute are Field objects carrying the CFI letter (#code), semantic symbol (#name), and authoritative ISO 10962 label (#label), plus <name>? predicates scoped to the field's own domain — so decode.category.equity? and decode.attributes.voting_right.voting? answer, while an out-of-domain predicate (e.g. category.voting?) raises NoMethodError. #attributes is an Enumerable of the attribute fields keyed by each position's group meaning (attributes.voting_right, nil-safe attributes[:form]). Ships a human-readable #to_s and #to_h/#as_json for serialization. X decodes to :not_applicable; pure-N/A positions are omitted
  • SecID::CFI now validates the full ISO 10962:2021 attribute matrix — positions 3–6 are checked per-group, Strategies (K) codes require XXXX, pure-N/A positions accept only X, and the ED (depositary receipts on equities) cross-position rule is enforced. A new :invalid_attribute error code (mapped to InvalidStructureError) names the offending positions and group
  • Opt-in ActiveModel / Rails validator (require 'sec_id/active_model'), registered as sec_id, for declarative validation: validates :isin, sec_id: { type: :isin }. Validates a single type (type:), an allowlist (types:), or any supported type (sec_id: true); a bad type raises ArgumentError at class-load. Opt-in normalize: true accepts separatored input and rewrites the attribute to canonical form on success; opt-in details: true surfaces the specific failure reason. Auto-activates in Rails via a Railtie (no require:/initializer needed) and adds no runtime dependency — nothing on the default require 'sec_id' path loads Rails or ActiveModel. Tested across Rails 7.2, 8.0, 8.1, and main
  • BIC / SWIFT code (ISO 9362) support via SecID::BIC — validate, normalize, parse, detect, extract, and generate 8- or 11-character Business Identifier Codes. Exposes bank_code, country_code, location_code, and branch_code (nil for a BIC8) components. Validates the embedded country code against a frozen ISO 3166-1 / SWIFT-recognized set (SecID::BIC.countries), raising InvalidStructureError (:invalid_country) for unrecognized codes. Validation confirms structure and a real country code only — it does not verify registration in the licensed SWIFT registry
  • DTI (ISO 24165, Digital Token Identifier) support via SecID::DTI — the gem's 15th identifier type, and the first offline DTI validator in any language. Validates the ISO 7064 hybrid MOD 31,30 check character over the 30-symbol DTI alphabet (digits plus consonants; vowels and Y never appear), identified empirically from public registry data (1,595 of 1,596 snapshot codes plus 2 post-snapshot registrations). Bitcoin's hand-assigned code (4H95J0R2X, which fails the algorithm) is honored via a frozen exception map consulted by valid?, restore, and check_digit alike. Unlike every other check-digit type in the gem, check_digit/calculate_check_digit return a String rather than an Integer, since DTI check characters can be letters
  • .generate on all 15 identifier types plus a central SecID.generate(:type) dispatcher for producing syntactically valid identifiers (with correct check digits where applicable) as test fixtures — accepts an optional random: keyword (a Ruby Random) for reproducible output. Generated values are valid in format only and are not real, registered securities
  • 100% YARD documentation coverage of the public API, enforced in CI via rake yard:stats. The gem now ships a .yardopts so rubydoc.info renders the public API cleanly (private internals hidden, README/CHANGELOG/LICENSE as pages) and exposes documentation_uri metadata (a Documentation link on the RubyGems page). Adds a yard dev/test dependency (require: false — the gem stays zero-runtime-dependency) and a rake yard task for local HTML docs

Changed

  • Internal @api private IBAN country-data module dissolved into the SecID::IBAN class, so constant names match their file paths per Ruby convention: SecID::IBANCountryRules::COUNTRY_RULES / LENGTH_ONLY_COUNTRIES are now SecID::IBAN::COUNTRY_RULES / SecID::IBAN::LENGTH_ONLY_COUNTRIES
  • SecID.valid? and SecID.parse are faster and allocate less — SecID.valid? short-circuits on the first matching type instead of running a full detect-and-sort, and SecID.parse reuses the instance built during detection instead of instantiating the matched type a second time
  • BREAKING: SecID::CFI.valid? is now strict at the attribute level for all 14 categories (including the derivative categories S, H, J) — codes with attribute letters outside the ISO 10962:2021 tables, previously accepted, are now invalid (e.g. CFI.valid?('ESZZZZ') returns false). There is no leniency option, matching every other identifier type in the gem. The ActiveModel validator inherits this strictness automatically
  • BREAKING: SecID::CFI group tables corrected to ISO 10962:2021 — six categories carried wrong group letters. Non-listed options (H) are now classified by underlying (HR Rates, HT Commodities, HE Equity, HC Credit, HF FX, HM Others) instead of the copied listed-option call/put shape; D gains DE (structured products without capital protection) and corrects DG/DA/DN (dropping the invented municipal_notes); L becomes LL/LR/LS, T becomes TC/TT/TR/TI/…, and the phantom FM/IM/JM/LM groups are removed. Group symbols renamed accordingly (e.g. LS:securities_lending, TI:indices, MM:other_assets). Real H-category codes that were rejected now validate; codes relying on the old wrong letters no longer do

Removed

  • BREAKING: the 12 hardcoded equity predicate helpers on SecID::CFI (equity?, voting?, non_voting?, restricted_voting?, enhanced_voting?, restrictions?, no_restrictions?, fully_paid?, nil_paid?, partly_paid?, bearer?, registered?). They were category-wide and semantically wrong for non-equity groups. Migration: use cfi.decode and its scoped field predicates — cfi.voting?cfi.decode.attributes.voting_right.voting? — which answer only on the field whose ISO domain defines the concept. Two names do not carry over name-for-name: migrate cfi.equity? to cfi.decode.category.equity? (or cfi.category == :equity, unchanged), and cfi.no_restrictions? to cfi.decode.attributes.ownership_restrictions.free_of_restrictions? (the ISO value name)

[5.2.0] - 2026-02-24

Added

  • SecID.scan and SecID.extract methods for finding identifiers in freeform text — returns Scanner::Match objects (Data.define(:type, :raw, :range, :identifier)) with the validated identifier instance; supports types: filtering, hyphenated identifiers, and compound patterns (OCC with spaces, FISN with slashes)
  • SecID.explain method for debugging identifier detection — returns per-type validation results showing exactly why each type matched or rejected the input
  • on_ambiguous: option for SecID.parse and SecID.parse!:first (default, existing behavior), :raise (raises AmbiguousMatchError), :all (returns array of all matching instances)
  • SecID::AmbiguousMatchError exception class for ambiguous identifier detection
  • #as_json method on all identifier types (delegates to #to_h) and on Errors (delegates to #details) for JSON serialization compatibility
  • SecID::IBAN.supported_countries class method returning sorted array of all supported country codes
  • SecID::CFI.categories class method returning the categories hash
  • SecID::CFI.groups_for(category_code) class method returning groups hash for a given category

[5.1.0] - 2026-02-19

Added

  • #==, #eql?, and #hash methods on all identifier types — two instances of the same type with the same normalized form are equal and usable as Hash keys / in Sets
  • #to_h method on all identifier types for consistent hash serialization — returns { type:, full_id:, normalized:, valid:, components: } with type-specific component hashes (e.g. ISIN: country_code, nsin, check_digit)
  • #to_pretty_s and .to_pretty_s display formatting methods on all identifier types, returning a human-readable string or nil for invalid input — with type-specific formats for IBAN (4-char groups), LEI (4-char groups), ISIN (CC + NSIN + CD), CUSIP (cusip6 + issue + CD), FIGI (prefix+G + random + CD), OCC (space-separated components), and Valoren (thousands grouping)
  • Lookup service integration guides and runnable examples for OpenFIGI, SEC EDGAR, GLEIF, and Eurex APIs (docs/guides/, examples/)
  • GitHub community standards files: Code of Conduct, Contributing guide, Security policy, issue templates, and PR template

[5.0.0] - 2026-02-17

Added

  • Validatable, Normalizable, and IdentifierMetadata concerns extracted from Base, making each responsibility independently includable
  • #validate and .validate methods on all identifier types that eagerly trigger + cache errors and return self/instance
  • #validate! and .validate! methods that raise InvalidFormatError, InvalidCheckDigitError, or InvalidStructureError on validation failure, returning self/instance on success
  • Rails-like #errors API returning Errors with details, messages, none?, any?, empty?, size, each, and to_a on all identifier classes, with type-specific error detection for check digits, FIGI prefixes, CFI categories/groups, IBAN BBAN format, and OCC dates
  • #restore instance method on check-digit identifiers returning the full identifier string without mutation
  • .restore class method on check-digit identifiers returning the full identifier string
  • SecID.parse(str, types: nil) and SecID.parse!(str, types: nil) methods that return a typed identifier instance for the most specific match, with optional type filtering
  • SecID.valid?(str, types: nil) method for quick boolean validation against all or specific identifier types
  • SecID.detect(str) method that identifies all matching identifier types for a given string, returning symbols sorted by specificity
  • Metadata registry: SecID.identifiers returns all identifier classes, SecID[:isin] looks up by symbol key
  • Metadata class methods on all identifiers: short_name, full_name, id_length, example, has_check_digit?
  • #normalized and #normalize instance methods on all identifier types returning the canonical string form
  • #normalize! instance method on all identifier types that mutates full_id to canonical form and returns self
  • .normalize(id) class method on all identifier types that strips separators, upcases, validates, and returns the canonical string
  • SEPARATORS constant in Normalizable (/[\s-]/) included in Base, with type-specific overrides for OCC and FISN (/-/)

Changed

  • BREAKING: Minimum Ruby version raised from 3.1 to 3.2 (Ruby 3.1 reached EOL on 2025-03-31)
  • BREAKING: #restore! now returns self instead of a string; use #restore for the string return value
  • BREAKING: .restore! now returns the restored instance instead of a string; use .restore for the string return value
  • BREAKING: #normalize! on CIK, OCC, and Valoren now returns self instead of a string; use #normalized to get the canonical string
  • BREAKING: Class-level .normalize! on CIK, OCC, and Valoren replaced by .normalize (non-bang) which returns the canonical string
  • BREAKING: Base#parse always upcases input; the upcase keyword parameter is removed
  • BREAKING: #full_number renamed to #full_id on all identifier types
  • BREAKING: Ruby module renamed from SecId to SecID (e.g. SecId::ISINSecID::ISIN)
  • Luhn helper methods in Checkable are now private (implementation detail)

Removed

  • Class-level .normalize! on CIK, OCC, and Valoren — replaced by .normalize
  • upcase keyword parameter from Base#parse
  • #valid_format? instance method (now private) and .valid_format? class method
  • OCC#full_symbol method — use #full_id instead

Fixed

  • to_str now always returns the same value as to_s across all identifier types — previously LEI, IBAN, and Checkable identifiers could return divergent strings due to Ruby alias resolving to the parent class method
  • OCC #date memoization for invalid dates — previously re-attempted parsing on every call instead of caching nil
  • LEI restore and to_s now correctly pad single-digit check digits to 2 characters
  • Valoren#to_isin no longer mutates the source instance

[4.4.1] - 2026-02-05

Fixed

  • CUSIP#to_isin and SEDOL#to_isin no longer mutate source instance when check digit is missing (#127)

[4.4.0] - 2026-01-29

Added

  • Cross-identifier conversions: SEDOL, WKN, and Valoren to_isin methods with country code validation; ISIN to_sedol, to_wkn, to_valoren methods with predicate helpers (sedol?, wkn?, valoren?) (#115)
  • ISIN nsin_type and to_nsin methods for country-aware NSIN extraction (#114)
  • CEI (CUSIP Entity Identifier) support for syndicated loan market entity identification (#113)
  • FISN (Financial Instrument Short Name) support per ISO 18774 (#112)
  • CFI (Classification of Financial Instruments) support with category/group validation and equity-specific predicates (#111)
  • Valoren support (Swiss Security Number) (@wtn, #109)
  • WKN support (Wertpapierkennnummer - German securities identifier) (@wtn, #108)

Changed

  • Replaced has_check_digit DSL with explicit Checkable concern that consolidates all check-digit logic (constants, Luhn algorithms, validation, restoration)
  • Simplified Base class to core validation and parsing; check-digit classes now include Checkable
  • Non-check-digit classes (CIK, OCC, WKN, Valoren, CFI, FISN) no longer need any special declaration
  • Moved Normalizable module to lib/sec_id/concerns/ for consistency with other concerns
  • Optimized hot paths by replacing &method(:char_to_digit) with inline blocks to avoid Method object allocation
  • Added frozen Set constants for ISIN country code lookups (SEDOL_COUNTRY_CODES, VALOREN_COUNTRY_CODES)

Fixed

  • Allow Crown Dependencies (GG, IM, JE) and Overseas Territories (FK) in SEDOL/ISIN conversions (@wtn, #117)
  • Removed BR (Brazil) from CGS country codes — Brazil never used CINS numbers and Brazilian ISINs cannot be converted to CUSIP (@wtn, #110)

[4.3.0] - 2025-01-13

Added

  • LEI support (Legal Entity Identifier, ISO 17442)
  • IBAN support (International Bank Account Number, ISO 13616) with EU/EEA country validation

Changed

  • Improved README: better formatting, navigation, and clear API distinction between check-digit and normalization identifiers
  • Refactored CIK and OCC to inherit from Base class with has_check_digit? hook for cleaner architecture
  • Added Normalizable module for consistent normalize! class method across identifiers
  • Added validate_format_for_calculation! helper method to Base class to reduce code duplication
  • Added comprehensive YARD documentation to all classes (public and private methods)
  • Applied Stepdown Rule to method ordering throughout codebase
  • Created shared RSpec examples for edge cases (nil, empty, whitespace inputs)
  • Created shared RSpec examples for check-digit and normalization identifiers
  • Applied shared examples to all identifier specs, removing ~350 lines of duplicate test code
  • Improved test maintainability with 582 tests covering all identifier types

[4.2.0] - 2025-01-12

Added

Changed

  • Separate CIK from Base for cleaner architecture (@wtn, #92)
  • Use rubocop-rspec plugin (@wtn, #90)
  • Replace CodeClimate with Codecov
  • Add permissions to CI workflow
  • Clean up gemspec: update description and simplify files list

Fixed

  • CUSIP#cins? usage example in README (@wtn, #91)

[4.1.0] - 2024-09-23

Added

Changed

  • Small internal refactorings

[4.0.0] - 2024-07-09

Changed

  • BREAKING: Minimum required Ruby version is 3.1 now
  • BREAKING: Default repository branch renamed to main
  • Small internal refactorings
  • TravisCI -> GitHub Actions
  • Dropped tests for Ruby below 3.1
  • Rubocop's Ruby target version changed to 3.1

[3.0.0] - 2020-03-10

Changed

  • BREAKING: Minimum required Ruby version is 2.5 now
  • Small internal refactorings
  • TravisCI config updated: dropped Ruby 2.3 and 2.4, added Ruby 2.7
  • Rubocop's Ruby target version changed to 2.5

[2.0.0] - 2019-02-03

Added

  • SEDOL numbers support: SecID::SEDOL

Changed

  • BREAKING: API for accessing full number is unified across all classes:

    ```
    SecId::ISIN#full_number  # previously SecId::ISIN#isin
    SecId::CUSIP#full_number # previously SecId::CUSIP#cusip
    SecId::SEDOL#full_number
    ```
    

Fixed

  • CUSIP check-digit algorithm fixed

[1.1.0] - 2019-02-03

Added

  • CUSIP numbers support: SecID::CUSIP
  • CHANGELOG.md file added

Changed

  • Char to digit conversion now uses precalculated tables instead of dynamic calculation for speed

[1.0.0] - 2017-10-25

Added

  • ISIN numbers support: SecID::ISIN