Transporting a package
Table of Contents
- 1. Summary
- 2. Detail
- 2.1. What a bundle is, the test for one, and what a package carries
- 2.2. Anatomy: manifest, part, package archive
- 2.3. Why nothing bulk is on the wire
- 2.4. The manifest
- 2.5. The container and the encoding
- 2.6. Checksums and compression
- 2.7. The coordinator
- 2.8. The owner
- 2.9. Transactionality
- 2.10. Progress, retry, and staleness
- 2.11. Worked example: a portfolio exported for ORE XML
- 2.12. Worked example: an environment export imported into development
- 2.13. What a package must not become
- 3. Patterns used
- 4. See also
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:
- 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.
- 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.
- 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.
- The part format version is independent of the package format
version.
part.format_versiongates the row encoding;package_format_versiongates 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.
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.
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:
- 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.
- Decompress each entry and verify the SHA-256 of the uncompressed
stream against
part.checksum. - Look up the idempotency key (package id, bundle id, kind, checksum). If this part already committed, reply applied with its recorded counts and stop.
- Compare
as_ofwith the owner's heads. A stale bundle is refused, as Progress, retry, and staleness states. - Decode the stream with the codec
part.format_versionnames, one row at a time, and validate each row against the owner's own model: required fields, keys, and references the owner holds. - Write each validated row through the generated repository for its
kind, inside one
unit_of_workfor 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. - Commit only after the last row, and only if the decoded count equals
part.row_countfor 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. - 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.
- Stamp the session's tenant and party, never a value from the payload.
- 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.
- 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.
- 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
bundlesis 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, andas_ofis what makes the set coherent rather than the sequence. - 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.
- 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.
- 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.
- 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,failedortimed_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
refdatabundle:refdata/currency,refdata/calendar,refdata/day_countandrefdata/convention; - the
marketdatabundle:marketdata/curveandmarketdata/vol_surface; - the
tradingbundle:trading/portfolio,trading/book,trading/tradeandtrading/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.
- The converter fixes
as_ofand asks the export operation for the portfolio. No owner reads outside that instant. - 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.
- 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.
- 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.
- The source environment exports the package and stores the archive and manifest, as in the previous example.
- 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. - It sends one message per bundle in
bundlesorder:ores.iamfirst, thenores.refdata, thenores.marketdata, thenores.trading. - Each owner fetches only its bundle's entries, verifies the checksums,
streams the rows, validates them, and applies them in one
unit_of_workper bundle. - 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.familyfor 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
- Data classification — the classification cluster's structure note: this page is the last step in its reading order, and it delivers the package that carries the classifications that cross owners.
- Data classifications: entity, list, family, bundle, package and artefact — the classifications this transport delivers, the relatedness test, and the anti-type it replaces.
- The data classification checklist — the form whose answers select this transport.
- The data tiers — which rows a package carries, and the dataset or seeder that delivers them.
- Codegen patterns: choosing a model shape —
ores.codegen.operation, the pattern the manifest is declared with, andores.codegen.family, the pattern each bundle's families are declared with. - Entity-table writes go through the repositories — the rule that binds the owner's apply to the generated repository of each kind.
- A repository works inside an open transaction and never holds credentials — why the owner's reads run on its one transaction.
- Object Storage — the store the archive and manifest live in, and its key rule.
- Data storage — the structure note that places the archive against the database and the stream.
- Database per Service — why the coordinator does not parse another owner's data.
- No service reads or writes another component's tables — the same rule as a memory.
- Bounded Request — the deadline every bundle message carries.
- Polymorphic payloads over NATS — the encoded-value rule the archive field breaks.
- Time and Timestamps: Architecture and Conventions — the bitemporal
instant
as_ofnames. - Sandbox — the isolation the package applies into, and the examine-before-live step.
- Message Queue — the bus, the subject namespace and the message contract.
- Knowledge — the index this page belongs to.