Data module stable¶
Purpose & Scope¶
The Data module owns CORA's record of the research data products the facility produces or registers, from the logical content record down to the byte-copy at a storage backend and up to the citable publication package. Five aggregates carry the responsibility.
Dataset is the logical content record: a product's id, name, URI, checksum, byte size, encoding, lineage edges, lifecycle status, and trust intent. It is the metadata record, not the bytes themselves. Distribution is one byte-identical materialization of a Dataset at a storage-kind Supply (a DCAT-3 dcat:Distribution): the same content reachable at a concrete URI on a concrete backend. Edition is a citable publication package over a frozen set of Production-intent Datasets, the aggregate that mints a DOI and serializes a citation manifest. Acquisition is the birth-certificate fact that a producing Asset captured bytes into a Dataset under an optional Run context. Attestation is the verification fact that some integrity check (checksum, format, bit-rot, conformance) ran against a Dataset or one of its Distributions, with a recorded outcome.
Dataset is the hub; the other four reference it. Acquisition records how a Dataset came to be; Distribution records where its bytes live; Attestation records that its bytes were checked; Edition records that it was published. The bytes themselves live wherever the URIs point (object storage, transfer service, POSIX filesystem, content-addressed store); the Data module records only what is needed to identify, cite, find, verify, and publish them.
Out of scope
- Storage tier transitions on Distribution. The
DistributionStatusFSM ships its full four-state shape day-one, but only the genesisRegisteredstate is reachable today; theVerified/Staleflips are driven indirectly by Attestation facts through the projection writer, and a request-pathverify/mark-stale/discardslice is deferred until a storage-tier consumer ships. - A
Transferaggregate. Moving bytes between backends is modeled as aMethod+TransferPortin the Recipe module, not as a Data aggregate.Distributionis the resting materialization, not the transfer log. - Persistent external identifier minting beyond DOI.
Editionmints a DOI through the sharedPersistentIdentifierMinterport at publish time and tombstones it at withdraw. Handle, ARK, and IGSN flows, and DataCite event-data harvesting, are deferred until a real consumer asks. - PROV-O vocabulary in the domain core. In-domain lineage stays as the
derived_fromedge set on each Dataset plus theAcquisitioncapture fact. PROV-O export (prov:wasDerivedFrom,prov:wasGeneratedBy) lives at an API export adapter when a real consumer asks. - Multi-checksum algorithms. Two algorithms are accepted:
sha256(a single file) andsha256-tree(a directory of files, hashed as a deterministic tree-hash over the whole directory, the digest a directory-output compute artifact carries). Both produce a 64-lowercase-hex value, so the value format is identical and only the algorithm tag differs. The(algorithm, value)shape stays forward-compatible for adding BLAKE3, SHA3, or other algorithms when a real consumer asks. - Re-promotion from Retracted. Retracted is terminal. Operators who want to publish a corrected version register a new Dataset with
derived_frompointing at the retracted one. - Concrete Attestation kinds beyond checksum.
AttestationKindships all four values, but onlyChecksumVerifiedhas a concrete evidence shape and verifier adapter today;FormatValidated,ConformsToValidated, andBitRotCheckedare reserved for follow-on slices.
Aggregates¶
| Name | Identity | State summary | FSM |
|---|---|---|---|
Dataset |
id: UUID |
id, name, uri, checksum, byte_size, encoding, producing_run_id?, producing_procedure_id?, subject_id?, derived_from: frozenset[UUID], status: DatasetStatus, producing_run_end_state: str?, producing_actuation_kind: str?, intent: Intent, used_calibration_ids: frozenset[UUID] |
yes (2-state lifecycle plus orthogonal 3-state Intent) |
Distribution |
id: UUID |
id, dataset_id, supply_id, uri: DistributionUri, checksum, byte_size, encoding, access_protocol: AccessProtocol, status: DistributionStatus, transition attribution (registered_at/by, verified_at/by?, marked_stale_at/by?, discarded_at/by?, discard_reason?) |
yes (4-state; only Registered reachable today) |
Edition |
id: UUID |
id, kind: EditionKind, title, dataset_ids: frozenset[UUID], creators: tuple[Creator], publisher_facility_code?, publication_year?, license?, content_hash?, external_pid?, status: EditionStatus, transition attribution (registered_at/by, sealed_at/by?, published_at/by?, withdrawn_at/by?, withdrawal_reason?) |
yes (4-state) |
Acquisition |
id: UUID |
id, dataset_id, producing_asset_id, producing_run_id?, captured_at, settings: dict, evidence: dict, recorded_at, recorded_by, status: AcquisitionStatus |
no (terminal at genesis) |
Attestation |
id: UUID |
id, dataset_id, distribution_id?, kind: AttestationKind, outcome: AttestationOutcome, evidence: AttestationEvidence, attested_at, attested_by, status: AttestationStatus |
no (terminal at genesis) |
Dataset cross-aggregate references (producing_run_id, producing_procedure_id, subject_id, derived_from) are eventual-consistency: the handler pre-loads each referenced aggregate to confirm it exists, the decider applies any further checks (no derived_from edges into Discarded Datasets), and no fold-time re-validation runs. All are optional: a Dataset can be registered with no producing Run or Procedure (externally-sourced data), no Subject (calibration scans, dark fields), and no upstream lineage (raw data captured at the source). producing_run_id and producing_procedure_id are the two producer arms: most Datasets come from a Run, while a conducted Procedure that retains data uses producing_procedure_id (the secondary arm). producing_run_end_state captures the producing Run's terminal status at registration time per the capture-don't-recompute principle, producing_actuation_kind snapshots the conduct's observed actuation kind (Physical / Simulated / Hybrid) that powers the promote-gate simulator guard, and used_calibration_ids is the AsShot citation set (the CalibrationRevision.id values the product actually used), set once and immutable thereafter.
Distribution is the materialization tier: one Dataset may have many Distributions (a raw copy on POSIX, an archived copy on tape, a public copy on object storage), each a byte-identical copy at a different storage-kind Supply. The checksum and byte_size must equal the parent Dataset's (the byte-identical-copy invariant). Edition is the publication tier: a citable package that freezes a set of Production-intent Datasets, records ordered creators, and on publish mints a DOI and serializes a manifest in one of several EditionKind formats. Acquisition and Attestation are both recorded-fact-chains: terminal at genesis, one stream per fact, exactly one event ever; they record provenance and verification facts without a lifecycle.
Value Objects¶
| Name | Shape | Where used |
|---|---|---|
DatasetName |
trimmed string, 1-200 chars | Dataset.name |
DatasetUri |
trimmed string, 1-2048 chars, must have a URI scheme, scheme must not be in the blocked list | Dataset.uri |
DatasetChecksum |
(algorithm, value) pair; algorithm is sha256 (single file) or sha256-tree (directory tree-hash), value must be 64 lowercase hex chars for both |
Dataset.checksum, Distribution.checksum |
DatasetEncoding |
(media_type, conforms_to) pair; media_type is loose MIME-shape string 1-200 chars, conforms_to is a frozenset of up to 16 profile URIs each 1-2048 chars |
Dataset.encoding, Distribution.encoding |
DatasetStatus |
closed StrEnum: Registered | Discarded |
Dataset.status |
Intent |
open StrEnum (additive); today: Trial | Production | Retracted |
Dataset.intent |
PromotionReason / DemotionReason / DatasetDiscardReason |
trimmed string, 1-500 chars | the respective Dataset transition reasons (serialized as plain str on the events) |
DistributionUri |
trimmed string, 1-2048 chars; same scheme + blocklist shape as DatasetUri |
Distribution.uri |
AccessProtocol |
closed StrEnum: HTTPS | Globus | S3 | POSIX | NFS | OAI_PMH |
Distribution.access_protocol |
DistributionStatus |
closed StrEnum: Registered | Verified | Stale | Discarded |
Distribution.status |
EditionTitle |
trimmed string, 1-500 chars | Edition.title |
Creator |
(actor_id, affiliation?); affiliation trimmed 1-200 chars; order is publication-significant (never sorted) |
members of Edition.creators |
SpdxIdentifier |
trimmed string, 1-100 chars, SPDX license-id char class | Edition.license |
WithdrawalReason |
trimmed string, 1-500 chars | EditionWithdrawn.withdrawal_reason |
EditionKind |
closed StrEnum: ROCrate | DataCite | Croissant | OAIS_AIP | OAIS_DIP | NeXus (only ROCrate has a serializer today) |
Edition.kind |
EditionStatus |
closed StrEnum: Registered | Sealed | Published | Withdrawn |
Edition.status |
PersistentIdentifier |
(scheme, value); the DOI minted at publish |
Edition.external_pid |
AcquisitionStatus |
closed StrEnum: Recorded (single value; terminal at genesis) |
Acquisition.status |
AttestationKind |
closed StrEnum: ChecksumVerified | FormatValidated | ConformsToValidated | BitRotChecked (only ChecksumVerified reachable today) |
Attestation.kind |
AttestationOutcome |
closed StrEnum: Match | Mismatch | Unreachable |
Attestation.outcome |
AttestationStatus |
closed StrEnum: Recorded (single value; terminal at genesis) |
Attestation.status |
AttestationEvidence |
discriminated union keyed on Attestation.kind; only the ChecksumVerifiedEvidence arm (algorithm, value?, verifier_supply_id, verifier_kind, error_detail?) is concrete today |
Attestation.evidence |
DatasetUri and DistributionUri validation is intentionally loose: anything with a non-empty scheme after trim, within the length cap, whose scheme is not in the blocked list (javascript, vbscript, data, about, view-source). The blocklist is defensive against storing a URI a downstream UI would render as a clickable XSS vector. Real storage schemes (s3, https, file, globus, posix, and so on) are not constrained.
Intent is the one open StrEnum in the BC, on purpose: future trust values (Calibration, Superseded, Authoritative) can land additively. Every other enum here is closed, with its full value set fixed day-one even where only one value is reachable (DistributionStatus, EditionKind, AttestationKind); the unreachable values are present so follow-on slices add projection-writer code only, with no schema migration.
FSM¶
The Data module runs three lifecycles across two aggregates. Dataset carries two orthogonal axes; Edition carries one four-state lifecycle; Distribution carries a four-state lifecycle of which only the genesis state is reachable today. Acquisition and Attestation have no FSM: each is recorded once and never transitions.
Dataset¶
stateDiagram-v2
direction LR
[*] --> Registered: register_dataset
Registered --> Discarded: discard_dataset
Discarded --> [*]
stateDiagram-v2
direction LR
[*] --> Trial: register_dataset
Trial --> Production: promote_dataset
Production --> Retracted: demote_dataset
Retracted --> [*]
| Axis | From | To | Command | Event |
|---|---|---|---|---|
| status | [*] |
Registered |
register_dataset |
DatasetRegistered |
| status | Registered |
Discarded |
discard_dataset |
DatasetDiscarded |
| intent | [*] |
Trial |
register_dataset (default) |
DatasetRegistered |
| intent | Trial |
Production |
promote_dataset |
DatasetPromoted |
| intent | Production |
Retracted |
demote_dataset |
DatasetDemoted |
Strict re-entry semantics apply across both axes: re-discarding, re-promoting, and re-demoting all raise.
Distribution¶
stateDiagram-v2
direction LR
[*] --> Registered: register_distribution
Registered --> Verified: (Attestation ChecksumVerified Match)
Registered --> Stale: (Attestation ChecksumVerified Mismatch)
Verified --> Stale
Discarded --> [*]
| From | To | Command | Event |
|---|---|---|---|
[*] |
Registered |
register_distribution |
DistributionRegistered |
Only the genesis transition is request-reachable today. The Verified and Stale states are written by the Distribution projection writer in response to AttestationRecorded facts (a ChecksumVerified Match flips the row to Verified, a Mismatch flips it to Stale, Unreachable is a no-op). No DistributionVerified / DistributionMarkedStale / DistributionDiscarded aggregate event ships yet; those names are reserved.
Edition¶
stateDiagram-v2
direction LR
[*] --> Registered: register_edition
Registered --> Sealed: seal_edition
Sealed --> Published: publish_edition
Published --> Withdrawn: withdraw_edition
Withdrawn --> [*]
| From | To | Command | Event |
|---|---|---|---|
[*] |
Registered |
register_edition |
EditionRegistered |
Registered |
Registered |
add_dataset_to_edition / remove_dataset_from_edition |
EditionDatasetAdded / EditionDatasetRemoved |
Registered |
Sealed |
seal_edition |
EditionSealed |
Sealed |
Published |
publish_edition |
EditionPublished |
Published |
Withdrawn |
withdraw_edition |
EditionWithdrawn |
Membership mutates only in Registered; seal_edition freezes the member set (the EditionSealed payload's sealed_dataset_ids is the immutability anchor) and computes content_hash. publish_edition mints the DOI and serializes the manifest, recording external_pid and a distinct published_content_hash. Withdrawn is terminal and tombstones the DOI.
Acquisition and Attestation¶
Neither has an FSM. record_acquisition and record_attestation each append exactly one genesis event (AcquisitionRecorded, AttestationRecorded) to a fresh stream whose status is Recorded and never changes. A correction is a new fact on a new stream, never a transition (the reserved AttestationSuperseded event would link the chain when that slice lands).
Guards. Beyond source-state checks, the write slices enforce cross-aggregate and cross-field invariants; see Cross-aggregate invariants.
Events¶
The five aggregates emit fourteen event types. Every event carries a fold-symmetry attribution field (registered_by, promoted_by, discarded_by, recorded_by, attested_by, and so on), the envelope principal_id of the actor that issued the command, paired with the event's occurred_at.
Dataset events¶
| Event | Payload sketch | When emitted |
|---|---|---|
DatasetRegistered |
dataset_id, name, uri, checksum, byte_size, encoding, producing_run_id?, producing_procedure_id?, subject_id?, derived_from, producing_run_end_state?, producing_actuation_kind?, intent (always Trial), used_calibration_ids, registered_by, occurred_at |
register_dataset succeeds (genesis) |
DatasetPromoted |
dataset_id, reason, promoted_by, occurred_at |
promote_dataset succeeds; intent flips to Production |
DatasetDemoted |
dataset_id, reason, demoted_by, occurred_at |
demote_dataset succeeds; intent flips to Retracted |
DatasetDiscarded |
dataset_id, reason, discarded_by, occurred_at |
discard_dataset succeeds; status flips to Discarded |
DatasetRegistered payloads carry derived_from, conforms_to, and used_calibration_ids as sorted lists so the same logical Dataset yields byte-identical jsonb (which keeps the idempotency-key hash stable).
Distribution events¶
| Event | Payload sketch | When emitted |
|---|---|---|
DistributionRegistered |
distribution_id, dataset_id, supply_id, uri, checksum, byte_size, encoding, access_protocol, registered_by, occurred_at |
register_distribution succeeds (genesis) |
DistributionVerified, DistributionMarkedStale, and DistributionDiscarded are named but not shipped; the Verified / Stale projection states are driven by Attestation facts, not by these events.
Edition events¶
| Event | Payload sketch | When emitted |
|---|---|---|
EditionRegistered |
edition_id, kind, title, dataset_ids, creators, publisher_facility_code?, publication_year?, license?, registered_by, occurred_at |
register_edition succeeds (genesis) |
EditionDatasetAdded |
edition_id, dataset_id, added_by, occurred_at |
add_dataset_to_edition succeeds (Registered only) |
EditionDatasetRemoved |
edition_id, dataset_id, removed_by, occurred_at |
remove_dataset_from_edition succeeds (Registered only) |
EditionSealed |
edition_id, content_hash, sealed_dataset_ids, publisher_facility_code, publication_year, license, sealed_by, occurred_at |
seal_edition succeeds; member set frozen |
EditionPublished |
edition_id, external_pid_scheme, external_pid_value, published_content_hash, published_by, occurred_at |
publish_edition succeeds; DOI minted |
EditionWithdrawn |
edition_id, withdrawal_reason, withdrawn_by, occurred_at |
withdraw_edition succeeds; DOI tombstoned |
Acquisition events¶
| Event | Payload sketch | When emitted |
|---|---|---|
AcquisitionRecorded |
acquisition_id, dataset_id, producing_asset_id, producing_run_id?, captured_at, settings, evidence, recorded_by, occurred_at |
record_acquisition succeeds (genesis-and-terminal) |
captured_at is the instrument wall-clock (caller-asserted provenance); occurred_at is the CORA-side wall-clock when the record landed. The dual-time split is deliberate.
Attestation events¶
| Event | Payload sketch | When emitted |
|---|---|---|
AttestationRecorded |
attestation_id, dataset_id, distribution_id?, kind, outcome, evidence, attested_by, occurred_at |
record_attestation succeeds (genesis-and-terminal) |
AttestationSuperseded is reserved for a future correction-chain slice.
Slices¶
| Command / query | REST | MCP tool |
|---|---|---|
AddDatasetToEdition |
POST /editions/{edition_id}/datasets/{dataset_id} |
add_dataset_to_edition |
DemoteDataset |
POST /datasets/{dataset_id}/demote |
demote_dataset |
DiscardDataset |
POST /datasets/{dataset_id}/discard |
discard_dataset |
DiscardDistribution |
POST /distributions/{distribution_id}/discard |
discard_distribution |
GetDataset |
GET /datasets/{dataset_id} |
get_dataset |
ListDatasets |
GET /datasets |
list_datasets |
PromoteDataset |
POST /datasets/{dataset_id}/promote |
promote_dataset |
PublishEdition |
POST /editions/{edition_id}/publish |
publish_edition |
RecordAcquisition |
POST /acquisitions |
record_acquisition |
RecordAttestation |
POST /attestations |
record_attestation |
RegisterDataset |
POST /datasets |
register_dataset |
RegisterDistribution |
POST /distributions |
register_distribution |
RegisterEdition |
POST /editions |
register_edition |
RemoveDatasetFromEdition |
DELETE /editions/{edition_id}/datasets/{dataset_id} |
remove_dataset_from_edition |
SealEdition |
POST /editions/{edition_id}/seal |
seal_edition |
WithdrawEdition |
POST /editions/{edition_id}/withdraw |
withdraw_edition |
The five create-style slices (register_dataset, register_distribution, record_acquisition, register_edition, record_attestation) are idempotency-wrapped (an Idempotency-Key header replays the prior result).
Errors per slice. Beyond Pydantic boundary 422s, each slice raises (the Dataset slices unchanged from prior shape):
RegisterDatasetInvalidDatasetName,InvalidDatasetUri,InvalidDatasetChecksum,InvalidDatasetByteSize,InvalidDatasetEncoding,InvalidDerivedFrom,InvalidUsedCalibrations,DatasetAlreadyExists,ProducingRunNotFound,LinkedSubjectNotFound,DerivedFromDatasetsNotFound,DerivedFromDatasetsDiscarded,UnauthorizedPromoteDataset/DemoteDataset/DiscardDatasetDatasetNotFound, the relevantInvalid<X>Reason, the already-in-target guard (DatasetAlreadyPromoted/DatasetAlreadyRetracted), the cross-field guard (DatasetCannotPromote/DatasetCannotDemote/DatasetCannotDiscard),UnauthorizedRegisterDistributionDistributionSupplyNotFound(404),DistributionCannotRegisterOnNonStorageSupply,DistributionCannotRegisterOnDiscardedDataset,DistributionChecksumMismatch,DistributionByteSizeMismatch,DistributionAlreadyExists(409), boundaryInvalid<X>(400),UnauthorizedRecordAcquisitionAcquisitionAssetNotFound,AcquisitionRunNotFound(404),AcquisitionCannotRecordWithoutCapturing(the producing Asset's Family does not declare theCAPTURINGaffordance),AcquisitionAlreadyExists(409), invalidsettings/evidence/captured_at(400),UnauthorizedRegisterEditionEmptyDatasetIdsAtRegistration, boundaryInvalid<X>(400),UnauthorizedAddDatasetToEdition/RemoveDatasetFromEditionEditionNotFound(404),EditionNotInRegisteredState,EditionDatasetAlreadyMember/EditionDatasetNotMember,EditionCannotBindToDiscardedDataset(409),UnauthorizedSealEditionEditionNotFound,EditionPublisherNotFound,EditionDatasetDistributionNotFound(404),EditionCannotSeal,EditionCannotBeEmpty,EditionDatasetsNotAllProduction,EditionCannotSealOnDiscardedDataset,EditionLicenseRequiredForKind(409),UnauthorizedPublishEdition/WithdrawEditionEditionNotFound(404),EditionCannotPublish/EditionCannotWithdraw(409),EditionSerializerError/PersistentIdentifierMinterTombstoneError/PersistentIdentifierMintError(502, port failures),EditionPublishedWithoutContentHash/EditionWithdrawnWithoutPersistentId(500, invariant breach),UnauthorizedRecordAttestationAttestationDistributionNotFound(404),AttestationKindRequiresDistribution,AttestationKindRejectsDistribution,AttestationDistributionDatasetMismatch,AttestationChecksumEvidenceMismatch,AttestationAlreadyExists(409),AttestationKindNotYetSupported,ChecksumVerifierUnsupportedScheme, invalidkind/outcome/evidence(400),UnauthorizedGetDatasetDatasetNotFound.ListDatasets: boundary 422 only.
Ports¶
| Port | Kind | Adapter(s) | Used by |
|---|---|---|---|
ChecksumVerifierPort |
BC-local | HttpRangeChecksumAdapter |
record_attestation (ChecksumVerified): fetches the bytes and recomputes the digest, returning Match / Mismatch / Unreachable |
DistributionLookup |
BC-local | PostgresDistributionLookup, InMemoryDistributionLookup |
seal_edition: confirms each member Dataset has a canonical Distribution |
EditionSerializerPort |
BC-local (per-kind) | RoCrate12Adapter, stub |
publish_edition: serializes the citation manifest for the Edition's kind |
PersistentIdentifierMinter |
shared (cora/shared/ports) |
StubPersistentIdentifierMinter |
publish_edition mints, withdraw_edition tombstones the DOI |
SupplyLookup |
cross-BC (Supply) | (Supply BC adapters) | register_distribution: resolves supply_id, requires a storage-kind Supply |
AssetLookup |
cross-BC (Equipment) | (Equipment BC adapters) | record_acquisition: resolves producing_asset_id and reads family_affordances for the CAPTURING gate |
FacilityLookup |
cross-BC (Federation) | (Federation BC adapters) | seal_edition / publish_edition: resolves publisher_facility_code |
Cross-aggregate invariants¶
The five aggregates compose around the Dataset hub; the write slices enforce the binding rules so a stranded reference cannot propagate into a downstream read.
- Byte-identical copy (Distribution). A
Distribution.checksumandbyte_sizemust equal the parentDataset's (DistributionChecksumMismatch/DistributionByteSizeMismatch). A Distribution is a copy of the same bytes at a different address, not a derivative. - Storage-kind Supply only (Distribution).
register_distributionrequires the resolvedsupply_idto name a storage-kind Supply (DistributionCannotRegisterOnNonStorageSupply). - No Distribution on a Discarded Dataset. A
DiscardedDataset has had its bytes deleted, so a new byte-copy is rejected (DistributionCannotRegisterOnDiscardedDataset).Retractedintent is allowed: intent (trust) and status (lifecycle) are orthogonal, and a retracted-but-not-discarded Dataset still has bytes. - Production-only seal (Edition).
seal_editionrequires the member set to be non-empty, every member Dataset to beProductionintent and notDiscarded, and each to have a canonical Distribution;DataCiteandCroissanteditions additionally require alicense, and the publisher Facility must resolve. - Attestation dual-binding.
ChecksumVerified,FormatValidated, andBitRotCheckedare byte-level and require adistribution_id;ConformsToValidatedis content-level and forbids one (AttestationKindRequiresDistribution/AttestationKindRejectsDistribution). When adistribution_idis present it must belong to the named Dataset (AttestationDistributionDatasetMismatch), and aChecksumVerifiedoutcome must agree with its recorded evidence (AttestationChecksumEvidenceMismatch). - Capture affordance (Acquisition).
record_acquisitionrequires the producing Asset's Family to declare theCAPTURINGaffordance (AcquisitionCannotRecordWithoutCapturing), read throughAssetLookup.family_affordances.
Storage & Projections¶
Five read-side tables back the Data module, one per aggregate. Cross-aggregate referential integrity is enforced by decider-time pre-loads, not foreign keys; the new tables carry RLS FORCE policies uniform with the rest of the BC.
CREATE TABLE proj_data_dataset_summary (
dataset_id UUID PRIMARY KEY,
name TEXT NOT NULL,
uri TEXT NOT NULL,
producing_run_id UUID,
subject_id UUID,
status TEXT NOT NULL CHECK (
status IN ('Registered', 'Discarded')
),
used_calibration_ids UUID[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX proj_data_dataset_summary_keyset_idx
ON proj_data_dataset_summary (created_at, dataset_id);
CREATE INDEX proj_data_dataset_summary_run_idx
ON proj_data_dataset_summary (producing_run_id)
WHERE producing_run_id IS NOT NULL;
CREATE INDEX proj_data_dataset_summary_subject_idx
ON proj_data_dataset_summary (subject_id)
WHERE subject_id IS NOT NULL;
CREATE INDEX proj_data_dataset_summary_used_calibration_ids_gin_idx
ON proj_data_dataset_summary USING GIN (used_calibration_ids);
The GIN index on used_calibration_ids supports the "every Dataset that cites revision X" read pattern through the @> containment operator. checksum, byte_size, encoding, derived_from, and intent are intentionally not projected as filter columns; they are single-record detail or list-shaped and read from GET /datasets/{id} or the folded stream.
CREATE TABLE proj_data_distribution_summary (
distribution_id UUID PRIMARY KEY,
dataset_id UUID NOT NULL,
supply_id UUID NOT NULL,
uri TEXT NOT NULL,
checksum JSONB NOT NULL,
byte_size BIGINT NOT NULL CHECK (byte_size >= 0),
encoding JSONB NOT NULL,
access_protocol TEXT NOT NULL CHECK (
access_protocol IN ('HTTPS', 'Globus', 'S3', 'POSIX', 'NFS', 'OAI_PMH')
),
status TEXT NOT NULL DEFAULT 'Registered' CHECK (
status IN ('Registered', 'Verified', 'Stale', 'Discarded')
),
registered_at TIMESTAMPTZ NOT NULL,
registered_by UUID NOT NULL,
verified_at TIMESTAMPTZ,
verified_by UUID,
marked_stale_at TIMESTAMPTZ,
marked_stale_by UUID,
discarded_at TIMESTAMPTZ,
discarded_by UUID,
discard_reason TEXT,
backfilled BOOLEAN NOT NULL DEFAULT FALSE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX proj_data_distribution_summary_triple_uq
ON proj_data_distribution_summary (dataset_id, supply_id, uri)
WHERE status != 'Discarded';
CREATE INDEX proj_data_distribution_summary_dataset_idx
ON proj_data_distribution_summary (dataset_id);
CREATE INDEX proj_data_distribution_summary_conforms_to_idx
ON proj_data_distribution_summary USING GIN ((encoding -> 'conforms_to'));
The partial UNIQUE index captures "this Dataset has at most one non-Discarded Distribution at this URI on this Supply"; a re-register after discard is permitted. The collision is swallowed writer-side (caught, logged WARN, bookmark advances), so the request path always returns 201. This writer also subscribes to AttestationRecorded and flips status to Verified (ChecksumVerified Match) or Stale (Mismatch); Unreachable and non-ChecksumVerified kinds are no-ops, and a rowcount-zero update is warned and continued.
CREATE TABLE proj_data_edition_summary (
edition_id UUID PRIMARY KEY,
kind TEXT NOT NULL CHECK (
kind IN ('ROCrate', 'DataCite', 'Croissant', 'OAIS_AIP', 'OAIS_DIP', 'NeXus')
),
title TEXT NOT NULL,
dataset_ids UUID[] NOT NULL DEFAULT '{}',
creators JSONB NOT NULL DEFAULT '[]'::jsonb,
license TEXT,
publication_year INT,
publisher_facility_code TEXT,
content_hash TEXT,
published_content_hash TEXT,
external_pid_scheme TEXT,
external_pid_value TEXT,
status TEXT NOT NULL DEFAULT 'Registered' CHECK (
status IN ('Registered', 'Sealed', 'Published', 'Withdrawn')
),
registered_at TIMESTAMPTZ NOT NULL,
registered_by UUID NOT NULL,
sealed_at TIMESTAMPTZ,
sealed_by UUID,
published_at TIMESTAMPTZ,
published_by UUID,
withdrawn_at TIMESTAMPTZ,
withdrawn_by UUID,
withdrawal_reason TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX proj_data_edition_summary_status_kind_idx
ON proj_data_edition_summary (status, kind);
CREATE INDEX proj_data_edition_summary_dataset_ids_idx
ON proj_data_edition_summary USING GIN (dataset_ids);
CREATE INDEX proj_data_edition_summary_publisher_facility_code_idx
ON proj_data_edition_summary (publisher_facility_code);
creators order is publication-significant and never sorted. content_hash is set once at seal (the pre-DOI serializer output); published_content_hash is the distinct hash of the re-serialized post-DOI bytes (the DOI bake invalidates the sealed hash).
CREATE TABLE proj_data_acquisition_summary (
acquisition_id UUID PRIMARY KEY,
dataset_id UUID NOT NULL,
producing_asset_id UUID NOT NULL,
producing_run_id UUID,
captured_at TIMESTAMPTZ NOT NULL,
settings JSONB NOT NULL,
evidence JSONB NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL,
recorded_by UUID NOT NULL,
status TEXT NOT NULL CHECK (status IN ('Recorded')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX proj_data_acquisition_summary_dataset_idx
ON proj_data_acquisition_summary (dataset_id, captured_at DESC);
CREATE INDEX proj_data_acquisition_summary_asset_idx
ON proj_data_acquisition_summary (producing_asset_id, captured_at DESC);
CREATE INDEX proj_data_acquisition_summary_run_idx
ON proj_data_acquisition_summary (producing_run_id, captured_at DESC)
WHERE producing_run_id IS NOT NULL;
No cross-stream UNIQUE: multiple Acquisitions per (dataset, asset, captured_at) are legal (calibration replays, paired flat-fields, rapid-fire detector frames). Same-stream strictness is enforced at append time.
CREATE TABLE proj_data_attestation_summary (
attestation_id UUID PRIMARY KEY,
dataset_id UUID NOT NULL,
distribution_id UUID NULL,
kind TEXT NOT NULL CHECK (
kind IN ('ChecksumVerified', 'FormatValidated',
'ConformsToValidated', 'BitRotChecked')
),
outcome TEXT NOT NULL CHECK (
outcome IN ('Match', 'Mismatch', 'Unreachable')
),
evidence JSONB NOT NULL,
attested_at TIMESTAMPTZ NOT NULL,
attested_by UUID NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX proj_data_attestation_summary_dataset_idx
ON proj_data_attestation_summary (dataset_id, attested_at DESC);
CREATE INDEX proj_data_attestation_summary_distribution_idx
ON proj_data_attestation_summary (distribution_id, attested_at DESC)
WHERE distribution_id IS NOT NULL;
CREATE INDEX proj_data_attestation_summary_kind_outcome_idx
ON proj_data_attestation_summary (kind, outcome);
The (distribution_id, attested_at DESC) partial index is the read the Distribution projection writer uses to drive its status flip.
Cross-Module boundaries¶
| Module | Relationship | What's exchanged |
|---|---|---|
| Trust | gated-by | Every write-side Data slice is gated by the Authorize port resolving a Policy for the (principal, command, conduit, surface) tuple; deny outcomes refuse before the decider runs |
| Run | reads-from | register_dataset pre-loads the Run when producing_run_id is set and captures producing_run_end_state; record_acquisition pre-loads the optional producing_run_id |
| Subject | reads-from | register_dataset pre-loads the Subject when subject_id is set |
| Calibration | shared-id-with | Dataset.used_calibration_ids carries CalibrationRevision.id values (the AsShot citation) |
| Supply | reads-from (via SupplyLookup) |
register_distribution resolves supply_id and requires a storage-kind Supply |
| Equipment | reads-from (via AssetLookup) |
record_acquisition resolves producing_asset_id and requires its Family to declare the CAPTURING affordance |
| Federation | reads-from (via FacilityLookup) |
seal_edition / publish_edition resolve the publisher facility_code |
| DataCite (external) | mints-via (PersistentIdentifierMinter) |
publish_edition mints a DOI; withdraw_edition tombstones it |
| Access | shared-id-with | Every Data event carries the actor id on the envelope for principal attribution |
| Data (self) | reads-from | Dataset.derived_from references other Datasets; Distribution / Edition / Acquisition / Attestation all reference Dataset.id; an Edition references its members' canonical Distributions; an Attestation references an optional Distribution |
Cross-aggregate state mutation stays inside the BC: a ChecksumVerified Attestation fact flips the bound Distribution's projected status through the Distribution projection writer. No other module mutates Data state; the only inverse direction is the producing Run's end state captured once at Dataset registration.
Examples¶
The examples below cover the canonical flow: register a Dataset, materialize it as a Distribution, record how it was acquired, verify its bytes, and publish a citable Edition; plus the original Dataset trust-axis transitions. For the REST and MCP equivalence, auth, and idempotency conventions these examples share, see Reading the examples on the Modules landing page.
Register a Dataset against a producing Run¶
POST /datasets
Content-Type: application/json
Idempotency-Key: 4d2e1a8c-9b3f-4c5d-6e7a-8b9c0d1e2f3a
X-Principal-Id: 11111111-2222-3333-4444-555555555555
{
"name": "Catalyst pellet B-12, run 2026-05-19-007, raw projections",
"uri": "s3://aps-2bm-raw/2026-05-19/run-007/projections.h5",
"checksum": {
"algorithm": "sha256",
"value": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
},
"byte_size": 4831838208,
"encoding": {
"media_type": "application/x-hdf5",
"conforms_to": ["https://manual.nexusformat.org/classes/applications/NXtomo"]
},
"producing_run_id": "<run-id>",
"subject_id": "<subject-id>",
"derived_from": [],
"used_calibration_ids": ["<calibration-revision-id>"]
}
Returns 201 Created with the newly-assigned dataset_id. Status is Registered and intent is Trial by default. The producing Run is pre-loaded, its terminal status is captured on producing_run_end_state, and the Subject's existence is confirmed.
mcp.call_tool(
"register_dataset",
{
"name": "Catalyst pellet B-12, run 2026-05-19-007, raw projections",
"uri": "s3://aps-2bm-raw/2026-05-19/run-007/projections.h5",
"checksum": {"algorithm": "sha256", "value": "0123...cdef"},
"byte_size": 4831838208,
"encoding": {
"media_type": "application/x-hdf5",
"conforms_to": ["https://manual.nexusformat.org/classes/applications/NXtomo"],
},
"producing_run_id": "<run-id>",
"subject_id": "<subject-id>",
"derived_from": [],
"used_calibration_ids": ["<calibration-revision-id>"],
},
)
Register a Distribution (a byte-copy at a storage Supply)¶
POST /distributions
Content-Type: application/json
Idempotency-Key: 8f1c2d3e-4a5b-6c7d-8e9f-0a1b2c3d4e5f
X-Principal-Id: 11111111-2222-3333-4444-555555555555
{
"dataset_id": "<dataset-id>",
"supply_id": "<storage-supply-id>",
"uri": "globus://aps-archive/2026-05-19/run-007/projections.h5",
"checksum": {
"algorithm": "sha256",
"value": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
},
"byte_size": 4831838208,
"encoding": {
"media_type": "application/x-hdf5",
"conforms_to": ["https://manual.nexusformat.org/classes/applications/NXtomo"]
},
"access_protocol": "Globus"
}
Returns 201 Created with the newly-assigned distribution_id. The supply_id is resolved through SupplyLookup and must be a storage-kind Supply; the checksum and byte_size must equal the parent Dataset's (the byte-identical-copy invariant). Status is Registered.
mcp.call_tool(
"register_distribution",
{
"dataset_id": "<dataset-id>",
"supply_id": "<storage-supply-id>",
"uri": "globus://aps-archive/2026-05-19/run-007/projections.h5",
"checksum": {"algorithm": "sha256", "value": "0123...cdef"},
"byte_size": 4831838208,
"encoding": {"media_type": "application/x-hdf5", "conforms_to": []},
"access_protocol": "Globus",
},
)
Record an Acquisition (the capture fact)¶
POST /acquisitions
Content-Type: application/json
Idempotency-Key: a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d
X-Principal-Id: 11111111-2222-3333-4444-555555555555
{
"dataset_id": "<dataset-id>",
"producing_asset_id": "<camera-asset-id>",
"producing_run_id": "<run-id>",
"captured_at": "2026-05-19T14:03:22Z",
"settings": {"exposure_ms": 50, "frames": 1500},
"evidence": {"detector_serial": "MERLIN-2BM-01"}
}
Returns 201 Created. The producing Asset is resolved through AssetLookup; its Family must declare the CAPTURING affordance, otherwise the call rejects with 409 AcquisitionCannotRecordWithoutCapturing. captured_at is the instrument wall-clock; CORA stamps recorded_at itself.
Record a checksum Attestation against a Distribution¶
POST /attestations
Content-Type: application/json
Idempotency-Key: b2c3d4e5-f6a7-8b9c-0d1e-2f3a4b5c6d7e
X-Principal-Id: 11111111-2222-3333-4444-555555555555
{
"dataset_id": "<dataset-id>",
"distribution_id": "<distribution-id>",
"kind": "ChecksumVerified"
}
Returns 201 Created. The ChecksumVerifierPort fetches the bytes at the Distribution URI and recomputes the digest; the recorded outcome is Match, Mismatch, or Unreachable. A Match flips the Distribution's projected status to Verified; a Mismatch flips it to Stale. ChecksumVerified requires a distribution_id (byte-level kinds bind a Distribution; ConformsToValidated forbids one).
Register, seal, and publish an Edition¶
POST /editions
Content-Type: application/json
Idempotency-Key: c3d4e5f6-a7b8-9c0d-1e2f-3a4b5c6d7e8f
X-Principal-Id: 11111111-2222-3333-4444-555555555555
{
"kind": "ROCrate",
"title": "Catalyst degradation series, beamtime 2026-1",
"dataset_ids": ["<dataset-id>"],
"creators": [{"actor_id": "<orcid-actor-id>", "affiliation": "Argonne National Laboratory"}],
"publisher_facility_code": "aps",
"publication_year": 2026,
"license": "CC-BY-4.0"
}
Returns 201 Created in Registered. POST /editions/{id}/seal freezes the member set and computes content_hash (every member must be Production intent, not Discarded, and carry a canonical Distribution). POST /editions/{id}/publish mints the DOI through PersistentIdentifierMinter, serializes the manifest through the per-kind EditionSerializerPort, and records external_pid plus published_content_hash. POST /editions/{id}/withdraw tombstones the DOI.
mcp.call_tool(
"register_edition",
{
"kind": "ROCrate",
"title": "Catalyst degradation series, beamtime 2026-1",
"dataset_ids": ["<dataset-id>"],
"creators": [{"actor_id": "<orcid-actor-id>", "affiliation": "Argonne National Laboratory"}],
"publisher_facility_code": "aps",
"publication_year": 2026,
"license": "CC-BY-4.0",
},
)
# then: seal_edition -> publish_edition -> (withdraw_edition)
Promote, demote, or discard a Dataset¶
POST /datasets/<dataset-id>/promote
Content-Type: application/json
X-Principal-Id: 11111111-2222-3333-4444-555555555555
{
"reason": "Reviewed by beamline lead 2026-05-19; reconstruction passes QA"
}
Returns 204 No Content; intent flips Trial -> Production. 409 DatasetCannotPromote if the producing Run did not end Completed, any derived_from Dataset is still Trial, or the Dataset is Discarded. demote (Production -> Retracted) and discard (Registered -> Discarded) follow the same shape with their own reason fields; the original events stay on the stream so the audit log preserves every reason.