Project

edoxen

0.0
The project is in a healthy, maintained state
Edoxen provides a Ruby library for working with resolution models, allowing users to create, manipulate, and serialize resolution data in a structured format. It is built on top of the lutaml-model serialization framework, which provides a flexible and extensible way to define data models and serialize them to YAML or JSON formats.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

 Project Readme

Edoxen

GitHub Stars License Build Status RubyGems Version

Purpose

Edoxen is a Ruby library for the canonical Edoxen information model of formal resolutions and decisions. It is built on top of the lutaml-model serialization framework.

The information model is defined in LutaML UML files (one .lutaml per concept). This gem mirrors that model exactly — attribute declarations, enum values, and field shapes — so that anything you can express in LutaML you can also construct, serialize, and validate in Ruby.

The intended users are standards organizations and governance bodies that need to publish structured records of formal decision-making. Real-world samples (ISO/TC 154, CIPM, OIML CIML) ship in spec/fixtures/.

Installation

gem 'edoxen'

Then bundle install, or gem install edoxen standalone.

Quick start

require 'edoxen'

yaml = File.read('resolutions.yaml')
collection = Edoxen::ResolutionCollection.from_yaml(yaml)

collection.resolutions.each do |resolution|
  # identifier is StructuredIdentifier[1..*] — a Resolution can carry
  # its TC number, SC number, and any cross-cutting reference number.
  id = resolution.identifier.first
  puts "#{id.prefix}/#{id.number}"

  # Canonical rendering — English when available, else the first
  # declared localization. See "Lookup accessors" below.
  loc = resolution.primary_localization
  puts "  [#{loc.language_code}/#{loc.script}] #{loc.title}"

  loc.actions.each do |action|
    puts "    - #{action.type}: #{action.message}"
  end

  # Or look up a specific language explicitly:
  fra = resolution.in_language("fra")
  puts "  FR: #{fra.title}" if fra
end

# Round-trip back to YAML
puts collection.to_yaml

Data model

The full model is in lib/edoxen/*.rb (Ruby) and schema/edoxen.yaml (JSON-Schema). Both are kept in lockstep by spec/edoxen/schema_enum_sync_spec.rb.

ResolutionCollection
├── metadata:  ResolutionMetadata
│                ├── title / title_localized[]
│                ├── date
│                ├── source
│                ├── source_urls: SourceUrl[]
│                ├── city, country_code
└── resolutions: Resolution[]
                   ├── identifier: StructuredIdentifier[1..*]
                   ├── type: ResolutionType (resolution | recommendation | decision | declaration)
                   ├── doi, urn, agenda_item
                   ├── dates: ResolutionDate[]
                   ├── categories: String[]
                   ├── meeting: MeetingIdentifier
                   ├── relations: ResolutionRelation[]
                   ├── urls: Url[]
                   └── localizations: Localization[1..*]
                                      ├── language_code (ISO 639-3)
                                      ├── script       (ISO 15924)
                                      ├── title, subject, message, considering
                                      ├── considerations: Consideration[]
                                      ├── approvals: Approval[]
                                      └── actions: Action[]

Every translatable field on a Resolution is wrapped inside one of its localizations[] entries. Language-agnostic admin fields (identifier, doi, urn, agenda_item, dates, categories, meeting, relations, urls) live on the parent Resolution.

Lookup accessors

Direct iteration over localizations[] is discouraged — the language-preference policy lives behind two accessors:

  • Resolution#in_language(code, fallback: false) — exact match by ISO 639-3 code, or nil (or the first declared localization when fallback: true).

  • Resolution#primary_localization — English when available, else the first declared localization. Mirrors the glossarist LocalizedConcept "preferred language" notion.

resolution.in_language("fra")           # => Localization or nil
resolution.in_language("deu", fallback: true)
resolution.primary_localization         # English-or-first

Multilingual resolution sets

resolutions:
  - identifier:
      - prefix: CIML
        number: "2025-44"
    doi: 10.63493/resolutions/ciml202544
    agenda_item: "16.2"
    dates:
      - date: 2025-10-13
        type: discussed
    localizations:
      - language_code: eng
        script: Latn
        title: Decision on the renewal of the contract of Mr Anthony Donnellan
        subject: CIML
        actions:
          - type: decides
            date_effective:
              date: 2025-10-13
              type: adoption
            message: |
              The Committee decides to renew the contract of
              Mr Anthony Donnellan as BIML Director.
      - language_code: fra
        script: Latn
        title: Décision sur le renouvellement du contrat de M. Anthony Donnellan
        subject: CIML
        actions:
          - type: decides
            date_effective:
              date: 2025-10-13
              type: adoption
            message: |
              Le Comité décide de renouveler le contrat de
              M. Anthony Donnellan en tant que Directeur du BIML.

Command-line interface

The edoxen executable exposes four commands — two for Resolution collections, two for Meeting/Agenda documents.

validate — JSON-Schema validation + model parsing for Resolutions

$ edoxen validate "spec/fixtures/*.yaml"

Runs both Edoxen::SchemaValidator and Edoxen::ResolutionCollection.from_yaml against each matching file. The schema catches additionalProperties, required, enum, and pattern violations; the model catches structural problems the schema can’t express.

Exit code is non-zero if any file fails.

normalize — round-trip Resolution YAML through the model

$ edoxen normalize "spec/fixtures/*.yaml" --output clean/
$ edoxen normalize legacy.yaml --inplace

Loads each YAML file, parses it through the Ruby model, and writes the result back. Either --output DIR or --inplace is required (mutually exclusive).

validate-meetings — validate Meeting/Agenda YAML

$ edoxen validate-meetings "spec/fixtures/meetings/*.yaml"

Validates against schema/meeting.yaml and parses through Edoxen::Meeting (single-Meeting files) or Edoxen::MeetingCollection (wrappers). The schema’s root is oneOf — both shapes are accepted.

normalize-meetings — round-trip Meeting YAML through the model

$ edoxen normalize-meetings "spec/fixtures/meetings/*.yaml" --output clean/

Same shape as normalize, but for Meeting/Agenda documents.

Meeting & Agenda model

The Meeting model represents the event that produces Resolutions, plus the agenda document that orders its business. Mirrors edoxen-model/models/meeting*.lutaml.

MeetingCollection
├── metadata:  MeetingCollectionMetadata
└── meetings: Meeting[]
              ├── identifier: StructuredIdentifier[1..*]
              ├── urn, ordinal, type (MeetingType), status (MeetingStatus), year
              ├── date_range: DateRange {start, end}
              ├── committee, committee_group
              ├── venues: Location[]   (multi-venue supported)
              ├── general_area, city (IATA), country_code (ISO 3166-1), virtual
              ├── chair, secretary: Person
              ├── host, hosts: HostRef[]   (typed: national_body/liaison/associate/organizer)
              ├── source_urls: SourceUrl[]   (with `kind` discriminator)
              ├── landing_url, registration_url
              ├── agenda: Agenda
              │            ├── identifier, status (AgendaStatus), source_doc
              │            ├── items: AgendaItem[]
              │            │            ├── label, kind (numbered/unnumbered/header/opening/closing)
              │            │            ├── title, description
              │            │            ├── references: Reference[]
              │            │            ├── outcome (discussed/resolved/deferred/adopted/withdrawn)
              │            │            └── resolution_ref (URN link to a Resolution)
              │            └── opening_session, closing_session: ScheduleItem
              ├── schedule: ScheduleItem[]   (the time-bound timetable)
              ├── deadlines: Deadline[]
              ├── localizations: MeetingLocalization[]   (per-language content)
              ├── relations: MeetingRelation[]   (continues_from, joint_with, ...)
              └── resolution_refs: String[]   (URN links to ResolutionCollections)

Linking Meetings to ResolutionCollections

Meetings and ResolutionCollections are separate documents joined by URN:

  • Meeting.resolution_refs: [urn:…​]

  • ResolutionCollection.metadata.meeting_urn: urn:…​

The join is by URN because the two have different lifetimes — agendas exist weeks before a meeting, resolutions only after adoption.

Lookup accessors (parallel to the Resolution side)

meeting = Edoxen::Meeting.from_yaml(File.read('meeting.yaml'))

meeting.in_language("fra")                  # => MeetingLocalization or nil
meeting.in_language("deu", fallback: true)  # falls back to first
meeting.primary_localization                # English-or-first
meeting.find_agenda_item("5.2")             # AgendaItem by label

collection = Edoxen::MeetingCollection.from_yaml(...)
collection.find_by_urn("urn:oiml:ciml:meeting:ciml-56")
collection.find_by_identifier(prefix: "CIML", number: "56")

Architecture

  • lib/edoxen.rb is the single entry-point. It configures Lutaml::Model::Config and autoload`s every model class and service. No `require_relative in library code — cross-references resolve through Ruby autoload.

  • Each model lives in its own file under lib/edoxen/. Models declare attribute only — lutaml-model auto-emits an identity map (wire name = snake_case attribute name) when no explicit key_value block is present. Add a key_value do; map "wire", to: :attr; end block only when the wire name differs from the attribute name.

  • lib/edoxen/enums.rb is the single source of truth for every enum value used by the gem. Both the Ruby model (attribute :type, :string, values: Enums::ACTION_TYPE) and the schemas reference the same constants.

  • lib/edoxen/error.rb defines the unified Edoxen::ValidationError — produced by both SchemaValidator (with source: :schema or :syntax) and by model parse rescues in the CLI (with source: :model). Carries file, line, column, pointer, message_text, and source.

  • lib/edoxen/schema_validator.rb is intentionally small: two validate methods, a LineMap module for line-accurate error reporting (longest-prefix match — no path-shape hardcoding), and date coercion so json_schemer can validate format: date against YAML-loaded Date instances.

  • lib/edoxen/cli.rb exposes four Thor commands (validate, normalize, validate-meetings, normalize-meetings) that delegate their shared scaffolding (expand/sort/empty/header/loop/tally/summary/exit) to the deep Edoxen::Cli::Batch module.

Schema ↔ Ruby invariants

Two pairs of runtime specs guard each side of the schema ↔ Ruby boundary:

  • Resolution side: schema_enum_sync_spec.rb
    schema_model_sync_spec.rb against schema/edoxen.yaml.

  • Meeting side: schema_meeting_enum_sync_spec.rb
    schema_meeting_model_sync_spec.rb against schema/meeting.yaml.

Drift fails CI immediately, not at fixture-validation time.

Contributing

Follow the rules in CLAUDE.md:

  • All public methods have specs.

  • Specs use real model instances — never double().

  • Serialization goes through lutaml-model only — no hand-rolled to_h, from_h, to_yaml, or to_json on a model class. Declare attribute; the wire map is auto-emitted.

  • Schema and Ruby must agree on enum values and on property shape. Both schema_enum_sync_spec and schema_model_sync_spec catch drift at CI time.

  • Library code uses autoload (declared in lib/edoxen.rb), never require_relative. No send to private methods, no instance_variable_set/get, no respond_to? for type checks.

  • All changes go through PRs. Never commit to main, never push tags, never add AI attribution to commits.

License

BSD-2-Clause. Copyright Ribose Inc.