> ## Documentation Index
> Fetch the complete documentation index at: https://skaum.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Protokoll JSON Schema

> Reference for the machine-readable JSON export format used by the protokoll renderers

## Overview

The protokoll export system generates meeting minutes (protokoll) in multiple formats. The JSON format is designed for programmatic consumption by scripts and integrations. It represents a filtered projection of the canonical aggregation hash produced by `ProtokollAggregationService`.

The JSON renderer (`ProtokollJsonRenderer`) receives the full aggregation hash and a set of `content_options`, then produces a filtered JSON document with only the requested content. Keys for disabled content are **omitted entirely** (not set to `null`), so consumers can use `key?` checks to detect presence.

## Versioning policy

Every JSON export includes a top-level `version` field (currently `"2.0"`).

* **Additive changes within a major** (2.x): new keys, new enum values, new audit-log actions. These do not bump the version.
* **Breaking changes bump the major**: renames, removals, type changes, or reorderings of existing keys or values.

> [!NOTE]
>
> Consumers should tolerate unknown keys gracefully. New keys may appear in any 2.x release.

### Version history

| Version | Change |
| - | - |
| `2.0` (additive) | VOT-466: `agenda_items[].no_vote` (boolean, information items); `winning_result` reads «Tatt til orientering» for a completed information item that has no manual result and heads no ordinary sub-items. |
| `2.0` (format note) | VOT-425: timestamps carry the Europe/Oslo offset (`+01:00` / `+02:00`) instead of `Z`. Same instants, still ISO 8601, no key or type change. See [Timestamps](#timestamps). |
| `2.0` (additive) | VOT-434: `meeting.activated_at`, `meeting.activated_by`, `meeting.closed_at`, `meeting.closed_by` (see [Meeting lifecycle fields](#meeting-lifecycle-fields)); audit-log actions `meeting.started`, `meeting.ended`, `meeting.reopened`. |
| `2.0` (additive) | VOT-424: `agenda_items[].unfilled_seats` (integer). |
| `2.0` (additive) | VOT-436: `participants[].attendance`, `absent_since`, `registered_in_error`, `registered_in_error_at`; top-level `attendance` (see [Attendance](#attendance)); audit-log actions `participant.marked_absent`, `participant.marked_present`, `participant.marked_registered_in_error`, `participant.unmarked_registered_in_error`. |
| `2.0` (additive) | VOT-437: the `meeting.reopened` audit entry's `metadata` carries the optional `reason` and `superseded_protokoll_exports` (see [Audit log](#audit-log)). Whether an export was later superseded is not part of the document — see [The document and its export record](#the-document-and-its-export-record). |
| `2.0` | VOT-432: `meeting.status` is the stored meeting state. Its first value is renamed `"upcoming"` → `"preparing"` (values: `"preparing"`, `"active"`, `"closed"`). `starts_on`/`ends_on` are information only and no longer decide `status`. Exports made before this change keep `"upcoming"` in `meeting_status_at_export`. |
| `1.0` (additive) | VOT-423: `agenda_items[].left_unfinished`, `left_unfinished_at` ("ikke ferdigbehandlet"). |
| `1.0` | Initial schema. `meeting.status` was `"upcoming"`, `"active"` or `"closed"`, derived from the dates when both were set. |

## Timestamps

Every `*_at` value is an ISO 8601 string with an explicit offset. Since VOT-425 the app runs in the Europe/Oslo zone, so the offset is Oslo's for that date: `+02:00` in summer (CEST) and `+01:00` in winter (CET), e.g. `"2026-06-12T18:02:00+02:00"`. Exports generated before VOT-425 used UTC (`"2026-06-12T16:02:00Z"`) for the same instant. Parse the values with an ISO 8601 / RFC 3339 parser and compare them as instants, not as strings. Date-only fields (`starts_on`, `ends_on`) are plain `YYYY-MM-DD` dates and have no offset.

The Markdown, PDF and DOCX covers show the export time and the meeting start/end in Oslo local time (`protokoll.time_format`, e.g. «12.06.2026 kl. 18:02»).

## Top-level structure

Every JSON export contains these keys:

| Key | Type | Always present | Description |
| - | - | - | - |
| `version` | string | Yes | Schema version (`"2.0"`) |
| `generated_at` | string (ISO 8601) | Yes | Timestamp when the export was generated |
| `meeting` | object | Yes | Meeting metadata |
| `organization` | object | Yes | Organization metadata including terminology and branding |
| `participants` | array | Conditional | Participant list (omitted when `include_participants_list: false`) |
| `attendance` | object | Conditional | Attendance counts and changes (VOT-436; omitted with `participants`, and absent from exports made before it shipped) |
| `agenda_items` | array | Yes | Filtered agenda items with nested polls |
| `audit_log` | array | Yes | Meeting lifecycle events |

## Meeting lifecycle fields

Added in 2.0 (VOT-434, additive). They record the formal start of the meeting («Start møte») and its end.

| Key | Type | Description |
| - | - | - |
| `meeting.activated_at` | string (ISO 8601) or `null` | When the meeting was started (preparing → active). `null` while it is still being prepared. Never changes once set |
| `meeting.activated_by` | object or `null` | Who started it: `{ "name": string, "meeting_participant_id": integer or null }`. `meeting_participant_id` links to `participants[].id`; it is `null` for an org admin without a role in the meeting. `null` when unknown (meetings started before VOT-434) |
| `meeting.closed_at` | string (ISO 8601) or `null` | When the meeting was last ended (active → closed). Cleared when the meeting is reopened |
| `meeting.closed_by` | object or `null` | Who ended it, same shape as `activated_by`. Cleared on reopen |

The Markdown, PDF and DOCX covers print the same information («Møtet startet» / «Møtet avsluttet», with the name).

## Attendance

Added in 2.0 (VOT-436, additive). Everyone is present until the secretary marks them «Fraværende» (absent); a participant added by mistake once the meeting had started is marked «Feilregistrert» (registered in error) instead of being deleted.

| Key | Type | Description |
| - | - | - |
| `attendance.present_count` | integer | Participants not marked absent, at export time. «Feilregistrert» participants are not counted |
| `attendance.absent_count` | integer | Participants marked absent, at export time. «Feilregistrert» participants are not counted |
| `attendance.changes` | array | Every attendance change the audit log holds, oldest first: `{ "meeting_participant_id": integer, "attendance": "absent" or "present", "at": ISO 8601 }`. «Start møte» deletes the rehearsal's audit rows, so a «forfall» marked while preparing is not listed — that participant shows an `absent_since` before `meeting.activated_at`. Changes of «Feilregistrert» participants are left out |

Per participant, `attendance` / `absent_since` give the state at export time, and `registered_in_error` flags a «Feilregistrert» participant. The Markdown, PDF, DOCX and CSV exports leave «Feilregistrert» participants out of the participant list and the attendance section; the JSON keeps them, flagged.

An absent or «Feilregistrert» participant is not stemmeberettiget. A poll's own eligible set is frozen when it closes (its snapshot): `eligible_voter_count` and `non_voter_participant_ids` reflect who was absent then, while `participants[].can_vote` is computed at export time.

## Audit log

`audit_log` lists the allowlisted events (`ProtokollAggregationService::AUDIT_LOG_ALLOWLIST`) in chronological order: `meeting.started`, `meeting.ended`, `meeting.reopened`, `poll.opened`, `poll.closed`, `poll.round_created`, `poll_option.withdrawn`, `agenda_item.manual_result_set`, `agenda_item.manual_result_cleared`, `agenda_item.completed`, `agenda_item.reopened`, `agenda_item.voting_restarted`, and (VOT-436) `participant.marked_absent`, `participant.marked_present`, `participant.marked_registered_in_error` and `participant.unmarked_registered_in_error` (`auditable_id` is the participant).

«Start møte» deletes every audit row written while the meeting was being prepared (owner decision, VOT-434), so for a started meeting the log begins with `meeting.started`. A meeting still being prepared has no lifecycle entries yet (and its rehearsal entries will be deleted when it starts).

`metadata` of the lifecycle entries:

| Action | `metadata` |
| - | - |
| `meeting.started` | What the start deleted, as counts: `votes`, `rounds`, `quick_poll_items`, `simulated_participants`, `completed_items`, `audit_log_entries`, `protokoll_exports` |
| `meeting.ended` | `{}` |
| `meeting.reopened` | `reason` (string, the optional reason typed on the confirmation page; absent when none was given) and `superseded_protokoll_exports` (integer, how many earlier exports the reopen marked superseded) — VOT-437 |

A reopened meeting shows `meeting.ended` followed by `meeting.reopened` (and, if it was ended again, another `meeting.ended`); `meeting.closed_at` / `closed_by` describe the last end only.

## The document and its export record

A generated JSON document never changes. Facts that arise later live on the **export record** (`ProtokollExport`), which the web UI's export list shows next to the files:

| Export record field | Meaning |
| - | - |
| `meeting_status_at_export` | The meeting state when the export was requested: `"preparing"`, `"active"` or `"closed"` (historical exports may say `"upcoming"`). An export of an `"active"` meeting is a draft of the minutes; only a `"closed"` one is the final record. Exports requested while preparing are deleted by «Start møte» (VOT-434), so they never survive into a started meeting |
| `superseded_at` | Set when «Gjenåpne møte» reopened the meeting after the export was made (VOT-437). The document no longer reflects the final record — the web UI labels it «Erstattet». `null` otherwise. Make a new export once the meeting has been ended again |

## Content options filtering

The `content_options` hash controls which keys appear in the output:

| Option | Path | Default | Effect when disabled |
| - | - | - | - |
| Include participants | `include_participants_list` | `true` | `participants` key omitted entirely |
| Include uncompleted items | `include_uncompleted_items` | `false` | Only `completed: true` items in `agenda_items` |
| Vote detail level | `vote_detail` | `"summarized_plus_final"` | See vote detail modes below |
| Item description | `per_item.description` | `true` | `body_html` key omitted from each agenda item |
| Secretary comment | `per_item.secretary_comment` | `true` | `secretary_comment` key omitted from each agenda item |
| Poll settings | `per_item.poll_settings` | `"deviations_only"` | Controls `settings_deviations` key (see below) |
| Eligible voter count | `per_round.eligible_voter_count` | `true` | `eligible_voter_count` key omitted from each poll |
| Non-voter names | `per_round.non_voter_names` | `false` | `non_voter_participant_ids` key omitted from each poll |

### Poll settings modes

| Mode | Behavior |
| - | - |
| `"always"` | `settings_deviations` key always present (even if empty array) |
| `"deviations_only"` | `settings_deviations` present only when non-empty |
| `"never"` | `settings_deviations` key omitted entirely |

## Vote detail modes

The `vote_detail` option controls how poll rounds appear in each agenda item's `polls` array:

### `full_rounds`

All closed rounds included with full detail (options, vote counts, outcome, settings).

### `summarized_plus_final` (default)

Non-final rounds are reduced to summary keys only. The final/decisive round has full detail.

Summary keys: `round_number`, `round_summary`, `outcome`, `status`.

### `final_only`

Only the decisive round (where `latest_for_agenda_item` is true) appears, with full detail. Earlier rounds are omitted entirely.

## Minimal example

A minimal export with default options for a meeting with one completed binary poll:

```json theme={null}
{
  "version": "2.0",
  "generated_at": "2026-04-16T14:00:00+02:00",
  "meeting": {
    "id": 1,
    "slug": "abc123",
    "title": "Annual Meeting 2026",
    "display_name": "Annual Meeting 2026",
    "status": "closed",
    "starts_on": "2026-04-16",
    "ends_on": "2026-04-16",
    "activated_at": "2026-04-16T18:02:00+02:00",
    "activated_by": { "name": "Ola Nordmann", "meeting_participant_id": 1 },
    "closed_at": "2026-04-16T20:45:00+02:00",
    "closed_by": { "name": "Ola Nordmann", "meeting_participant_id": 1 }
  },
  "organization": {
    "id": 1,
    "name": "Example Org",
    "slug": "example-org"
  },
  "participants": [
    {
      "id": 1,
      "participant_number": 1,
      "name": "Ola Nordmann",
      "role": "chair",
      "can_vote": true,
      "attendance": "present",
      "absent_since": null,
      "registered_in_error": false,
      "registered_in_error_at": null
    }
  ],
  "attendance": { "present_count": 1, "absent_count": 0, "changes": [] },
  "agenda_items": [
    {
      "id": 1,
      "display_number": "1",
      "title": "Approval of notice",
      "completed": true,
      "left_unfinished": false,
      "unfilled_seats": 0,
      "polls": [
        {
          "round_number": 1,
          "poll_type": "binary",
          "status": "closed",
          "outcome": "adopted",
          "total_votes_cast": 1,
          "options": [
            { "id": 1, "title": "Yes", "vote_count": 1 },
            { "id": 2, "title": "No", "vote_count": 0 }
          ]
        }
      ]
    }
  ],
  "audit_log": [
    { "action": "meeting.started", "created_at": "2026-04-16T18:02:00+02:00", "auditable_type": "Meeting", "auditable_id": 1,
      "meeting_participant_id": 1, "metadata": { "votes": 12, "rounds": 1, "quick_poll_items": 0, "simulated_participants": 5,
      "completed_items": 1, "audit_log_entries": 9, "protokoll_exports": 0 } },
    { "action": "poll.opened", "created_at": "2026-04-16T18:10:00+02:00", "auditable_type": "Poll", "auditable_id": 1,
      "meeting_participant_id": 1, "metadata": { "title": "Approval of notice" } },
    { "action": "poll.closed", "created_at": "2026-04-16T18:12:00+02:00", "auditable_type": "Poll", "auditable_id": 1,
      "meeting_participant_id": 1, "metadata": { "title": "Approval of notice" } },
    { "action": "meeting.ended", "created_at": "2026-04-16T20:45:00+02:00", "auditable_type": "Meeting", "auditable_id": 1,
      "meeting_participant_id": 1, "metadata": {} }
  ]
}
```

> [!WARNING]
>
> This is a simplified example. The actual output includes many more fields per object. See the key reference tables below for the complete set.

## Key reference

### Meeting object

| Key | Type | Description |
| - | - | - |
| `id` | integer | Database ID |
| `slug` | string | URL-safe identifier (6 chars) |
| `title` | string | Full meeting title |
| `short_name` | string or null | Compact display name |
| `display_name` | string | `short_name` or `title` |
| `status` | string | Stored meeting state: `"preparing"`, `"active"`, or `"closed"` (`"upcoming"` before 2.0). Changed only by «Start møte» / «Avslutt møte» / «Gjenåpne møte», never by the dates |
| `starts_on` | string or null | ISO 8601 date — planned start, information only |
| `ends_on` | string or null | ISO 8601 date — planned end, information only |
| `chair_can_vote` | boolean | Whether chair role has voting rights |
| `secretary_can_vote` | boolean | Whether secretary role has voting rights |
| `welcome_message_html` | string or null | Rich text welcome message |
| `planned_order_enabled` | boolean | Whether planned agenda ordering is active |
| `current_agenda_item_id` | integer or null | Active agenda item |
| `created_at` | string | ISO 8601 datetime |
| `activated_at` | string or null | ISO 8601 datetime the meeting was started — see [Meeting lifecycle fields](#meeting-lifecycle-fields) (2.0, additive) |
| `activated_by` | object or null | `{ "name", "meeting_participant_id" }` of who started it |
| `closed_at` | string or null | ISO 8601 datetime the meeting was last ended; `null` while preparing / active (cleared on reopen) |
| `closed_by` | object or null | Who ended it, same shape as `activated_by` |

### Organization object

| Key | Type | Description |
| - | - | - |
| `id` | integer | Database ID |
| `name` | string | Organization name |
| `slug` | string | URL-safe identifier |
| `effective_terminology` | object | Terminology overrides (string-keyed) |
| `custom_field_definitions` | array | Org-defined participant fields |
| `agenda_numbering_scheme` | string | `"numeric"`, `"numeric_alpha"`, etc. |
| `custom_numbering_config` | object or null | Custom numbering level config |
| `branding` | object | Colors and logo URL |

### Participant object

| Key | Type | Description |
| - | - | - |
| `id` | integer | MeetingParticipant ID |
| `participant_number` | integer | Assigned number within meeting |
| `name` | string | Display name |
| `email` | string | Email address |
| `role` | string | `"participant"`, `"chair"`, `"secretary"`, `"observer"` |
| `can_vote` | boolean | Whether the participant may vote, **computed at export time** from the current role, attendance and the meeting's voting-rights switches — not "could vote in every poll". Each poll's frozen eligible set is in its snapshot (`eligible_voter_count`, `non_voter_participant_ids`) |
| `custom_field_values` | object | Org-defined field values (string-keyed) |
| `attendance` | string | `"present"` or `"absent"` at export time (VOT-436, additive; treat a missing key as `"present"`) |
| `absent_since` | string (ISO 8601) or `null` | When the participant was marked absent; `null` while present. Before `meeting.activated_at` means a known «forfall» marked while preparing |
| `registered_in_error` | boolean | «Feilregistrert»: added by mistake once the meeting had started. Not eligible; left out of the human-readable lists (VOT-436, additive; treat a missing key as `false`) |
| `registered_in_error_at` | string (ISO 8601) or `null` | When it was marked |

### Agenda item object

| Key | Type | Conditional | Description |
| - | - | - | - |
| `id` | integer | No | Database ID |
| `parent_id` | integer or null | No | Parent item for nesting. For a quick poll (`quick_poll: true`): the item it was raised on (VOT-448), or null when there was no current item |
| `depth` | integer | No | Nesting depth (0-4). A quick poll has the depth of the item it was raised on |
| `position` | integer | No | Position within the ordinary siblings (0-based). For a quick poll: its place in the meeting-wide quick-poll sequence («Hurtig N» = `position` + 1); quick polls take no slot in the ordinary numbering |
| `planned_position` | integer | No | Planned voting order position. A quick poll mirrors the planned position of the item it was raised on |
| `ancestry_path` | string | No | Materialized path for sorting (`0004.0001` …). A quick poll's is `<item path>-<nnnn>`, which sorts right after the item and before its sub-items, or `~<nnnn>` with no item (after every ordinary item) |
| `display_number` | string | No | Human-readable number (e.g. "1.a.i") |
| `title` | string | No | Item title |
| `body_html` | string | `per_item.description` | Rich text body as HTML |
| `quick_poll` | boolean | No | Whether this is a bench proposal |
| `no_vote` | boolean | No | An information item («Ingen votering», VOT-466): the item has no polls. Its `winning_result` is «Tatt til orientering» once completed, unless it has a manual result or heads ordinary sub-items. Added in 2.x (additive) — absent from exports generated before it shipped; treat a missing key as `false` |
| `secretary_comment` | string or null | `per_item.secretary_comment` | Secretary's note |
| `completed_at` | string or null | No | ISO 8601 completion timestamp |
| `completed` | boolean | No | Whether item is completed |
| `left_unfinished` | boolean | No | The meeting moved on from the item without a final decision ("ikke ferdigbehandlet", VOT-423). Always `false` once the item is completed. Added in 1.x (additive) — absent from exports generated before it shipped |
| `left_unfinished_at` | string or null | No | ISO 8601 time the meeting last moved on from the item; `null` unless `left_unfinished` |
| `manual_result` | string or null | No | `"unanimously_accepted"`, `"rejected"`, `"withdrawn"`, `"no_winner"` |
| `manual_result_set_at` | string or null | No | ISO 8601 timestamp |
| `winning_result` | string or null | No | Human-readable result summary. For an election concluded with seats left it ends with the unfilled seats (e.g. `"Alice, Bob · 1 plass ubesatt"`) |
| `all_elected_candidates` | array | No | Elected option titles across rounds |
| `unfilled_seats` | integer | No | Seats a multi-seat election was concluded without filling, saved when the chair concluded it (VOT-424: «Bekreft og avslutt valget», «Avslutt uten vinner», «Enstemmig vedtatt» with fewer candidates than seats, or no candidates left). `0` when nothing was left unfilled or the item is not such an election. Added in 2.x (additive) — absent from exports generated before it shipped; treat a missing key as `0` |
| `voting_exclusions` | array | No | Voting exclusion rules |
| `polls` | array | No | Filtered poll rounds |

### Poll object (full detail)

| Key | Type | Conditional | Description |
| - | - | - | - |
| `id` | integer | No | Database ID |
| `round_number` | integer | No | Round number (1-based) |
| `restarted` | boolean | No | Whether voting was restarted |
| `final_round` | boolean | No | Whether this is the final round |
| `latest_for_agenda_item` | boolean | No | Whether this is the most recent round |
| `title` | string | No | Poll title |
| `poll_type` | string | No | `"binary"` or `"multiple_choice"` |
| `status` | string | No | `"draft"`, `"open"`, or `"closed"` |
| `created_at` | string | No | ISO 8601 datetime |
| `allow_abstention` | boolean | No | Whether abstain option exists |
| `winning_threshold` | string | No | `"majority"` or `"qualified"` |
| `counting_base` | string | No | `"votes_cast"` or `"eligible_voters"` |
| `plus_one` | boolean | No | Plus-one threshold modifier |
| `qualified_threshold_numerator` | integer or null | No | Numerator for qualified threshold |
| `qualified_threshold_denominator` | integer or null | No | Denominator for qualified threshold |
| `max_selections` | integer | No | Max options a voter can select |
| `original_max_selections` | integer | No | First round's max\_selections |
| `settings_deviations` | array | `per_item.poll_settings` | Non-default settings list |
| `threshold_display_text` | string | No | Human-readable threshold description |
| `confirmed_outcome` | string or null | No | `"confirmed"`, `"unconfirmed"`, or null |
| `outcome` | string or null | No | `"adopted"`, `"rejected"`, `"elected"`, `"tied"` |
| `total_votes_cast` | integer | No | Number of votes cast |
| `eligible_voter_count` | integer | `per_round.eligible_voter_count` | Eligible voters in meeting |
| `did_not_vote_count` | integer | No | Voters who did not vote |
| `abstain_count` | integer | No | Abstention count |
| `percentage_denominator` | integer | No | Denominator for vote percentages |
| `decision_reached` | boolean | No | Whether a decision was reached |
| `winning_option_ids` | array | No | IDs of winning options |
| `eliminated_option_titles` | array | No | Options eliminated between rounds |
| `round_summary` | string | No | Human-readable round summary |
| `elected_option_ids_across_rounds` | array | No | All elected option IDs |
| `remaining_seats` | integer | No | Seats still to fill (MC elections) |
| `election_complete` | boolean | No | Whether all seats are filled |
| `partial_winners` | boolean | No | Whether partial winners exist |
| `options` | array | No | Poll options with vote counts |
| `non_voter_participant_ids` | array | `per_round.non_voter_names` | IDs of participants who did not vote |

### Poll object (summary detail)

When a poll is rendered in summary mode (non-final rounds under `summarized_plus_final`):

| Key | Type | Description |
| - | - | - |
| `round_number` | integer | Round number |
| `round_summary` | string | Pre-computed summary text |
| `outcome` | string or null | Poll outcome |
| `status` | string | Poll status |

### Poll option object

| Key | Type | Description |
| - | - | - |
| `id` | integer | Database ID |
| `position` | integer | Display position (0-based) |
| `title` | string | Option text |
| `abstain` | boolean | Whether this is the abstain option |
| `withdrawn` | boolean | Whether option was withdrawn |
| `elected` | boolean | Whether option was elected (MC elections) |
| `vote_count` | integer | Number of votes received |

### Audit log entry

| Key | Type | Description |
| - | - | - |
| `action` | string | Event action (e.g. `"poll.opened"`, `"agenda_item.completed"`) |
| `created_at` | string | ISO 8601 datetime |
| `auditable_type` | string | Model class name |
| `auditable_id` | integer | Model ID |
| `meeting_participant_id` | integer or null | Actor |
| `metadata` | object | Additional event data |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.