GitHub Stars License Build Status RubyGems Version

Purpose

Edoxen is a Ruby library for the canonical Edoxen information model — a generic meeting, agenda, motion, voting, and decision model that covers standards bodies (ISO, IEC, ITU, BIPM, OIML, ILO), parliamentary bodies (UK Hansard, HK LegCo, US Congress), technical community meetings (IETF, W3C, Apache), academic conferences (Crossref-registered), corporate boards, and generic web/virtual meetings.

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 anything expressible in LutaML is constructable, serializable, and validatable in Ruby.

Generic core + profile extensions

The core schema is the intersection of all domains. Domain-specific concepts (Bill, Witness, Petition, Address, Quorum Bell, etc.) live in profile extensions via the MeetingExtension slot every core entity carries — the ISO 8601-2 §15 profile mechanism. Adopters register a profile namespace (legco, us-congress, ietf, oiml) and define extension kinds within it; consumers ignore profiles they don’t understand.

Installation

gem 'edoxen'

Then bundle install, or gem install edoxen standalone.

Quick start

require 'edoxen'

yaml = File.read('decisions.yaml')
collection = Edoxen::DecisionCollection.from_yaml(yaml)

collection.decisions.each do |decision|
  id = decision.identifier.first
  puts "#{id.prefix}/#{id.number}"

  loc = decision.primary_localization
  puts "  [#{loc.language_code}/#{loc.script}] #{loc.title}"

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

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

# Round-trip back to YAML
puts collection.to_yaml

The five faces of a meeting

A meeting has five concerns, each modelled as a distinct first-class entity rather than a column on a flat table:

Concern

What it captures

Top-level

Decisions

Formal outcomes adopted by the meeting: resolution, order, ruling, determination, recommendation, statement, finding, opinion.

DecisionCollection

Motions + Votings

The procedural record — who moved what, how the question was put, how members voted, what the chair declared.

Meeting.motions[] / Meeting.votings[]

Topics

The subjects of discussion. A topic carries documents, assets, references, and URN links to the motions and decisions it produced.

AgendaItem.topics[]

Meetings

The event itself: identifier, dates, polymorphic venues, officers, agenda, components, attendance, minutes.

MeetingCollection

Series

The recurring parent of meetings — annual plenaries, monthly board meetings, IETF meeting series, etc.

(top-level standalone)

A Meeting carries its decisions, motions, and votings directly. The DecisionCollection is the standalone form for publishing decisions without meeting-level detail.

Data model

The full model is in lib/edoxen/*.rb (Ruby) and schema/edoxen.yaml + schema/meeting.yaml (JSON-Schema). Both are kept in lockstep by runtime sync specs.

DecisionCollection                        MeetingCollection
├── metadata: DecisionMetadata            ├── metadata: MeetingCollectionMetadata
│   ├── title / title_localized[]         └── meetings: Meeting[]
│   ├── date, source                          ├── identifier, urn, ordinal
│   ├── source_urls[]                         ├── series_ref (→ MeetingSeries)
│   ├── city (UN/LOCODE), country_code        ├── type, status, visibility
│   └── meeting_urn (back-ref to Meeting)     ├── date_range, recurrence
└── decisions: Decision[]                     ├── venues: Venue[]  (polymorphic)
    ├── identifier: StructuredIdentifier[1..*]|    ├── kind: physical | virtual
    ├── kind (DecisionKind)                    |    ├── name, label, capacity
    ├── status (DecisionStatus)                |    ├── (physical) unlocode, iata_code,
    ├── doi, urn, agenda_item                  |    │             address, country_code, lat, lon
    ├── dates: DecisionDate[]                  |    └── (virtual)  uri, features, passcode,
    ├── categories, relations, urls            ├── officers: Officer[] (role + person + term)
    ├── brought_by_motions[]                   ├── hosts: HostRef[] (typed)
    ├── about_topics[]                         ├── source_urls[], landing_url, registration_url
    ├── made_in_component                      ├── agenda: Agenda (items, status)
    └── localizations: Localization[1..*]      ├── components: MeetingComponent[]
         ├── language_code, script             │    (track, session, debate, breakout, keynote,
         ├── title, subject, message           │     opening, closing, break, reception, ...)
         ├── considerations: Consideration[]   ├── deadlines: Deadline[]
         ├── approvals: Approval[]             ├── attendance: Attendance[]
         └── actions: Action[]                 │    (status, role, response, proxy_for)
                                              ├── minutes: Minutes[]
Motion                                        ├── motions: Motion[]
├── identifier, urn                           │    (status: introduced → seconded → debating
├── text, mover, seconders[]                  │     → question_put → voting → carried/negatived)
├── status (MotionStatus)                     ├── votings: Voting[]
├── introduced_at                             │    (status: called → in_progress → decided;
├── proposed_decision, resulting_decision     │     voting_method, counts, casting_vote,
└── votings[]                                 │     vote_records[])
                                              ├── decisions: Decision[]  (inline)
Voting                                        ├── localizations: MeetingLocalization[]
├── status (VotingStatus)                     ├── relations: MeetingRelation[]
├── voting_method (VotingMethod)              └── extensions: MeetingExtension[]
├── result (VotingOutcome)
├── counts: VotingCounts {ayes, noes,
│                        abstentions, absent}
├── casting_vote: VoteRecord
└── vote_records: VoteRecord[]

MeetingExtension (profile mechanism)
├── profile (e.g. "legco", "ietf", "us-congress")
├── kind (in-profile discriminator)
├── ref (URN to external profile document)
└── attributes: ExtensionAttribute[]
     ├── key
     ├── type (string | integer | float | boolean | date | datetime)
     └── value / integer_value / float_value / boolean_value /
         date_value / date_time_value (one of, per `type`)

Every core entity has an extensions: MeetingExtension[0..*] slot. Adopters extend the generic core without modifying it.

ExtensionAttribute is polymorphic on value type — consumers read the typed payload via #typed_value without re-parsing strings back into Int/Float/Bool/Date. The v2.0 bare value: String wire shape still parses (routed into the string variant).

Polymorphic Venue

A Venue is one flat class on the wire; the kind field discriminates physical vs virtual and validators enforce that fields match kind.

  • Physical: UN/LOCODE + IATA + address + geo-coordinates.

  • Virtual: URI + iCalendar-style features + access details.

A meeting can have multiple venues of either kind (hybrid meetings, multi-room conferences bridged by video, etc.). UN/LOCODE and IATA codes are validated against the canonical unlocodes and iata gems by Edoxen::VenueValidator.

Lookup accessors

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

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

  • Decision#primary_localization — English when available, else the first declared localization.

The same pair exists on Meeting (MeetingLocalization).

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

meeting.chair                          # => Person (via officers + role)
meeting.secretary                      # => Person
meeting.officers_with_role(:treasurer) # => [Officer]
meeting.hybrid?                        # true if both physical + virtual venues
meeting.physical_only? / meeting.virtual_only?

validator = Edoxen::VenueValidator.new(venue)
validator.valid?                       # checks UN/LOCODE + IATA against the gems
validator.validate(auto_populate: true) # also fills city/country_code from UN/LOCODE

Multilingual by default

Per-language content lives inside localizations[] siblings — one entry per language. Admin fields (identifier, doi, urn, dates, meeting) are declared once on the parent; translatable content (title, subject, considerations, actions, approvals) lives inside each Localization.

This pattern — borrowed from the glossarist project — lets translators work on each language in isolation while comparing them side-by-side in the same file. Drift shows up on git diff, not on a translator’s spreadsheet.

decisions:
  - identifier:
      - prefix: CIML
        number: "2025-44"
    kind: resolution
    status: decided
    doi: 10.63493/decisions/ciml202544
    agenda_item: "16.2"
    dates:
      - date: 2025-10-13
        type: adoption
    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 six commands.

validate — schema + model validation for Decisions

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

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

normalize — round-trip Decision YAML through the model

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

Either --output DIR or --inplace is required (mutually exclusive).

validate-meetings / normalize-meetings

Same shape, for Meeting/Agenda YAML against schema/meeting.yaml.

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

The schema’s root is oneOf — both single-Meeting and MeetingCollection shapes are accepted.

unlocode CODE / iata CODE — registry lookup

$ edoxen unlocode FRPAR
UN/LOCODE:  FRPAR
  Name:      Paris
  Country:   FR
  ...

$ edoxen iata JFK
IATA:       JFK
  Name:      John F. Kennedy International Airport
  Country:   US

Profile mechanism (ISO 8601-2 §15)

Every core entity has an extensions: MeetingExtension[0..*] slot. Adopters register a profile namespace and define extension kinds within it. MeetingExtension carries three identity fields plus a typed attributes[] list:

  • profile — the namespace (lowercase, hyphen-separated).

  • kind — discriminator within the profile.

  • ref — URN of an external profile document, when the data lives elsewhere.

  • attributes[] — typed key/value pairs (ExtensionAttribute).

ExtensionAttribute is polymorphic on value type (v2.1+). Set type to one of string | integer | float | boolean | date | datetime and populate the matching value field:

extensions:
  - profile: legco
    kind: vote_block
    ref: urn:legco:vote-block:2024-01-15:item-5
  - profile: ietf
    kind: wg_meeting_meta
    attributes:
      - key: wg_name
        type: string
        value: quic
      - key: draft_name
        type: string
        value: draft-ietf-quic-v2
      - key: quorum
        type: integer
        integer_value: 7
      - key: live_stream
        type: boolean
        boolean_value: true
      - key: start
        type: datetime
        date_time_value: 2026-07-04T10:00:00Z

Read the typed payload in Ruby via #typed_value:

attr = extension.attributes.find { |a| a.key == "quorum" }
attr.type            # => "integer"
attr.integer_value   # => 7
attr.typed_value     # => 7

The v2.0 bare value: String wire shape still parses (routed into the string variant; type defaults to string).

Consumers ignore profile extensions they don’t understand. See the HK LegCo profile example for a real-world reference.

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.

  • lib/edoxen/enums.rb is the single source of truth for every enum value used by the gem. Both the Ruby model (attribute :kind, :string, values: Enums::DECISION_KIND) 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).

  • 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 six Thor commands (validate, normalize, validate-meetings, normalize-meetings, unlocode, iata) that delegate their shared scaffolding to the deep Edoxen::Cli::Batch module.

  • lib/edoxen/venue_validator.rb validates polymorphic Venue instances using the unlocodes and iata gems; optionally auto-populates city and country_code from the UN/LOCODE registry.

Schema ↔ Ruby invariants

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

  • Decision 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.

Migrating from v0.x (Resolution → Decision)

v2.0 is a breaking release. The migration is mechanical:

Resolution class

Decision class with kind: resolution

ResolutionType enum (4 values)

DecisionKind enum (9 values: resolution, order, ruling, determination, recommendation, statement, finding, opinion, other)

ResolutionCollection

DecisionCollection

Meeting.virtual: Boolean

Meeting.venues: Venue[] (polymorphic)

Meeting.chair / Meeting.secretary

Meeting.officers: Officer[] (role discriminates)

Meeting.schedule[] (ScheduleItem)

Meeting.components: MeetingComponent[] (flat)

Resolution alone

Decision + Motion + Voting (procedural core)

(none)

Topic + TopicDocument + TopicAsset

(none)

MeetingSeries, Recurrence (ISO 8601-2 §13)

(none)

MeetingExtension (profile mechanism, ISO 8601-2 §15)

See the migration guide for full details.

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.