Learning OVD by Building It: An Open Source Voyage Reporting Platform
TL;DR: OVL is an open source vessel-to-office voyage reporting platform built around DNV’s Operational Vessel Data standard. A self-contained Go binary that runs onboard, a shore-side counterpart backed by Postgres, and one shared validation engine so both sides always agree on whether a report is valid. Multiple officers can work on the same report at the same time, and sensor data fills in what it can so the crew verifies rather than types. AGPL-3.0.
Why Build It
I’ve known OVD for a while, but I stopped following it closely somewhere around the earlier schema versions. Lately it’s clear the market is moving toward adopting it properly, so I wanted to catch up on what had changed.
The way I learn anything is by building it. Reading the interface description gives you the field names. Implementing it tells you what the standard actually asks of you: what breaks when reports arrive out of order, what happens when an earlier report gets corrected, why so many fields are marked optional.
So I started coding.
What OVD Actually Is
Operational Vessel Data (OVD) is DNV’s standard for how vessels report operational data to shore. A common vocabulary for the things a ship reports anyway: where it was, what it burned, what cargo it carried, what commercial state it was in. It feeds regulatory reporting under MRV, EU ETS, FuelEU Maritime, DCS and CII.
OVL implements five curated schemas from OVD 3.13. The field counts tell most of the story:
| Schema | Fields | Entered where |
|---|---|---|
| Log Abstract | 409 | Vessel |
| Bunker Report | 23 | Vessel (with attachments) |
| EDN Report | 9 | Vessel (with attachments) |
| Cargo Nomination | 22 | Office |
| Commercial Period | 6 | Office |
Four hundred and nine fields. For one noon report.
No deck officer fills in 409 fields, and OVD does not expect them to. Most fields apply only to specific event types, or only under specific regulations, or only for specific vessel types. The standard hands you the full superset and leaves the narrowing entirely to you.
That gap between “here is every field that could exist” and “here is what this officer needs to type right now” is the entire application.
The second thing only became clear once I was writing validation code. Reports in a voyage are not independent. A Log Abstract depends on the voyage state established by earlier reports. Fuel remaining on board carries forward. Time buckets chain. Correct an earlier report and later ones can become wrong. The standard defines the record. It does not define the consequences.
Why an App and Not a Translator
The obvious version of this project is middleware. Take whatever the shipowner already has, map it to OVD, push it out. A few thousand lines, done in a fortnight.
I went the other way, for three reasons.
1. Middleware lets you skip the hard parts. A translator receives a complete record and reshapes it. It never has to answer where the data came from, whether it was entered correctly, or what to do when it is only half filled in. Those are exactly the parts of OVD I wanted to understand. Translation teaches you the field names and not much else.
2. Middleware assumes a source system exists. The vessels that most need structured reporting are the ones without a fleet management system to translate from. If your input is a spreadsheet emailed once a day, a translator does not help. A data entry application with validation at the point of entry does.
3. Validation belongs where the human is. Catching a bad report in a shore-side translator means someone emails the vessel three days later. Catching it while the officer is still on the form means it gets fixed in thirty seconds. Same schema, very different operational value.
The cost is real. Authentication, sync, offline storage, two UIs, a lifecycle model, config distribution, deployment. A translator would have been finished months ago and taught me a fraction of it.
Architecture
Two binaries, one shared library.
embedded"] --- V["ovl-vessel
Go + SQLite
:8420"] V -->|"REST pull, bearer key"| Sensor["ovl-sensor-stub
IAS + VMS feeds
:8422"] end subgraph Shore["Shore-side"] O["ovl-office
Go + Postgres
:8080"] --- OWeb["React SPA
embedded"] O -->|"GraphQL + CSV, API key"| Ext["External analytics
and middleware"] end V <-->|"ConnectRPC sync
vessel-initiated only"| O Shared["pkg: domain, schema, validation"] Shared -.-> V Shared -.-> O style V fill:#f9a,stroke:#333 style O fill:#f9a,stroke:#333 style Shared fill:#baa,stroke:#333
The most important thing in that diagram is the dotted line. pkg/ holds the domain model, the OVD schema handling, and the validation engine. Vessel and office both import it and run the same rules.
The common alternative is a light client that submits and a heavy server that judges. That gives you a vessel which thinks the report is fine and an office which says it is not, with no way for the crew to find out until it comes back rejected.
With shared validation a report’s health is never ambiguous. Green on the ship means green ashore. The office is not a second opinion, it is the same opinion computed twice.
Vessel side: Go with SQLite via modernc.org/sqlite, the pure Go driver. React frontend built with Vite and embedded into the binary with go:embed. CGO_ENABLED=0 throughout, so it cross compiles to a single file for linux, windows and darwin on amd64 and arm64.
That matters more than it sounds. Deployment onboard means someone copies a file to a machine that may not have internet, may be Windows, and definitely does not have Docker. One binary, no runtime, no dependencies. It runs standalone bound to 127.0.0.1, or in LAN mode so the whole ship’s network can reach it.
Office side: Same Go stack, Postgres via pgx/v5, same embedded React approach, shipped as a distroless container image. Both sides migrate with pressly/goose.
Several Officers, One Report, At The Same Time
This is the piece I am most pleased with, and it is the thing that separates OVL from a form on a PC in the ship’s office.
A Log Abstract is not one person’s document. The engine consumption and machinery performance figures come from the engine room. Position, distance, weather and times come from the bridge. Cargo figures come from wherever cargo figures come from. In the usual arrangement, one person collects all of it on paper and types it in, which is both slow and a great way to introduce transcription errors.
In OVL, ovl-vessel runs in LAN mode and every officer opens the same report from their own machine. Concurrency is handled per section rather than per report:
- Section soft locks. Sections come from the schema itself (
header,voyage,position,times,distanceAndSpeed,cargo,weather,engine.consumption,engine.performance,rob, and so on). Opening a section acquires a lock on that section only. The second engineer working onengine.consumptiondoes not block the third mate inweather. - Live presence over SSE. Every client subscribes to the report’s lock stream. When someone acquires or releases a section, everyone else sees it immediately, with the name and role of whoever holds it. No refresh, no guessing.
- Locks expire. Five minute TTL on inactivity, renewed while you are working. Somebody who opens the ROB section and then walks off to a fire drill does not hold that section hostage.
- Soft, not hard. Locks are advisory and there is a force-release path. Anyone who has been at sea knows the alternative gets someone shouting across the accommodation at 1150.
The result is that a noon report is assembled by the people who own the data, in parallel, in the same record. Nobody transcribes anybody else’s numbers.
Onboard Data Capture: Verify, Don’t Type
The other half of the same problem. ovl-vessel pulls from two onboard sources rather than waiting for anyone to type their contents in:
- The sensor source (IAS). Machinery and navigation data from the integrated automation system.
- The VMS. Voyage management system reference data.
Both are configured by the Master (base URL plus API key, key masked after saving), both can be enabled independently, and both fail independently. A separate button on the report form fetches from each. Pulled values land in the form as prefill mapped onto curated OVD field names, and the officer checks them.
That is the design intent worth stating plainly. Filling in a noon report should not be a typing exercise. The data mostly already exists onboard, and the officer’s real job is verification: does this figure look right, does it match what I saw on watch, is this reading from a sensor I trust today. Human in the loop, on top of automatic capture, rather than a human as the transport layer between two machines.
Pull rather than push, deliberately. ovl-vessel reaches out to the sensor service on a schedule it controls, instead of exposing an ingestion endpoint for other onboard systems to write into. One less inbound surface on a machine that sits on the ship’s network.
ovl-sensor-stub is a fake IAS and VMS feed I wrote so this could be developed and demonstrated without shipboard hardware. The stub is not the point, the contract is. Real instrumentation swaps in behind the same REST interface without ovl-vessel knowing.
The 409-Field Problem
This is where most of the design effort went, and it is what I would point at if someone asked what OVD demands of an implementer.
Forms are never hand-coded. Every schema is a versioned JSON document and both frontends render forms from it at runtime. Writing a 409-field React form by hand is not stubbornness, it is a maintenance impossibility. When OVD 3.14 arrives, a new schema JSON produces a new form.
The schema JSON describes each field like this:
{
"name": "ME_Consumption_HFO",
"label": "Main Engine HFO Consumption",
"type": "decimal",
"unit": "mt",
"enumRef": null,
"schemaMandatory": false,
"relevance": "mandatory for MRV&DCS",
"section": "engine.consumption",
"appliesToEvents": ["*"]
}
A meta-schema.json validates every schema document, including any uploaded through the office UI. It defines eight field types: text, wholeNumber, decimal, date, time, dateTime, boolean, enum. The original design had six. Real OVD 3.13 data forced the other two.
Narrowing happens through a five-state field policy. Every field, per schema version, resolves to one of:
| Policy state | Meaning |
|---|---|
hidden | Not shown at all |
optional | Shown, no pressure |
recommended | Shown, nudged, does not block |
companyMandatory | Required by this company’s configuration |
schemaMandatory | Required by OVD itself, not negotiable |
schemaMandatory always wins regardless of what the office configures. Everything else is the company’s call.
This also answers a question I had going in. Why does OVD mark so many fields optional? Because optionality is not a schema property. It is a workflow decision that depends on vessel type, trade, charterer and regulation. The standard cannot resolve it. Only the operator can. The implementation has to give them somewhere to say it.
The Office Admin Panel
Which brings us to the shore side, because that is where all of those decisions get made. I wanted an office administrator to be able to run OVL without touching a config file or waiting for a developer, and that includes surviving the next OVD release.
Configuring what vessels see. The configuration area covers field policy per schema, regulatory profiles as independent toggles (MRV, which bundles MRV plus ETS plus FuelEU, alongside DCS, CII and voyage verification), rule severities, and reporting cadence rules. All of it is scoped: apply to the whole fleet, a vessel group, or a single vessel, with a precedence banner showing which override is actually winning for the scope you are looking at.
Everything gets composed into a config bundle, published, and pulled down by vessels on their next sync. There is no separate delivery channel and nothing to install onboard. Publish ashore, and it reaches the ship the next time the ship calls in.
Keeping up with schema versions. This is the part I care about, because “what happens when OVD 3.14 lands” is the question that decides whether a tool like this ages well.
from office"] --> B["Edit against the new
DNV interface description"] B --> C["Upload"] C --> D["Meta-schema validation"] D --> E["Mandatory diff review
added / removed / changed fields"] E --> F["Publish as new immutable version"] F --> G["Field policy migration assistant"] G --> H["Publish config bundle"] H --> I["Vessels pull on next sync"] style E fill:#fa6,stroke:#333 style G fill:#fa6,stroke:#333 style F fill:#6d6,stroke:#333
The two highlighted steps are the ones that make this survivable.
The diff review is mandatory. You cannot publish a schema version without being shown exactly which fields were added, removed and changed. Nobody uploads a file and hopes.
The migration assistant carries the existing configuration forward. Policies and prefills for unchanged fields come across automatically. Added fields arrive at default and get flagged for review. Removed fields are listed for acknowledgment. Redoing 409 field policies by hand once a year would guarantee nobody ever upgrades.
Published versions are immutable. A new version is always a new record, never an edit in place, so any report can always be validated against the rules that actually applied when it was written.
One thing to be aware of. DNV publishes the interface description as a spreadsheet, and OVL does not import that. When 3.14 or anything later arrives, the schema update is done in JSON, not in Excel. Download the current schema, edit it against the new interface description, upload it back. Straightforward enough with an LLM’s help, but it is a JSON job.
Validation: Three Classes of Rule
Findings carry one of three severities. error blocks submit, warning shows in the health check but allows submit, info is advisory. Severity per rule is itself part of the config bundle, except for schema-mandatory and hard OVD rules, which are always errors.
Field rules come straight from the schema. Type, range, enum membership, mandatory-ness after policy resolution.
Plausibility rules are the ones a chief officer would catch by eye:
timeBucketSum— time-elapsed buckets must add up to time since previous reportimpliedSpeed— distance over elapsed time has to be physically sensiblenoDistanceStationary— you did not travel 40 miles while alongsideconsumptionSchemeExclusivity— consumption reported one way, not two contradictory wayspositionRequiredandpositionConsistency— a position exists and is reachable from the last one
Continuity rules operate across reports:
timeChain— no gaps or overlaps in the voyage timelinerobContinuity— remaining on board carries forward correctly through consumption and bunkeringeventOrdering— you cannot arrive before you departtimestampUniqueness— the OVD natural key (IMO, date, time) is unique per vessel
This is nowhere near a comprehensive plausibility set. It is a starting point, chosen because each rule catches something I have actually seen go wrong in a report. Anyone with more rules in mind is very welcome to open an issue on the repository and they can go in. The rule engine is built to take them, and the severity of each one is already configurable per fleet.
Continuity is where cascade revalidation lives, and that is the hardest code in the project.
Report Lifecycle and Corrections
The decision I am happiest with: a correction is not a state. It is a new version of the report that re-enters at draft. Nothing is edited in place. Nothing is overwritten.
That comes from the audit problem. In fleet reporting, “what did the vessel say” and “what did the vessel eventually say” are different questions, and regulators and charterers ask both. Mutate a report to fix it and you have destroyed the first answer.
The cascade is the difficult part. Because a later report depends on an earlier one, correcting an earlier report can invalidate later ones. Fix a Log Abstract from three days ago and the two that followed may now break robContinuity or timeChain. pkg/validation/cascade.go computes this, and both sides compute it identically.
One refinement worth recording, because the first version was wrong. Cascade started out severity-agnostic, on the reasoning that “invalidated” is a lifecycle concept separate from whether a severity blocks submit. That does not hold up. A warning explicitly allows submit. invalidated locks the report and demands a correction. Those are opposites. Now only error-severity continuity violations invalidate. A fleet that wants ROB breaks to be chain-breaking configures that rule as an error, and then it blocks submit and invalidates consistently.
This is the part of the codebase I would most like someone else to read.
Scope Change: From Push to Pull
The original design ended at DNV Veracity. Vessel reports to office, office batches them, office pushes them to Veracity for regulatory verification. There was an adapter interface, an interface log with per-batch retry, and a lifecycle state called pushed.
DNV declined API access to me as a solo developer, so that scope came out.
What replaced it is a read surface instead of a write one. The office exposes a read-only GraphQL API and CSV export, gated by API keys and separate from staff sessions. Two things follow.
Deep analysis becomes possible. GraphQL over the full report set means analytics tools and fleet dashboards can query whatever slice they need, down to individual field values, without anyone building a bespoke export each time. A compliance sink would not have given me that.
The Veracity path stays open, just moved. Anyone who does have API access can put a middleware in front of the GraphQL endpoint and push to Veracity from there. The submission logic sits outside OVL rather than inside it, which is a cleaner boundary than the original design had. OVL stays the source of truth instead of becoming a client of somebody else’s compliance pipeline.
The pushed state is still in the code, vestigial. Nothing sets it. A pull-based API does not produce a one-time transition the way a batch push did, and removing a lifecycle state ripples into every frontend state union and label map, so it stays until there is a reason to decide otherwise.
Four API Surfaces, Deliberately
| Surface | Protocol | Auth | Who uses it |
|---|---|---|---|
| Vessel to office sync | ConnectRPC / protobuf | Long-lived bearer sync credential | Machines only |
| Staff and crew UI | HTTP + JSON, SSE for live updates | Session cookie, Argon2id passwords | Humans |
| External read access | GraphQL + CSV export | API key | Analytics, dashboards, middleware |
| Onboard sensor and VMS feed | REST pull | Bearer key | ovl-vessel |
Giving a BI tool a human’s credentials is how credentials end up in a config file. A scoped, read-only, revocable key costs a bit more to build and removes that conversation permanently.
The data API documents itself. ovl-office ships a GraphQL playground at /api/v1/graphql/playground, admin-gated, served from the same binary. Open it and you get the full schema browser, field-level docs, and a query builder pointed at the real endpoint. An admin’s session works for introspection straight away, and pasting a scoped API key into the headers panel lets you see exactly what that key would return, including its vessel-group restriction. Anyone integrating against OVL can explore the whole data model before writing a line of client code, without me maintaining a separate API reference that drifts out of date.
The Sync Protocol
Connectivity onboard used to be genuinely bad. It is much better now, and Starlink has changed the picture for a lot of the fleet. It is still not a terrestrial connection, and it is still intermittent. Vessels also sit behind carrier NAT, so an inbound connection is often not just a bad idea but impossible.
That combination drove a deliberate decision. OVL does not do store-and-forward over SFTP or email, which is how a lot of voyage reporting has historically moved. Those mechanisms were built for a world where a link might not exist for days, and they carry a real cost: no acknowledgment, no round trip, no way for the office to send anything back except another file. Instead OVL assumes a link will appear, and syncs properly over it when it does. Everything is queued locally in the meantime and the vessel keeps working regardless.
Every sync is initiated by the vessel. The office never dials a ship.
Six RPCs total. The sixth is FetchRestoreBundle for disaster recovery. If a vessel’s machine dies, the replacement pulls its state back down over the link it already trusts.
Transport is ConnectRPC over protobuf with zstd compression. The sync contract is the most brittle part of the system, so I wanted it defined in a file that generates both ends. Change the contract and the compiler tells me what broke, rather than a vessel at sea finding out at three in the morning. ConnectRPC rather than raw gRPC because it survives ordinary HTTP proxies and satellite middleboxes better.
Attachments sync as content-addressed chunks and the vessel asks which chunks are missing before uploading. A link that drops at 80% of a bunker delivery note scan should resume, not restart.
Everything the office wants to deliver travels back through PullInbox: config bundles, new schema versions, reviewer remarks, remote user administration commands. One direction, one mechanism, no push.
Security
Security is a build-time concern rather than a phase at the end. make security runs govulncheck, gosec, gitleaks and npm audit for both frontends. CI runs build, vet, gofmt, golangci-lint and the full test suite with -race on every push and pull request. Dependabot watches Go modules, both npm workspaces, and the Actions themselves.
The choices worth explaining:
- Argon2id for all password hashing, staff and crew, with parameters set above the library defaults to meet current guidance for interactive login.
- age (X25519) encryption for restore bundles. A disaster recovery bundle contains the whole vessel state. A backup file that leaks should be useless. It is.
- Session cookies are
HttpOnlyandSameSite=Strictunconditionally. TheSecureflag is opt-in. That is a deliberate trade. ASecurecookie sent over plain HTTP is silently dropped by the browser, and the symptom is a login that appears to work and then does not stick. Failing safe here meant failing confusingly, so it is an explicit flag with a note in the README instead. - Vessel-initiated sync only is a security property as much as a connectivity one. The office never opens an inbound connection to a ship.
- Distroless nonroot container for the office image. No shell, no package manager.
- Keyless cosign signing in the release pipeline, so a binary traces back to the workflow that built it without a long-lived key sitting somewhere.
- No CGO anywhere, including the SQLite driver. Pure Go removes a whole class of memory safety problems from C dependencies.
None of this is exotic. It is all boring, documented practice, which is rather the point.
Screenshots






The UI, and Where Claude Earned Its Keep
The vessel UI is used by deck officers at the end of a watch. If the form is annoying it does not get filled in properly, and every downstream promise about data quality falls over. So UX here is a data problem, not a taste problem.
It is also unusually awkward UI. Forms are rendered from schema, so nothing can be laid out by hand. Sections have to group sensibly across 409 fields. Several people edit the same report at once, so presence and lock state have to be visible without being noisy. Findings have to appear next to the field that caused them, at the right severity, without turning the form into a wall of red.
Frontend is the part of the stack I am slowest at, and left to my own devices my layouts tend to look like a table someone put a border around. Claude did a lot of the lifting: component structure for schema-driven rendering, section layout, spacing, keyboard and focus behaviour, empty states I would have skipped, and finding placement that puts the problem beside the field rather than in a banner at the top. It also caught accessibility basics I do not have the reflexes for.
What it did not decide: that a validation error should never block saving a draft, that sync status belongs on every screen rather than buried in settings, that locks should be per section rather than per report, or that the home screen should suggest the next likely report instead of showing a blank picker. Those come from having stood a watch.
Net result is an interface that no longer announces “backend developer” from across the room. That is a genuine win and also not the same thing as good design.
Solo Build, Honestly
Go testing is solid. Frontend testing does not exist. Around 140 Go test files cover the validation engine, the sync service, both stores and both HTTP API surfaces, all race-enabled in CI. The React side has zero tests. Not thin. Zero. The reasoning was that backend bugs are silent and expensive while frontend bugs are visible and I am the only user, which holds right up until someone else runs it.
Documentation is written for me. Four markdown files in the whole repository. The README is good, and there are per-directory READMEs for proto/, schemas/ and deploy/office/. What does not exist is anything telling a second officer how to complete a Log Abstract, or a superintendent how to review and remark a report. I know how it works, so I never had to write it down.
One reader on the hard parts. Cascade revalidation, the sync state machine and field policy resolution are the three most intricate pieces, and I have reviewed each of them several times. Which is not the same as somebody else reviewing them once.
Scope discipline is self-enforced. Nobody was there to tell me the GraphQL export could wait. Sometimes that is freedom. Sometimes it is why the user docs do not exist.
What Implementing OVD Taught Me
The schema is the easy half. Validating a Log Abstract against OVD 3.13 is a solved problem. santhosh-tekuri/jsonschema does it in a few lines. Everything hard lives outside the schema: narrowing, ordering, dependencies, corrections, versioning, and what “submitted” means when the link has been down for two days.
Optional is a workflow decision wearing a schema costume. The standard cannot tell you whether a field matters, because that depends on vessel type, trade, charterer and regulation. A generic implementation has to give the operator somewhere to express it. That is the whole reason the five-state field policy and the config bundle mechanism exist. It was not in the original plan, the schema forced it.
Versioning the schema is a first-class problem. Vessels update slowly. OVD does not. At any moment a fleet will be running several schema versions, and the office has to accept all of them and know which rules applied to which report.
Building against a standard is not the same as building against its ecosystem. The standard is public and implementable. Access to the platform around it is a separate conversation, and it can move independently of anything in your codebase.
What’s Next
Releases are built by GoReleaser on a version tag: cross-compiled ovl-vessel binaries for linux, windows and darwin on amd64 and arm64, and a signed multi-arch ovl-office image published to ghcr.io. Both land on GitHub.
On the roadmap:
- Application documentation for both vessel and office workflows
- Frontend tests, starting with the schema-driven form renderer
- A wider plausibility rule set
- Broader OVD report type and event coverage
- Integration coverage of failure paths: partial pushes, credential expiry mid-cycle, schema version mismatch between a stale vessel and an updated office
- A real IAS/VMS adapter to replace the sensor stub
Where I would most value help: application documentation. Not code comments, but the guide that tells a second officer how to complete a Log Abstract and a master how to review one. I know the application too well to write it usefully, and someone coming to it fresh would write a far better version. It can live in the repository or as GitHub Pages, whichever the person writing it prefers.
The other standing ask is a second pair of eyes on cascade revalidation.
This is a solo project and it could use more hands, on documentation, testing, plausibility rules, OVD schema coverage, or the roadmap generally. If any of that sounds interesting, reach me through github.com/captv89.
The code is here: github.com/captv89/ovl. AGPL-3.0.
Clone it. Break it. Tell me what you find.
References
- DNV. Operational Vessel Data (OVD) standard. dnv.com
- ConnectRPC. Connect protocol specification. connectrpc.com
- Biryukov, A., Dinu, D., Khovratovich, D. (2021). Argon2 memory-hard function for password hashing and proof-of-work applications. RFC 9106.
- Valsorda, F. et al. age file encryption format. age-encryption.org
