Edoxen
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_yamlData 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, ornil(or the first declared localization whenfallback: 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-firstMultilingual 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 --inplaceLoads 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.rbis the single entry-point. It configuresLutaml::Model::Configandautoload`s every model class and service. No `require_relativein library code — cross-references resolve through Ruby autoload. -
Each model lives in its own file under
lib/edoxen/. Models declareattributeonly —lutaml-modelauto-emits an identity map (wire name = snake_case attribute name) when no explicitkey_valueblock is present. Add akey_value do; map "wire", to: :attr; endblock only when the wire name differs from the attribute name. -
lib/edoxen/enums.rbis 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.rbdefines the unifiedEdoxen::ValidationError— produced by bothSchemaValidator(withsource: :schemaor:syntax) and by model parse rescues in the CLI (withsource: :model). Carriesfile,line,column,pointer,message_text, andsource. -
lib/edoxen/schema_validator.rbis intentionally small: two validate methods, aLineMapmodule for line-accurate error reporting (longest-prefix match — no path-shape hardcoding), and date coercion sojson_schemercan validateformat: dateagainst YAML-loadedDateinstances. -
lib/edoxen/cli.rbexposes four Thor commands (validate,normalize,validate-meetings,normalize-meetings) that delegate their shared scaffolding (expand/sort/empty/header/loop/tally/summary/exit) to the deepEdoxen::Cli::Batchmodule.
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.rbagainstschema/edoxen.yaml. -
Meeting side:
schema_meeting_enum_sync_spec.rb
schema_meeting_model_sync_spec.rbagainstschema/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-modelonly — no hand-rolledto_h,from_h,to_yaml, orto_jsonon a model class. Declareattribute; the wire map is auto-emitted. -
Schema and Ruby must agree on enum values and on property shape. Both
schema_enum_sync_specandschema_model_sync_speccatch drift at CI time. -
Library code uses autoload (declared in
lib/edoxen.rb), neverrequire_relative. Nosendto private methods, noinstance_variable_set/get, norespond_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.