208 lines
8.3 KiB
Markdown
208 lines
8.3 KiB
Markdown
# Destination State Contract
|
|
|
|
Audience: operators, integrators, and maintainers who inspect or reason about destination `.distributor.json` files.
|
|
|
|
Each managed destination bundle path contains `.distributor.json`. This file is the destination sentinel and state record used for comparison, skip, replacement, and recovery decisions.
|
|
|
|
## Single-Owner State Schema
|
|
|
|
State written by `run` for `state.mode: single_owner` uses schema version `2`.
|
|
|
|
```json
|
|
{
|
|
"schema_version": 2,
|
|
"distributor_version": "dev",
|
|
"pipeline_id": "reports",
|
|
"destination_id": "archive",
|
|
"published_at": "2026-06-04T12:00:00Z",
|
|
"created_at": "2026-06-04T12:00:00Z",
|
|
"updated_at": "2026-06-04T12:00:00Z",
|
|
"state": {
|
|
"mode": "single_owner"
|
|
},
|
|
"reconciliation": {
|
|
"mode": "replace"
|
|
},
|
|
"source": {
|
|
"manifest": {
|
|
"schema_version": 1,
|
|
"id": "reports.example.2026-06-04",
|
|
"digest": "sha256:...",
|
|
"created": "2026-06-04T11:55:00Z",
|
|
"files": [
|
|
{"path": "report.md", "sha256": "sha256:...", "size": 1234}
|
|
]
|
|
}
|
|
},
|
|
"links": {
|
|
"primary_url": "https://reports.example.com/archive/report.html"
|
|
},
|
|
"outputs": [
|
|
{
|
|
"path": "report.html",
|
|
"kind": "generated",
|
|
"source_path": "report.md",
|
|
"transform": "markdown_to_html",
|
|
"url": "https://reports.example.com/archive/report.html",
|
|
"sha256": "sha256:...",
|
|
"size": 2345,
|
|
"created_at": "2026-06-04T12:00:00Z",
|
|
"updated_at": "2026-06-04T12:00:00Z"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
Required fields:
|
|
|
|
- `schema_version`: must be `2` for newly written state.
|
|
- `pipeline_id`: configured pipeline id that wrote the state.
|
|
- `destination_id`: configured destination id that wrote the state.
|
|
- `published_at`: RFC3339 publication timestamp.
|
|
- `created_at`: RFC3339 timestamp for when this destination state record was first created.
|
|
- `updated_at`: RFC3339 timestamp for the latest state update.
|
|
- `state.mode`: must be `single_owner`.
|
|
- `reconciliation.mode`: `replace` or `merge`.
|
|
- `source.manifest`: embedded source bundle manifest.
|
|
- `outputs`: output records array; empty is allowed, but the field is required.
|
|
|
|
Optional fields:
|
|
|
|
- `distributor_version`: application version string when available.
|
|
- `links.primary_url`: absolute HTTP or HTTPS URL selected by destination link policy.
|
|
|
|
## Output Records
|
|
|
|
Each output record has:
|
|
|
|
- `path`: destination-relative output path.
|
|
- `kind`: `source` or `generated`.
|
|
- `source_path`: source manifest path used for the output.
|
|
- `transform`: required for `generated` outputs; omitted for copied source outputs.
|
|
- `url`: optional absolute HTTP or HTTPS URL for the output.
|
|
- `sha256`: lowercase `sha256:<64 hex>` digest of the output bytes.
|
|
- `size`: output byte size, zero or greater.
|
|
- `created_at`: RFC3339 timestamp for when this output path was first recorded as managed.
|
|
- `updated_at`: RFC3339 timestamp for when this output path was last written or updated in state.
|
|
|
|
Output paths must be unique and use clean relative slash-separated path rules.
|
|
|
|
For replacement updates, newly planned outputs are written into state. For merge updates, retained output records preserve both timestamps, overwritten managed output records preserve `created_at` and receive a new `updated_at`, and new output records receive the current publication time for both fields.
|
|
|
|
## Reconciliation Semantics
|
|
|
|
Destination reconciliation applies when destination state is older than the source and transfer policy permits replacement.
|
|
|
|
- `replace`: delete managed output paths recorded in `outputs` plus `.distributor.json`, require the destination bundle path to be empty afterward, write the newly planned outputs, and write state whose `outputs` are exactly that new planned set.
|
|
- `merge`: retain managed output paths omitted from the new plan, overwrite planned paths only when they are already recorded in existing state, fail when a newly planned path exists in storage but is not recorded as managed, and write state whose `outputs` are the cumulative managed set.
|
|
|
|
Top-level `links.primary_url` is selected from the newly planned outputs for the current publication. Retained outputs keep their existing per-output URL metadata.
|
|
|
|
If a merge publication fails after writing outputs, cleanup removes only newly created outputs from that failed attempt. Previously managed overwritten paths remain managed and are not removed by failed-attempt cleanup.
|
|
|
|
## Comparison Semantics
|
|
|
|
`distributor` compares the current source manifest to destination state before writing:
|
|
|
|
- No state and no content: publish new outputs.
|
|
- No state and existing content: treat the destination as unmanaged.
|
|
- Matching embedded source manifest: skip.
|
|
- Same source id with older `created`: replace if policy allows.
|
|
- Same source id with newer `created`: skip by default.
|
|
- Same source id and same `created` with different digest: conflict.
|
|
- Different source id, pipeline id, or destination id: conflict.
|
|
- Invalid state JSON or invalid state fields: conflict.
|
|
|
|
Normal replacement deletes only managed output paths recorded in `outputs` plus `.distributor.json`. Merge publication retains omitted managed outputs. Forced replacement deletes the bounded destination bundle path.
|
|
|
|
## Compatibility
|
|
|
|
`distributor` can read schema version `1` destination state for compatibility. When v1 state is read, it is treated as single-owner state with `reconciliation.mode: replace`. Missing top-level `created_at` and `updated_at` are inferred from `published_at`, and missing per-output timestamps are also inferred from `published_at`.
|
|
|
|
Newly written single-owner destination state from publish execution uses schema version `2`.
|
|
|
|
## Shared-Root State Schema
|
|
|
|
State written by `run` for `state.mode: shared_root` uses schema version `3`.
|
|
|
|
```json
|
|
{
|
|
"schema_version": 3,
|
|
"distributor_version": "dev",
|
|
"created_at": "2026-06-04T12:00:00Z",
|
|
"updated_at": "2026-06-04T12:10:00Z",
|
|
"state": {
|
|
"mode": "shared_root"
|
|
},
|
|
"owners": [
|
|
{
|
|
"pipeline_id": "reports",
|
|
"destination_id": "archive",
|
|
"reconciliation": {
|
|
"mode": "merge"
|
|
},
|
|
"source": {
|
|
"manifest": {
|
|
"schema_version": 1,
|
|
"id": "reports.example.2026-06-04",
|
|
"digest": "sha256:...",
|
|
"created": "2026-06-04T11:55:00Z",
|
|
"files": [
|
|
{"path": "report.md", "sha256": "sha256:...", "size": 1234}
|
|
]
|
|
}
|
|
},
|
|
"links": {
|
|
"primary_url": "https://reports.example.com/archive/report.html"
|
|
}
|
|
}
|
|
],
|
|
"outputs": [
|
|
{
|
|
"path": "report.html",
|
|
"kind": "generated",
|
|
"source_path": "report.md",
|
|
"transform": "markdown_to_html",
|
|
"url": "https://reports.example.com/archive/report.html",
|
|
"sha256": "sha256:...",
|
|
"size": 2345,
|
|
"pipeline_id": "reports",
|
|
"destination_id": "archive",
|
|
"source_id": "reports.example.2026-06-04",
|
|
"source_digest": "sha256:...",
|
|
"source_created": "2026-06-04T11:55:00Z",
|
|
"created_at": "2026-06-04T12:00:00Z",
|
|
"updated_at": "2026-06-04T12:10:00Z"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
Shared-root required fields:
|
|
|
|
- `schema_version`: must be `3`.
|
|
- `created_at`: RFC3339 timestamp for when this shared-root state record was first created.
|
|
- `updated_at`: RFC3339 timestamp for the latest shared-root state update.
|
|
- `state.mode`: must be `shared_root`.
|
|
- `owners`: owner records keyed by `pipeline_id` and `destination_id`; each owner records its latest source manifest and reconciliation mode.
|
|
- `outputs`: output records for every managed path under the shared destination root.
|
|
|
|
Shared-root optional fields:
|
|
|
|
- `distributor_version`: application version string when available.
|
|
- `owners[].links.primary_url`: absolute HTTP or HTTPS URL selected by that owner's destination link policy.
|
|
|
|
Shared-root output records carry the same `path`, `kind`, `source_path`, `transform`, `url`, `sha256`, `size`, `created_at`, and `updated_at` fields as single-owner outputs. They also include the owner `pipeline_id` and `destination_id`, plus compact source identity fields `source_id`, `source_digest`, and `source_created`.
|
|
|
|
## Boundaries
|
|
|
|
Destination state is internal managed state written by `distributor`. Operators may inspect it during recovery, but normal workflows should not edit it by hand. Source `manifest.json` is not copied as destination state.
|
|
|
|
## Tests
|
|
|
|
Before changing this contract, inspect and run:
|
|
|
|
```sh
|
|
go test ./internal/state ./internal/publish
|
|
```
|