Transporting a package

Table of Contents

1. Summary

A package is an artefact that carries one or more bundles between systems as bytes. It has no relatedness constraint: the bundles it carries need not be related to one another, may belong to different owners, and the package as a whole is never atomic. Relatedness is what makes a bundle a bundle, and the absence of relatedness is what makes a package merely a package.

The atomic unit inside a package is the bundle: a set of related families, belonging to one owner, applied as one unit. The families are related when they share either a data invariant that must hold at one instant, or a single operation whose intent is all-or-nothing. The test is one sentence, completed without naming a file, a screen, a workflow step or a boundary format: "these families may not be written apart because _ must hold at one instant, or because one operation must apply all of them or none". If the blank needs a file, a screen, a step or a format, the families are not related, and the set is a package's contents. A bundle never spans two owners, because no transaction does.

A package is carried by a manifest, its parts and an archive. The manifest is a generated, modelled operation type naming every bundle and every part. A part is one bundle's rows of one kind, encoded as a msgpack stream. The package archive is a content-addressed, zstd-compressed ZIP that holds one entry per (owner, kind) and embeds the manifest. The bytes live in object storage. The manifest and the part references are kilobytes; the rows are never on the wire.

A coordinator reads only the manifest and dispatches one message per bundle, to that bundle's owner. Each message carries the archive URI and that bundle's part records, never the data. Each owner fetches only its own entries, verifies their checksums, streams and validates the rows, and applies its bundle atomically in its own unit_of_work. A package is never atomic across bundles or owners: no transaction spans two owners, global atomicity does not exist on this platform, and it must not be promised. Consistency comes from sequencing, a stated as_of instant, and a sandbox for examine-before-live. Progress and retry are per bundle.

2. Detail

2.1. What a bundle is, the test for one, and what a package carries

A bundle is a classification, not a file format and not a large message. It is sanctioned by the Bundle classification when its families are related and one owner holds them all. The families are open in number: a structure with its trades, its instruments and its legs is one bundle over trading's tables, and the trading owner applies it in one unit_of_work.

The test is the sentence above. Complete it without naming a file, a screen, a workflow step or a boundary format:

These families may not be written apart because __ must hold at one instant, or because one operation must apply all of them or none.

  • If the blank needs a file, a screen, a step or a format to be true, the families are not related. The set is a package's contents, and each family is its own bundle. Split it.
  • If the invariant covers only some of the families, the set is two or more bundles. Split it at the invariant.
  • If the invariant covers every family and one owner holds them, the set is a bundle, and one transaction applies it.
  • If the families are related only by provenance and several owners hold them, no bundle spans them. The set is a package carrying one bundle per owner, and this page is its transport.

A file's contents are never an invariant. A document lists conventions because the format lists them; that is a fact about ORE XML, not about conventions. An export that must be converted to a boundary format is a package when its bundles came from one system at one instant; the conversion needs every part at one instant, and the stated as_of is what gives it that.

The design constraint is the heterogeneity, not the size. Size makes the transport worth designing; heterogeneity across owners is why the transport cannot name the types in one message, and why the atomic unit is the bundle rather than the package.

2.2. Anatomy: manifest, part, package archive

A package carries a manifest, its parts and an archive, and the bundles it carries are records in the manifest. Each artefact has one writer, one place to live, and a fixed set of readers.

Artefact What it is Where it lives Who may read it
Manifest A generated operation object: the package format version, the producer, the as_of instant, the target, one record per bundle, and one record per part inside its bundle. It is encoded with the platform wire codec, like any other message. The object packages/{package_id}/manifest.msgpack. The same bytes are the first entry of the archive, named manifest.msgpack, so the archive describes itself. The coordinator, the producer, and a boundary converter such as the ORE XML writer. An owner receives its own bundle and part records in its message and does not need the whole manifest.
Part One bundle's rows of one kind, encoded as a msgpack stream of one generated type. It is a logical member of the archive, not a separate object. An entry in package.zip.zst or in the tar container, selected by its entry name or by its byte range. Its owner, and only its owner, during an apply. A converter may read every part during an export.
Package archive A content-addressed ZIP whose entries are zstd-compressed, with one entry per (owner, kind) and the manifest embedded. It is the only object that holds rows. The object packages/{package_id}/package.zip.zst, or package.tar.zst when the container is a tar. An owner reads one entry. A converter reads every entry. The coordinator does not open it.

The archive is assembled from staging objects written by the owners. Each owner encodes the parts of its bundle, compresses each entry with zstd, writes the compressed entry to a staging object, and returns a part record with the checksum and both sizes. The producer writes the manifest, then asks object storage to copy each staging entry into the archive under its entry name, at ZIP method 93, without recompressing it, and to add the manifest as the manifest.msgpack entry. It never decodes an entry. The producer deletes the staging objects once the archive's part checksums have been verified. No component ever encodes or compresses another owner's rows.

The package_id is SHA-256 over the canonical manifest bytes with every part.uri set to the empty string, encoded by the same codec in declaration order. The URI is the only field blanked, because it is derived from the id. The digest therefore covers as_of, the target, the producer, the bundles, every part checksum and the compressed layout. A re-encoding changes the id and therefore the key; the part checksums do not change, so semantic identity survives at the part level. A key is never reused for different semantic content.

2.3. Why nothing bulk is on the wire

The manifest and the part references are kilobytes. The data is in object storage. Efficiency comes from that structure, not from the encoding: encoding a package into a message would still move the same bytes onto the bus, and would add a decode step before any owner could start.

The reason a package does not travel as a message is validation, routing, per-bundle retry and the prohibition on an encoded value in a message field — not size.

  • Validation. A message is validated before it is accepted. A part's rows cannot be validated until they are decoded against the owner's model, which is the owner's job at its own boundary.
  • Routing. One message has one subject and one handler. A package has one bundle per owner and one owner per bundle, and the coordinator must dispatch each bundle to the owner that may write it.
  • Per-bundle retry. A failed part must cost one bundle, not a whole package. A message that carries the archive forces a re-send of every bundle to retry one.
  • The encoded-value rule. A message field that holds an encoded value has no static type at either end; see Polymorphic payloads over NATS.

A message also has a practical bound, and a package does not. That is a limit on the message, not the reason for the design.

2.4. The manifest

The manifest is a generated, modelled operation type. It is declared in ores.codegen.operation, generated with a schema and a codec, and encoded with the platform wire codec like any other message. It is never free-form JSON, never a std::string field holding JSON, and never inferred from the payload or from the archive. It is versioned, so a reader refuses a layout it does not know instead of guessing. It is the document that states which bundles the package carries, and the parts of each.

Field Type Meaning
package_format_version uint16 The layout version of the manifest, its bundle records and its part records. A reader refuses an unknown version.
container enum { zip_zstd, tar_zstd } The archive container and its compression. The reader selects the entry access method from it.
producer.component string The service that assembled the package. It names the origin of the manifest, not the owner of the data.
producer.version string The build version of that service.
producer.environment string The environment the package was produced in: development, test, staging or production, with its instance identifier.
as_of timestamp The bitemporal instant the package is a view of, in UTC with microsecond precision. See Progress, retry, and staleness below.
target.sandbox uuid, empty The sandbox the package applies into. Empty means an export-only package, which may be converted but never applied.
target.anchor uuid The official portfolio-tree node the sandbox is anchored to, or the party's anchor for an export-only package.
target.party uuid The party the apply acts as.
bundles array<bundle> One record per bundle, in application order. The order is the sequencing rule.
bundle.id string The bundle's stable name, unique within the package, such as trading-portfolio.
bundle.owner string The owning component's short name, such as trading; its service is ores.trading. This is the routing field: one bundle, one owner.
bundle.parts array<part> The bundle's part records. Every part names the same owner as its bundle.
part.owner string The owning component's short name, such as refdata; its service is ores.refdata. It restates bundle.owner, so a part record can be routed and diagnosed on its own.
part.kind string The stable generated kind name the part holds, such as currency, used for diagnostics and for the owner's validation.
part.format_version uint16 The version of the row encoding in this part. It is independent of package_format_version.
part.row_count uint64 The expected number of rows. It drives progress and catches a truncated entry.
part.checksum sha256 SHA-256 of the uncompressed entry bytes. The owner verifies it before applying a row.
part.uncompressed_size uint64 The number of bytes the checksum covers.
part.compressed_size uint64 The number of bytes the entry occupies in the archive. It is recorded separately from the checksum.
part.uri string The bucket and key of the archive that holds the part. All parts of one package carry the same URI.
part.entry entry_ref The ZIP entry name, or the byte range (offset and length) in a tar container.

The target is not decoration. An apply package with an empty target.sandbox is refused by the coordinator. A package aimed at a sandbox applies inside that sandbox's isolation; the anchor decides who may open the sandbox and who may then see the result, per Sandbox.

bundles is ordered by owner dependency: every bundle is contiguous, and the bundles appear in dependency order. The grouping is what lets one message per bundle preserve the global application order. A manifest that interleaves two bundles of one owner is refused, because a bundle is one atomic unit and no message can apply half of it before the other half.

bundles is O(number of bundles), and each bundle's parts are O(number of parts), never O(number of rows). A part record holds a row_count, not the rows. That bound is what makes the manifest validatable before any bytes move: its size is set by the number of owners, bundles and kinds, not by the size of the portfolio, so a portfolio of a hundred million rows still has a manifest of kilobytes. A manifest that grew with rows would be the message the package exists to avoid, and the coordinator would have to read the data to validate its own routing table.

2.5. The container and the encoding

The archive is a ZIP with one zstd-compressed entry per (owner, kind), named {owner}/{kind}, using ZIP compression method 93, plus the embedded manifest.msgpack entry. The ZIP central directory is the index: it gives every entry's name, compression method, compressed size and offset. The archive is content-addressed: a key is never reused for a different semantic content. A put replaces the whole object; a different compression is a new package_id. A tar_zstd container is allowed only when it carries a seekable zstd frame index that gives each entry's byte range; a plain tar.zst has no index, cannot give random access, and is refused.

Random access is the decisive point. A container with an index gives random access to a single part. An owner seeks to its entry, reads that entry and nothing else. A single large msgpack document cannot do this, because a msgpack array has no index: to reach the owner's part, a reader must decode every row of every earlier part. That would force one owner to read and parse bytes another owner produced, which is exactly what Database per Service forbids. The index is therefore a boundary control, not a performance detail.

The row encoding follows these rules:

  1. One entry, one type, one stream. An entry is a stream of msgpack maps, one map per row, and every map is the owner's generated type for that kind. The entry does not hold one array of all rows.
  2. Named fields, never positional arrays. A row is a map with string keys. A later field addition adds a key and does not shift an existing value, so an archived package still decodes after the type gains a field. A positional array binds meaning to position; adding a field in the middle silently reinterprets every archived row.
  3. Row-by-row streaming decode with bounded memory. The owner decompresses the entry and decodes one map at a time, validates it and writes it inside its transaction. Memory is the size of one row plus the transaction state, not the size of the package. This is what makes a portfolio spanning every instrument type tractable: the number of kinds changes the number of parts, not the memory an owner needs to apply one of them.
  4. The part format version is independent of the package format version. part.format_version gates the row encoding; package_format_version gates only the manifest layout and the container rules. A version 1 manifest may contain a version 2 part, and the owner selects the decoder from the part record. A new row field or a new part encoding does not force a new manifest layout.

2.6. Checksums and compression

Every part is checksummed uncompressed: SHA-256 over the concatenated msgpack maps exactly as the owner will decode them. The checksum is therefore a statement about the data, not about the compressor. A later zstd version, a different compression level or a re-encoding of the archive leaves the checksum unchanged, so the part's semantic identity survives a compressor upgrade. The compressed size is recorded separately in part.compressed_size; it describes the object, not the data. A re-encoding produces a new package_id, because the compressed layout is part of the manifest, and the recomputed checksum still matches.

The owner decompresses the entry, hashes the uncompressed stream, and compares the digest with the manifest before it applies the first row. Verification is a first pass over the entry and decoding is a second pass, so memory stays bounded and no row is applied before the digest matches. A mismatch is a failure of the part, never a warning.

Compress the archive, not the messages. The wire codec already gzips a message payload above 4096 bytes, so compressing a manifest or a part-reference message again is compressing compressed data. Do not wrap the archive in gzip either: its entries are already zstd-compressed. Compression belongs to the object-storage layer, where the archive and its entries live.

2.7. The coordinator

The coordinator is the apply operation's front end. It reads only the manifest. It validates the version, the target, the required fields, that every bundle names a known owner, that every (owner, kind) pair is unique, and that the bundle order is the declared sequence. It may HEAD the archive object to confirm that it exists; it does not GET, decompress or decode a part, and it does not compute a part checksum. That check belongs to the owner, which reads the bytes anyway.

The coordinator must never parse another owner's data, or database per service is broken. It has no schema for another service's rows, so any parse it performs is a second model that can only drift. The same rule is recorded in No service reads or writes another component's tables and in Database per Service.

It dispatches one message per bundle, carrying the package id, the bundle id, the correlation id, the archive URI, the manifest's as_of and target, and that bundle's part records. A reference, never the data. When a bundle holds several parts, the one message carries all of that bundle's records; the message is still O(parts this owner owns), and the owner applies them in manifest order and reports each part separately. No message carries rows, a document or an archive. The coordinator never merges two bundles into one message, because that would make one message carry two atomic units and two owners' rows.

The part message is a request with a deadline, per Bounded Request. The reply names the part and reports applied, rejected or failed, with the reason and the row count. The bundle's reply is the set of its parts' outcomes.

package_transport_sequence.png

Figure 1: The package round trip: the export operation collects one part record per bundle, object storage assembles and stores the archive, the coordinator reads only the manifest and sends one bundle reference per owner, and each owner reads only its own entry and applies its bundle in one transaction.

Source: package_transport_sequence.puml. Regenerate with plantuml -tpng doc/knowledge/architecture/package_transport_sequence.puml.

package_transport_layout.png

Figure 2: The package layout: the manifest object, the archive object and its entries, and the one set of objects each participant reads. The manifest names the bundles and their parts; the coordinator reads it and never opens an entry; each owner reads only its own entries; the converter reads every entry.

Source: package_transport_layout.puml. Regenerate with plantuml -tpng doc/knowledge/architecture/package_transport_layout.puml.

2.8. The owner

The owner's step is the only step that writes rows, and it writes only its own. It receives one message per bundle:

  1. Fetch only its bundle's parts, by entry name or byte range, from the archive the message names. It does not fetch the whole archive.
  2. Decompress each entry and verify the SHA-256 of the uncompressed stream against part.checksum.
  3. Look up the idempotency key (package id, bundle id, kind, checksum). If this part already committed, reply applied with its recorded counts and stop.
  4. Compare as_of with the owner's heads. A stale bundle is refused, as Progress, retry, and staleness states.
  5. Decode the stream with the codec part.format_version names, one row at a time, and validate each row against the owner's own model: required fields, keys, and references the owner holds.
  6. Write each validated row through the generated repository for its kind, inside one unit_of_work for the bundle, in manifest order. Never write a kind's table with a hand-written statement in a part handler; see Entity-table writes go through the repositories.
  7. Commit only after the last row, and only if the decoded count equals part.row_count for every part. A row that fails validation, a count that differs and any write error roll back the whole bundle. The bundle is all or nothing.
  8. Make every read of the bundle with the unit of work's context, so it runs on the bundle's transaction and sees the bundle's own writes; see A repository works inside an open transaction and never holds credentials.
  9. Stamp the session's tenant and party, never a value from the payload.
  10. Reply with the counts per part and, on failure, the reason.

The transaction is the owner's, so the bundle is the unit of atomicity. A failure halfway rolls back that bundle alone. No other owner sees a partial write, and no other owner is asked to compensate. A package with several bundles is therefore never atomic as a whole.

2.9. Transactionality

The guarantee a package carries depends on how many owners its bundles span. One case is a package that carries a single bundle, and that bundle is written in one transaction. The other is a package whose bundles span several services, and no single transaction covers it. Which case a package is decides the mechanism, and the mechanism decides the guarantee. A package is never atomic across bundles or owners in either case.

2.9.1. A package that carries one bundle, held by one service

A structure with its trades, its instruments and its legs is a set of related families, and every family of it is held by one owner. That is the Bundle classification: one bundle. It is written in one unit_of_work: one transaction, all or nothing, over the central row and every part the write names. No partially saved structure can ever be observed, because the transaction either commits every row or none of them.

Each family is the generated ores.codegen.family model, whose put writes the central row and that kind's parts in one transaction, per Codegen patterns: choosing a model shape. The unit of work is bounded by the database connection, and every read made with its context runs on its transaction; see A repository works inside an open transaction and never holds credentials.

2.9.2. A package whose bundles span several services

An ORE import touches reference data, trades and market data, and those rows belong to several services. That set cannot be one transaction. It is a package carrying one bundle per owner.

The platform does not support distributed transactions. There is no two-phase commit, no XA, and no transaction that spans services. The unit_of_work is function-local to one connection: it begins and ends on the connection the calling service holds, and no mechanism extends it across a call to another service.

This is a deliberate position, not an omission:

  • Contention and lock lifetime. A transaction held across owners holds its row locks for the whole of every remote call. One bundle that takes minutes would block every writer of every table it had touched for those minutes, and the wait would grow with the size of the bundle rather than with the row being written.
  • A pooled connection cannot be held across messages. The service's connection comes from a pool, and a message is handled between other messages. Keeping the transaction open while another owner applies its bundle would pin one pooled connection and one open transaction for an unbounded time, and the pool would run out.
  • Availability. Two-phase commit couples every owner's availability to the coordinator's and to the slowest participant's. An owner that cannot answer leaves every other owner's prepared transaction open and locked. The component boundaries forbid that coupling.
  • Ownership. A service reads and writes only its own tables. A transaction over two services needs one transaction manager over both databases, and it gives one service the power to commit rows it does not own, which Database per Service forbids.

Do not promise global atomicity to a caller, and do not describe a package as all-or-nothing across bundles or owners.

2.9.3. What replaces the distributed transaction

The consistency the transaction cannot supply comes from these, in the order a bundle passes through them.

  1. Per-bundle atomicity. Each owner applies its bundle in one transaction. A bundle is all or nothing, and the manifest names exactly which bundles and parts exist. A package that is complete when every bundle reports applied is a package whose bundles all resolve at the same instant.
  2. Sequencing by the workflow engine. The coordinator is the front end of a workflow run: each bundle dispatch is a step, and the steps of the run are the unit of progress, never the archive. The engine holds the state of the run and decides each next step from the results so far, which is the Process Manager. The order in bundles is the application order, and a bundle starts only after its predecessor's reply where the invariant has a direction. An environment export imports reference data before the configuration that names it, and trades before the structures that group them. A failed bundle blocks only its successors, which stay pending and are not dispatched; independent bundles continue. Where no bundle constrains another, they apply independently, and as_of is what makes the set coherent rather than the sequence.
  3. A declared failure policy. Each step states what a failure does: stop, or compensate, per Failure, compensation and retry. An import into a sandbox declares stop, because the sandbox is the compensating control and a retry of the failed bundle is safe. A definition whose completed steps are a liability may declare compensate instead, and the engine walks the completed steps in reverse. That is the Saga, and a compensation is a new action, not an erasure: the other owners saw the intermediate state, so a compensation writes something new and is not a rollback of what was already committed. A bundle that fails inside its own transaction is rolled back locally and asks nothing of anyone; compensation is for the completed steps a run has to give back.
  4. An idempotency ledger per owner. The owner records the key (package id, bundle id, kind, checksum) in the same transaction as the rows it applies. A second delivery of the same part finds the key and replies applied without writing again, so a retry is safe.
  5. A sandbox for examine-before-live. An import applies into a sandbox, so a wrong bundle is discarded with the whole sandbox rather than repaired row by row in live data. Promotion from the sandbox to live data is a separate, smaller operation with its own review; it is not a retry of the import. See Sandbox.
  6. An honest report of a partial outcome. The reply names each part and its outcome, and the package's progress is the set of its bundles. A partial result is reported as a partial result.

A package whose bundles span owners is therefore eventually consistent: its bundles resolve at one declared instant, as_of, and not at one commit. A partial result is a supported outcome, reported as such, and not a failure of the mechanism.

A package that needs global rollback is a modelling error. If the invariant truly needs every owner to commit together, the data belongs under one owner, and the set is then a bundle, not a package. Split the invariant until each bundle holds locally, or use the sandbox above.

2.9.4. What each case guarantees

Case Scope of atomicity Mechanism What the user sees on failure Guaranteed Not guaranteed
A package carrying one bundle held by one service one service, every family of the bundle one unit_of_work over the central rows and their parts nothing was written: the transaction rolled back all or nothing for the bundle; no partially saved structure is ever observed atomicity across a second service
A package whose bundles span several services per bundle, which is per owner one transaction per bundle, sequenced by a workflow run the bundles that applied stay applied, and the failed bundle is named per-bundle atomicity, and eventual consistency at as_of global atomicity across bundles, all-or-nothing, global rollback
One part of a bundle one owner's rows of one kind the checksum, the bundle's transaction and the idempotency key the part applied nothing; the bundle rolled back the part is applied at most once that the other bundles agree with it
A bundle that is retried the same one bundle the ledger key (package id, bundle id, kind, checksum) applied, with the counts the first delivery recorded a repeat writes nothing a new instant; the retry keeps the package's as_of
A package in a sandbox the sandbox discard the whole sandbox the sandbox is discarded as a whole, and no official row was touched a wrong import costs no live row promotion to live data, which is a separate reviewed operation

2.9.5. How a part fails

Failure What the coordinator does What the user sees
Part missing The owner's fetch of the entry fails. The coordinator marks that part failed and does not re-send the same immutable archive; the other bundles keep their own state. The part's owner, kind and entry name, and missing. The bundle is incomplete, and no rows from that part were applied.
Checksum mismatch The owner refuses before applying, because the bytes are not the bytes the manifest names. The coordinator marks the part failed and reports both digests; a retry of the same bytes cannot help. checksum_mismatch, with the expected and actual SHA-256. The bundle applied nothing; the other bundles are unchanged.
Kind unsupported The coordinator refuses the manifest at dispatch when no known owner claims the bundle. If the owner knows the kind but not the format version, it replies unsupported and the coordinator marks the part failed. The kind and part.format_version, and the component that must be upgraded. No message is sent, or no row is applied.
Apply refused The owner validates or applies and rejects the bundle; it rolls the whole bundle back and replies rejected with the row or key that failed. The coordinator records the reason and does not retry a data finding. The reason and the failing row or key. The bundle applied nothing; the other bundles are not rolled back.
Owner unreachable No responder is available, or the request cannot be delivered. The coordinator retries with bounded backoff until the package deadline, then marks the bundle failed. unreachable, with the owner's name and the attempts made. The bundle stays pending until the deadline, and other bundles proceed.
Deadline exceeded The bundle's bounded request expires. The coordinator marks it timed_out and re-offers it only through the idempotent retry below; the owner may still commit after the reply was lost. timed_out, with the deadline and the bundle's last known state. The coordinator reports the bundle as unknown until the retry or a reconciliation read confirms it.

2.10. Progress, retry, and staleness

Progress and retry are per bundle and per part, never per archive:

  • Progress. The coordinator records each part as pending, dispatched, applied, rejected, failed or timed_out, with its row count and reason, and reports each bundle as the set of its parts. The package's progress is the set of its bundles, not a percentage of a blob.
  • Retry. A retry re-sends the bundle reference. The archive's content is immutable, so the retry reads the same uncompressed bytes. The apply is idempotent on the key (package id, bundle id, kind, checksum): the owner records that key in its own part-apply ledger in the same transaction as the rows, and a second delivery of the same part finds the key and replies applied without writing again. The coordinator does not re-upload the archive and does not restart the whole package to fix one bundle.
  • Rejection. A rejected bundle is a finding about the data, not a transport failure. It is reported with the row or key that failed, and the other bundles are not rolled back.
  • Restart. Restarting the coordinator re-offers the outstanding bundles only.

as_of makes a stale package detectable rather than last-writer-wins. It is the bitemporal instant the package is a view of, per Time and Timestamps. A bundle is stale when the owner's heads have moved since that instant: any row the bundle would write has a version whose transaction time is later than as_of, or a key the bundle references in this owner's database was deleted or replaced after as_of. A bundle whose as_of is later than or equal to every relevant change is not stale, and it applies.

The owner refuses a stale bundle. It does not rebase it. A rebase is an explicit new export at a newer as_of, which produces a new manifest and a new package_id; the producer mints it, and no owner edits the manifest or silently applies an older bundle against newer rows.

Refusal is the chosen behaviour. A silent rebase would make each bundle a different instant, which destroys the one property the package exists to provide. The archived package is the evidence of what was imported and when; a rebase that changed its meaning would destroy that evidence. And a refusal is detectable: the user sees the stale bundle and can ask for a fresh package, while a rebase reports success and leaves the mixture invisible.

2.11. Worked example: a portfolio exported for ORE XML

A portfolio spans currencies, calendars, conventions, curves, trades and every instrument type the book holds. The export must give the ORE XML converter one instant, or the document mixes two books. The artefact is a package; the atomic units inside it are one bundle per owner.

The bundles and their parts. One bundle per owner, and one part per (owner, kind), in application order:

  • the refdata bundle: refdata/currency, refdata/calendar, refdata/day_count and refdata/convention;
  • the marketdata bundle: marketdata/curve and marketdata/vol_surface;
  • the trading bundle: trading/portfolio, trading/book, trading/trade and trading/instrument/*, one part per instrument kind.

This is exactly the case a closed message type cannot carry: the last group gains a member whenever the book gains an instrument kind.

The manifest in outline.

package_format_version  1
container               zip_zstd
producer                {trading-export, 1.0.0, production}
as_of                   2026-10-17T09:00:00.000000Z
target                  {sandbox: empty (export only),
                         anchor: desk-42, party: portfolio-owner}
bundles                 refdata, marketdata, trading (in that order)
                        one part record per (owner, kind) in its bundle

The sequence.

  1. The converter fixes as_of and asks the export operation for the portfolio. No owner reads outside that instant.
  2. Each owner reads its rows in one transaction, encodes and zstd-compresses its entries, writes a staging object per part, and returns a part record with the checksum, row count and both sizes.
  3. The producer writes the manifest, then asks storage to assemble the staging entries into one archive and to add the manifest entry. It verifies every part checksum.
  4. The converter reads the manifest, then streams each entry into the ORE XML writer. It writes to a temporary file and renames it only after every part has been read and checked.

The atomicity actually obtained. There is no global transaction. Each owner's bundle is one consistent read at as_of, and the conversion is all or nothing at its own boundary: if any part is missing or fails its checksum, the converter writes no ORE XML at all. A failure does not roll back a read, because there is nothing to roll back; it withholds the output. That is consistency by the instant plus an atomic output file, not distributed atomicity, and not package-wide atomicity.

2.12. Worked example: an environment export imported into development

A developer needs a production defect reproduced from one snapshot of the environment: identity, reference data, market data and trades together. The snapshot is a package carrying one bundle per owner.

The bundles and their parts. The iam bundle: iam/tenant and iam/party. The refdata bundle: refdata/currency, refdata/calendar and refdata/convention. The marketdata bundle: marketdata/curve. The trading bundle: trading/book, trading/portfolio, trading/trade and trading/instrument/*. Owners: ores.iam, ores.refdata, ores.marketdata and ores.trading.

The bundles are not related to one another: iam rows and marketdata rows share no invariant and no operation. They are in one package because one export produced them, which is provenance and not relatedness. The package is therefore never atomic as a whole.

The manifest in outline.

package_format_version  1
container               zip_zstd
producer                {environment-export, 1.0.0, production}
as_of                   2026-10-17T09:00:00.000000Z
target                  {sandbox: 7f3c..., anchor: desk-42,
                         party: 8a21...}
bundles                 iam, refdata, marketdata, trading
                        ordered by reference, not alphabetical

The sequence.

  1. The source environment exports the package and stores the archive and manifest, as in the previous example.
  2. The development coordinator reads the manifest, checks that the sandbox is open, that its anchor matches target.anchor, and that every bundle names a known owner.
  3. It sends one message per bundle in bundles order: ores.iam first, then ores.refdata, then ores.marketdata, then ores.trading.
  4. Each owner fetches only its bundle's entries, verifies the checksums, streams the rows, validates them, and applies them in one unit_of_work per bundle.
  5. A person examines the sandbox. Promotion to live data is a separate operation with its own review.

The atomicity actually obtained. Each bundle is atomic in its owner's database. If trading rejects a trade after refdata applied, the refdata rows stay in the sandbox and the trading bundle applied nothing; the sandbox is then discarded as a whole, and no official row was touched. The package does not provide global rollback, and the sandbox is what makes that limit safe.

2.13. What a package must not become

  • An opaque archive inside a message field. That is the artefact anti-pattern in Data classifications: the receiver must unpack before it can validate, no owner can be routed to, and one failed row costs the whole archive. It also breaks the encoded-value rule in Polymorphic payloads over NATS.
  • A union of unrelated entities presented as one struct. That is the anti-type in Data classifications: one typed array per kind, named after a file or a screen, with no manifest, no owner boundary and no instant.
  • A payload the coordinator parses. A coordinator that decodes another owner's rows is a second model. It is the composite scope the classification page refuses, in Data classifications, and it breaks Database per Service. The manifest is the only thing the coordinator reads.
  • A package treated as one atomic set. The package is the carriage, not the unit of consistency. A caller that needs all-or-nothing is asking for a bundle, and a bundle belongs to one owner. A package that promises all-or-nothing across owners promises what no transaction can give.
  • A substitute for a bundle when one owner suffices. If one owner writes every family and one transaction can hold the invariant, the set is a bundle; use ores.codegen.family for its families, not this transport. The classification page owns that decision, in Data classifications, and the checklist records it, in the data classification checklist.

3. Patterns used

  • Saga — the declared failure policy: the completed bundles are local steps whose compensations run in reverse, and a compensation is a new action rather than a rollback.
  • Process Manager — the coordinator holds the run's state and decides each next step from the bundles' replies.
  • Database per Service — one owner per bundle, and a coordinator that reads only the manifest and never another owner's rows.

4. See also

Emacs 29.3 (Org mode 9.6.15)