Skip to content

Repository files navigation

Edoxen

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}"

  # Per-field Localized (ISO 24229): every translatable field is an
  # array of { spelling, value } entries. Pick the one you want.
  title = decision.title.find { |l| l.spelling == "eng" }&.value
  puts "  [eng] #{title}"

  fra = decision.title.find { |l| l.spelling == "fra" }&.value
  puts "  [fra] #{fra}" if fra

  decision.actions.each do |action|
    msg = action.message.find { |l| l.spelling == "eng" }&.value
    puts "    - #{action.type}: #{msg}"
  end
end

# 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: LocalizedString[]          └── 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)     ├── scheduled_date_range, occurred_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)
    ├── title: LocalizedString[]               ├── components: MeetingComponent[]
    ├── subject: LocalizedString[]             │    (track, session, debate, breakout, keynote,
    ├── message: LocalizedString[]             │     opening, closing, break, reception, ...)
    ├── considering: LocalizedString[]         ├── deadlines: Deadline[]
    ├── considerations: Consideration[]        ├── attendance: Attendance[]
    │   └── message: LocalizedString[]         │    (status, role, response, proxy_for)
    ├── approvals: Approval[]                  ├── minutes: Minutes[]
    │   └── message: LocalizedString[]         ├── motions: Motion[]
    └── actions: Action[]                      │    (status: introduced → seconded → debating
         └── message: LocalizedString[]        │     → question_put → voting → carried/negatived)
                                              ├── votings: Voting[]
Motion                                        │    (status: called → in_progress → decided;
├── identifier, urn                           │     voting_method, counts, casting_vote,
├── text: LocalizedString[]                   │     vote_records[])
├── mover, seconders[]                        ├── decisions: Decision[]  (inline)
├── status (MotionStatus)                     ├── declarations: Declaration[]   (BS 0:2006 §7.6)
├── introduced_at                             ├── relations: MeetingRelation[]
├── proposed_decision, resulting_decision     └── extensions: MeetingExtension[]
└── votings[]
                                              MeetingSeries (top-level standalone)
Voting                                        ├── identifier, urn
├── status (VotingStatus)                     ├── name: LocalizedString[]
├── voting_method (VotingMethod)              ├── recurrence
├── result (VotingOutcome)                    ├── meeting_refs[]
├── counts: VotingCounts {ayes, noes,         └── extensions: MeetingExtension[]
│                        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`)

Statement (BS 0:2006 §7.6)
├── kind (StatementKind: statement | comment | standpoint)
├── description: LocalizedString[]
├── party: Person[]
└── extensions: MeetingExtension[]

Declaration (BS 0:2006 §7.6)
├── kind (DeclarationKind: conflict_of_interest | ipr)
├── description: LocalizedString[]
├── party: Person[]
├── ipr_subject_ref: EntityRef       (only when kind == "ipr")
├── ipr_target_ref: EntityRef        (only when kind == "ipr")
└── extensions: MeetingExtension[]

DateTimeRange
├── start: DateTime
└── end: DateTime

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 pre-1.0 bare value: String wire shape still parses (routed into the string variant).

Per-field multilingual (ISO 24229)

Every translatable field is Localized<String|Name>[0..] — an array with one entry per ISO 24229 *spelling code. There is no separate Localization[] collection; each field carries its own language tags inline.

  • LocalizedString{ spelling, value: String, extensions }.

  • LocalizedName{ spelling, value: Name, extensions }.

  • spelling accepts ISO 24229 spelling-system codes ({lang}-{script}[-{country}][-{extension}], e.g. zho-Hans, ind-Latn-pre1972) AND conversion-system codes ({authority}:{source-spelling}:{target-spelling}:{identifying}, e.g. acadsin:zho-Hani:Latn:2002, UN:ara-Arab:Latn:2017).

Always verbose — single-language data uses the same [{ spelling, value }] shape as multi-language data. This lets a single field capture both native-script content and its romanized / transliterated forms.

Look up an entry by spelling with plain Ruby:

decision.title.find { |l| l.spelling == "eng" }&.value
decision.title.find { |l| l.spelling == "acadsin:zho-Hani:Latn:2002" }&.value

Polymorphic Venue

A Venue is one flat class on the wire; the kind field discriminates physical vs virtual.

  • 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 (pure validation via valid?; call populate_from_registry! separately if you want city/country_code back-filled from the UN/LOCODE entry).

Meeting helpers

meeting.chair                          # => Person (via officers + role)
meeting.officers_with_role(:treasurer) # => [Officer]

OfficersHost is mixed into Meeting and MeetingComponent; both expose chair (the first officer with role: "chair") and officers_with_role(role).

Multilingual example

A bilingual CIML decision in EN + FR, modelled with per-field Localized:

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
    title:
      - spelling: eng
        value: Decision on the renewal of the contract of Mr Anthony Donnellan
      - spelling: fra
        value: Décision sur le renouvellement du contrat de M. Anthony Donnellan
    subject:
      - spelling: eng
        value: CIML
      - spelling: fra
        value: CIML
    actions:
      - type: decides
        date_effective:
          date: 2025-10-13
          type: adoption
        message:
          - spelling: eng
            value: |
              The Committee decides to renew the contract of
              Mr Anthony Donnellan as BIML Director.
          - spelling: fra
            value: |
              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 / MeetingCollection / MeetingSeries 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 — single Meeting, MeetingCollection, and MeetingSeries are all accepted.

Walks DIRECTORY recursively, classifies every .yaml / .yml as a Meeting, MeetingCollection, or DecisionCollection, and reports:

  • every metadata.meeting_urn that does not resolve to an indexed Meeting;

  • every Meeting urn claimed by more than one file.

Error messages include file:line and the in-document pointer.

$ edoxen check-links ./spec/fixtures
🔗 Checking cross-document URN links in ./spec/fixtures...
✅ No broken or duplicate links found

$ edoxen check-links ./_data
❌ Found 2 link error(s):
  ./_data/decisions.yaml:4: metadata.meeting_urn references urn:oiml:ciml:meeting:ciml-99 (no matching Meeting)
  ./_data/dupe.yaml:7: urn references urn:oiml:ciml:meeting:ciml-55 (duplicate Meeting URN (first seen at ./_data/meeting-55.yaml:4))

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

  • lib/edoxen/venue_validator.rb validates polymorphic Venue instances against the unlocodes and iata gems. valid? is pure; populate_from_registry! is a separate, explicitly-mutating method for callers that want UN/LOCODE → city/country_code back-fill.

Schema ↔ Ruby invariants

Four pairs of runtime specs guard 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.

  • Canonical sync: schema_model_canonical_sync_spec.rb asserts the gem schemas byte-match ../edoxen-model/schema/{decision-collection, meeting}.yaml.

  • Lutaml sync: lutaml_ruby_sync_spec.rb walks every *.lutaml class and enum and asserts attribute names + collection flags match the Ruby side.

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

Migrating from pre-1.0 (Resolution → Decision)

Edoxen Model 1.0 is the first public release; the intermediate v0.x / v2.x history never shipped publicly. The migration from a pre-1.0 shape 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 (direct)

Meeting.officers: Officer[] (role discriminates); Meeting#chair accessor via OfficersHost

Meeting.schedule[] (ScheduleItem)

Meeting.components: MeetingComponent[] (flat)

Resolution alone

Decision + Motion + Voting (procedural core)

localizations: Localization[1..*] per entity

Per-field `Localized<String

Name>[0..*]` (ISO 24229); each translatable field carries its own spelling tag inline.

decision.in_language(code) / decision.primary_localization

`decision.title.find {

l

l.spelling == code }` — direct array lookup, no wrapper.

(none)

Topic + TopicDocument + TopicAsset

(none)

MeetingSeries, Recurrence (ISO 8601-2 §13)

(none)

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

(none)

ContactCollection, VenueCollection (scoped-URN registries)

Meeting.date_range (single range)

Meeting.scheduled_date_range + Meeting.occurred_date_range (split scheduled vs actual; occurred uses DateTimeRange for sub-day precision)

(none)

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.

About

Rubygem for working with the resolution models

Resources

Code of conduct

Stars

1 star

Watchers

5 watching

Forks

Releases

Packages

Used by

Contributors

Languages