diff --git a/05a-data-contracts.md b/05a-data-contracts.md index 0e96383..299ae0d 100644 --- a/05a-data-contracts.md +++ b/05a-data-contracts.md @@ -370,14 +370,49 @@ match the installed digest. A `seed` resource is created only when its target is absent and becomes user-owned immediately; later pack versions neither replace nor delete it unless an explicit seed-type upgrade is declared below. -A seed **type** resource MAY declare `upgrade_from: { digest, document }`. -The document is an exact previous publisher baseline, pinned by SHA-256, not -an assertion that the user's current document is unchanged. This declaration -is part of the reviewed manifest and its digest. Ordinary seeds are unaffected. +A seed **type** resource MAY declare `upgrade_from`: one baseline, or a +non-empty list of baselines. A baseline is `{ digest, document }` with an +optional integer `version`. `document` is the exact bytes of a starter the +publisher previously shipped for this resource, pinned by SHA-256 in `digest`; +it is not an assertion that the user's current document is unchanged. `version`, +when present, is the `version` that document declares and is only for +presentation. A single baseline is equivalent to a list containing it. Listing +every previously shipped starter that remains supported lets a collection +upgrade from any of them, not only from the most recent. This declaration is +part of the reviewed manifest and its digest. Ordinary seeds are unaffected. Engines that do not support this member MUST reject the manifest. -An upgrade performs a conservative three-way merge of baseline, live type, -and desired type. The type kind and name MUST match. Unchanged publisher +The manifest is invalid (`invalid_type_pack`) when `upgrade_from` appears on a +resource that is not a seed type, when a baseline's digest is not the SHA-256 of +its document, when two baselines share a digest, when a baseline's digest equals +the resource's own digest, when a baseline document's frontmatter `kind` or +`name` differs from the desired document's, or when a baseline's `version` +differs from the `version` its document's frontmatter declares. + +A seed's **origin** is the publisher document its live target descends from. +The lock records it (see Pack Identity And Portable Provenance). When the target +exists, is not an intentionally preserved seed target, and the resource declares +`upgrade_from`, the engine plans exactly one of these, in order: + +1. The live bytes equal the desired document: `preserve`. +2. The live bytes equal a baseline's document: `update`, writing the desired + document byte-for-byte. Byte equality proves the origin, whatever the lock + records. +3. The origin is the desired document: `preserve`. The seed was already upgraded + and has been edited since. +4. The origin is a baseline: `update` by a three-way merge of that baseline, the + live type, and the desired type. +5. Otherwise, because the origin is unknown or is not a listed baseline: + `preserve`, with a `reason` stating that no upgrade baseline applies. The + type is left as it is; it does not make the pack conflict. + +An engine MUST NOT choose a merge baseline any other way. In particular, it MUST +NOT merge against a baseline the seed is not known to descend from, because the +differences between that baseline and the seed's actual origin would be applied +as if they were the user's edits. The assessment reports, for every seed +`update`, the baseline used as `upgrade_baseline: { digest, version? }`. + +A three-way merge is conservative. The type kind and name MUST match. Unchanged publisher settings retain live customizations; unchanged live settings accept publisher changes. Object settings merge recursively. Contract implementations merge by contract ID only when unambiguous, retaining customized field mappings and @@ -428,6 +463,24 @@ mode, canonical source, resolved target, and installed digest of every resource. deterministically and users MAY inspect or version it. Tools MUST NOT infer ownership from filenames, application names, or `x-*` metadata. +A seed resource's entry also records `origin_digest`, the digest of the +publisher document its target descends from, when that is known. Because a seed +becomes user-owned, the installed digest of a seed describes the pack, not the +target, and MUST NOT be used as its origin. An apply sets `origin_digest` to the +desired resource digest when it creates the seed, when it upgrades it (by exact +replacement or by merge), and whenever the target's live bytes equal the desired +document, whatever else applies. Otherwise it carries the previous entry's +`origin_digest` for that target forward unchanged, or omits it when there is +none. A seed target that existed before the pack was installed, and an +intentionally preserved seed target, therefore have no origin unless their bytes +equal the desired document. A lock entry without `origin_digest` means the +origin is unknown. Locks written before `origin_digest` existed carry none, so +an edited seed under such a lock is preserved with a reason rather than merged +until it is upgraded or recreated; an unedited seed still upgrades, because its +bytes prove its origin. `origin_digest` is defined only for seed resources and +MUST NOT appear on a managed resource. An apply that changes only seed origins leaves the pack's +status `current`; it is not a reconfiguration. + Full collection snapshots, authority transfers, and unscoped synchronization MUST carry `mdbase.lock.yaml` when it exists. A scoped application projection MAY omit it to avoid disclosing unrelated pack metadata. A storage provider diff --git a/CHANGELOG.md b/CHANGELOG.md index e9d56ce..0bb08cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,11 +15,24 @@ All notable changes to this specification and conformance suite are documented h through `x-obsidian.bases.include` and the saved-view source operations for Bases become transitional. -- A seed type resource may declare `upgrade_from: { digest, document }`, a - digest-pinned previous publisher baseline. The engine performs a conservative - three-way merge of baseline, live type and desired type, fails closed on - competing changes, and publishes atomically. Engines that do not support the - member must reject the manifest. Records are never migrated this way. +- A seed type resource may declare `upgrade_from`: one baseline or a list of + baselines, each a digest-pinned starter the publisher previously shipped + (`{ digest, document, version? }`), so a collection can upgrade from any + supported starter, not only the latest. The engine chooses the merge + baseline from the seed's origin and never guesses: an unedited seed equal to + any baseline is replaced with the exact desired bytes; an edited seed merges + against the baseline it descends from; a seed whose origin is unknown or not + listed is preserved with a reason rather than merged or made to conflict. + Competing changes still fail closed, publication is atomic, and the + assessment reports the baseline used. Engines that do not support the member + must reject the manifest. Records are never migrated this way. +- The type-pack lock records a seed's `origin_digest`, the publisher document + its target descends from, set when the seed is created, upgraded, or already + matches the desired document, and otherwise carried forward. A seed's + installed digest describes the pack, not the target, and is never its origin. +- Type-pack conformance adds seed-upgrade fixtures (`examples/v0.3/seed-upgrades`) + and `input.history` steps, with an executable model in + `scripts/type_pack_model.py`. ### Saved views are identified through the `mdbase.view` contract diff --git a/examples/v0.3/seed-upgrades/README.md b/examples/v0.3/seed-upgrades/README.md new file mode 100644 index 0000000..7590f10 --- /dev/null +++ b/examples/v0.3/seed-upgrades/README.md @@ -0,0 +1,14 @@ +# Seed upgrade examples + +Versions of one pack, `example.seed-notes`, whose seed `note` type changes from +v1 (`title`) to v2 (adds `created`) to v3 (adds `tags`). The type-pack +conformance fixtures install them in sequence to exercise `upgrade_from` +baselines, seed origins in the lock, and manifest validation (05A). + +- `v1`, `v2`, `v3`: the starter at each version; v2 declares v1 as a single baseline object and v3 lists v2 and v1. +- `v1.5-plain`: ships the v2 starter without `upgrade_from`, so an installed + seed is preserved and keeps its origin. +- `v3-only-v2`: v3 supporting upgrades only from v2. +- `invalid-*`: manifests (one rule each) that must be rejected with `invalid_type_pack`. + +Pack sources are byte-exact; regenerate digests if a document changes. diff --git a/examples/v0.3/seed-upgrades/invalid-baseline-digest/_types/note.md b/examples/v0.3/seed-upgrades/invalid-baseline-digest/_types/note.md new file mode 100644 index 0000000..1d283b5 --- /dev/null +++ b/examples/v0.3/seed-upgrades/invalid-baseline-digest/_types/note.md @@ -0,0 +1,22 @@ +--- +kind: mdbase.type +name: note +version: 3 +description: A note with a creation date and tags. +match: + where: + type: note +schema: + dialect: json-schema-2020-12 + value: + type: object + required: [title] + properties: + title: { type: string } + created: { type: string, format: date-time } + tags: { type: array, items: { type: string } } + additionalProperties: true +--- +# Note + +A note in this collection. diff --git a/examples/v0.3/seed-upgrades/invalid-baseline-digest/mdbase-pack.yaml b/examples/v0.3/seed-upgrades/invalid-baseline-digest/mdbase-pack.yaml new file mode 100644 index 0000000..87ef205 --- /dev/null +++ b/examples/v0.3/seed-upgrades/invalid-baseline-digest/mdbase-pack.yaml @@ -0,0 +1,36 @@ +# The baseline digest does not match its document. +kind: mdbase.type-pack +id: example.seed-notes +version: 3.0.0 +name: Seed note example +resources: + - kind: type + mode: seed + source: _types/note.md + target: _types/note.md + digest: sha256:57160cce297903e123f876d789d518ed83b1cf790edafae660f43f3d44744065 + upgrade_from: + - digest: sha256:38887fbf68c97de99bc271209466bbf457a3635b3ed2ee93e6c5bf8213f53c10 + version: 2 + document: | + --- + kind: mdbase.type + name: note + version: 2 + description: A note with a creation date. + match: + where: + type: note + schema: + dialect: json-schema-2020-12 + value: + type: object + required: [title] + properties: + title: { type: string } + created: { type: string, format: date-time } + additionalProperties: true + --- + # Note + + A note in this collection. diff --git a/examples/v0.3/seed-upgrades/invalid-duplicate-baseline/_types/note.md b/examples/v0.3/seed-upgrades/invalid-duplicate-baseline/_types/note.md new file mode 100644 index 0000000..1d283b5 --- /dev/null +++ b/examples/v0.3/seed-upgrades/invalid-duplicate-baseline/_types/note.md @@ -0,0 +1,22 @@ +--- +kind: mdbase.type +name: note +version: 3 +description: A note with a creation date and tags. +match: + where: + type: note +schema: + dialect: json-schema-2020-12 + value: + type: object + required: [title] + properties: + title: { type: string } + created: { type: string, format: date-time } + tags: { type: array, items: { type: string } } + additionalProperties: true +--- +# Note + +A note in this collection. diff --git a/examples/v0.3/seed-upgrades/invalid-duplicate-baseline/mdbase-pack.yaml b/examples/v0.3/seed-upgrades/invalid-duplicate-baseline/mdbase-pack.yaml new file mode 100644 index 0000000..0df8538 --- /dev/null +++ b/examples/v0.3/seed-upgrades/invalid-duplicate-baseline/mdbase-pack.yaml @@ -0,0 +1,59 @@ +kind: mdbase.type-pack +id: example.seed-notes +version: 3.0.0 +name: Seed note example +resources: + - kind: type + mode: seed + source: _types/note.md + target: _types/note.md + digest: sha256:57160cce297903e123f876d789d518ed83b1cf790edafae660f43f3d44744065 + upgrade_from: + - digest: sha256:28887fbf68c97de99bc271209466bbf457a3635b3ed2ee93e6c5bf8213f53c10 + version: 2 + document: | + --- + kind: mdbase.type + name: note + version: 2 + description: A note with a creation date. + match: + where: + type: note + schema: + dialect: json-schema-2020-12 + value: + type: object + required: [title] + properties: + title: { type: string } + created: { type: string, format: date-time } + additionalProperties: true + --- + # Note + + A note in this collection. + - digest: sha256:28887fbf68c97de99bc271209466bbf457a3635b3ed2ee93e6c5bf8213f53c10 + version: 2 + document: | + --- + kind: mdbase.type + name: note + version: 2 + description: A note with a creation date. + match: + where: + type: note + schema: + dialect: json-schema-2020-12 + value: + type: object + required: [title] + properties: + title: { type: string } + created: { type: string, format: date-time } + additionalProperties: true + --- + # Note + + A note in this collection. diff --git a/examples/v0.3/seed-upgrades/invalid-managed-upgrade/_types/note.md b/examples/v0.3/seed-upgrades/invalid-managed-upgrade/_types/note.md new file mode 100644 index 0000000..1d283b5 --- /dev/null +++ b/examples/v0.3/seed-upgrades/invalid-managed-upgrade/_types/note.md @@ -0,0 +1,22 @@ +--- +kind: mdbase.type +name: note +version: 3 +description: A note with a creation date and tags. +match: + where: + type: note +schema: + dialect: json-schema-2020-12 + value: + type: object + required: [title] + properties: + title: { type: string } + created: { type: string, format: date-time } + tags: { type: array, items: { type: string } } + additionalProperties: true +--- +# Note + +A note in this collection. diff --git a/examples/v0.3/seed-upgrades/invalid-managed-upgrade/mdbase-pack.yaml b/examples/v0.3/seed-upgrades/invalid-managed-upgrade/mdbase-pack.yaml new file mode 100644 index 0000000..1ac44c5 --- /dev/null +++ b/examples/v0.3/seed-upgrades/invalid-managed-upgrade/mdbase-pack.yaml @@ -0,0 +1,35 @@ +kind: mdbase.type-pack +id: example.seed-notes +version: 3.0.0 +name: Seed note example +resources: + - kind: type + mode: managed + source: _types/note.md + target: _types/note.md + digest: sha256:57160cce297903e123f876d789d518ed83b1cf790edafae660f43f3d44744065 + upgrade_from: + - digest: sha256:28887fbf68c97de99bc271209466bbf457a3635b3ed2ee93e6c5bf8213f53c10 + version: 2 + document: | + --- + kind: mdbase.type + name: note + version: 2 + description: A note with a creation date. + match: + where: + type: note + schema: + dialect: json-schema-2020-12 + value: + type: object + required: [title] + properties: + title: { type: string } + created: { type: string, format: date-time } + additionalProperties: true + --- + # Note + + A note in this collection. diff --git a/examples/v0.3/seed-upgrades/invalid-name-mismatch/_types/note.md b/examples/v0.3/seed-upgrades/invalid-name-mismatch/_types/note.md new file mode 100644 index 0000000..1d283b5 --- /dev/null +++ b/examples/v0.3/seed-upgrades/invalid-name-mismatch/_types/note.md @@ -0,0 +1,22 @@ +--- +kind: mdbase.type +name: note +version: 3 +description: A note with a creation date and tags. +match: + where: + type: note +schema: + dialect: json-schema-2020-12 + value: + type: object + required: [title] + properties: + title: { type: string } + created: { type: string, format: date-time } + tags: { type: array, items: { type: string } } + additionalProperties: true +--- +# Note + +A note in this collection. diff --git a/examples/v0.3/seed-upgrades/invalid-name-mismatch/mdbase-pack.yaml b/examples/v0.3/seed-upgrades/invalid-name-mismatch/mdbase-pack.yaml new file mode 100644 index 0000000..d1aad9b --- /dev/null +++ b/examples/v0.3/seed-upgrades/invalid-name-mismatch/mdbase-pack.yaml @@ -0,0 +1,35 @@ +kind: mdbase.type-pack +id: example.seed-notes +version: 3.0.0 +name: Seed note example +resources: + - kind: type + mode: seed + source: _types/note.md + target: _types/note.md + digest: sha256:57160cce297903e123f876d789d518ed83b1cf790edafae660f43f3d44744065 + upgrade_from: + - digest: sha256:1e75d64c466f1945a689d17be1baff0851f9ef09e2e2ec03396b6b66fc713579 + version: 2 + document: | + --- + kind: mdbase.type + name: memo + version: 2 + description: A note with a creation date. + match: + where: + type: note + schema: + dialect: json-schema-2020-12 + value: + type: object + required: [title] + properties: + title: { type: string } + created: { type: string, format: date-time } + additionalProperties: true + --- + # Note + + A note in this collection. diff --git a/examples/v0.3/seed-upgrades/invalid-self-baseline/_types/note.md b/examples/v0.3/seed-upgrades/invalid-self-baseline/_types/note.md new file mode 100644 index 0000000..1d283b5 --- /dev/null +++ b/examples/v0.3/seed-upgrades/invalid-self-baseline/_types/note.md @@ -0,0 +1,22 @@ +--- +kind: mdbase.type +name: note +version: 3 +description: A note with a creation date and tags. +match: + where: + type: note +schema: + dialect: json-schema-2020-12 + value: + type: object + required: [title] + properties: + title: { type: string } + created: { type: string, format: date-time } + tags: { type: array, items: { type: string } } + additionalProperties: true +--- +# Note + +A note in this collection. diff --git a/examples/v0.3/seed-upgrades/invalid-self-baseline/mdbase-pack.yaml b/examples/v0.3/seed-upgrades/invalid-self-baseline/mdbase-pack.yaml new file mode 100644 index 0000000..5e8b1b5 --- /dev/null +++ b/examples/v0.3/seed-upgrades/invalid-self-baseline/mdbase-pack.yaml @@ -0,0 +1,36 @@ +kind: mdbase.type-pack +id: example.seed-notes +version: 3.0.0 +name: Seed note example +resources: + - kind: type + mode: seed + source: _types/note.md + target: _types/note.md + digest: sha256:57160cce297903e123f876d789d518ed83b1cf790edafae660f43f3d44744065 + upgrade_from: + - digest: sha256:57160cce297903e123f876d789d518ed83b1cf790edafae660f43f3d44744065 + version: 3 + document: | + --- + kind: mdbase.type + name: note + version: 3 + description: A note with a creation date and tags. + match: + where: + type: note + schema: + dialect: json-schema-2020-12 + value: + type: object + required: [title] + properties: + title: { type: string } + created: { type: string, format: date-time } + tags: { type: array, items: { type: string } } + additionalProperties: true + --- + # Note + + A note in this collection. diff --git a/examples/v0.3/seed-upgrades/invalid-version-mismatch/_types/note.md b/examples/v0.3/seed-upgrades/invalid-version-mismatch/_types/note.md new file mode 100644 index 0000000..1d283b5 --- /dev/null +++ b/examples/v0.3/seed-upgrades/invalid-version-mismatch/_types/note.md @@ -0,0 +1,22 @@ +--- +kind: mdbase.type +name: note +version: 3 +description: A note with a creation date and tags. +match: + where: + type: note +schema: + dialect: json-schema-2020-12 + value: + type: object + required: [title] + properties: + title: { type: string } + created: { type: string, format: date-time } + tags: { type: array, items: { type: string } } + additionalProperties: true +--- +# Note + +A note in this collection. diff --git a/examples/v0.3/seed-upgrades/invalid-version-mismatch/mdbase-pack.yaml b/examples/v0.3/seed-upgrades/invalid-version-mismatch/mdbase-pack.yaml new file mode 100644 index 0000000..9a2d776 --- /dev/null +++ b/examples/v0.3/seed-upgrades/invalid-version-mismatch/mdbase-pack.yaml @@ -0,0 +1,34 @@ +kind: mdbase.type-pack +id: example.seed-notes +version: 3.0.0 +name: Seed note example +resources: + - kind: type + mode: seed + source: _types/note.md + target: _types/note.md + digest: sha256:57160cce297903e123f876d789d518ed83b1cf790edafae660f43f3d44744065 + upgrade_from: + - digest: sha256:9fcf75786fa90108e1d0183de472e589adce9a0883157b68c8aaf2f036b03c13 + version: 2 + document: | + --- + kind: mdbase.type + name: note + version: 1 + description: A note. + match: + where: + type: note + schema: + dialect: json-schema-2020-12 + value: + type: object + required: [title] + properties: + title: { type: string } + additionalProperties: true + --- + # Note + + A note in this collection. diff --git a/examples/v0.3/seed-upgrades/v1.5-plain/_types/note.md b/examples/v0.3/seed-upgrades/v1.5-plain/_types/note.md new file mode 100644 index 0000000..3a1b2d1 --- /dev/null +++ b/examples/v0.3/seed-upgrades/v1.5-plain/_types/note.md @@ -0,0 +1,21 @@ +--- +kind: mdbase.type +name: note +version: 2 +description: A note with a creation date. +match: + where: + type: note +schema: + dialect: json-schema-2020-12 + value: + type: object + required: [title] + properties: + title: { type: string } + created: { type: string, format: date-time } + additionalProperties: true +--- +# Note + +A note in this collection. diff --git a/examples/v0.3/seed-upgrades/v1.5-plain/mdbase-pack.yaml b/examples/v0.3/seed-upgrades/v1.5-plain/mdbase-pack.yaml new file mode 100644 index 0000000..6fdcf5e --- /dev/null +++ b/examples/v0.3/seed-upgrades/v1.5-plain/mdbase-pack.yaml @@ -0,0 +1,11 @@ +# Ships the v2 starter without upgrade_from: an installed seed is preserved. +kind: mdbase.type-pack +id: example.seed-notes +version: 1.5.0 +name: Seed note example +resources: + - kind: type + mode: seed + source: _types/note.md + target: _types/note.md + digest: sha256:28887fbf68c97de99bc271209466bbf457a3635b3ed2ee93e6c5bf8213f53c10 diff --git a/examples/v0.3/seed-upgrades/v1/_types/note.md b/examples/v0.3/seed-upgrades/v1/_types/note.md new file mode 100644 index 0000000..90f29c3 --- /dev/null +++ b/examples/v0.3/seed-upgrades/v1/_types/note.md @@ -0,0 +1,20 @@ +--- +kind: mdbase.type +name: note +version: 1 +description: A note. +match: + where: + type: note +schema: + dialect: json-schema-2020-12 + value: + type: object + required: [title] + properties: + title: { type: string } + additionalProperties: true +--- +# Note + +A note in this collection. diff --git a/examples/v0.3/seed-upgrades/v1/mdbase-pack.yaml b/examples/v0.3/seed-upgrades/v1/mdbase-pack.yaml new file mode 100644 index 0000000..8f2ac31 --- /dev/null +++ b/examples/v0.3/seed-upgrades/v1/mdbase-pack.yaml @@ -0,0 +1,10 @@ +kind: mdbase.type-pack +id: example.seed-notes +version: 1.0.0 +name: Seed note example +resources: + - kind: type + mode: seed + source: _types/note.md + target: _types/note.md + digest: sha256:9fcf75786fa90108e1d0183de472e589adce9a0883157b68c8aaf2f036b03c13 diff --git a/examples/v0.3/seed-upgrades/v2/_types/note.md b/examples/v0.3/seed-upgrades/v2/_types/note.md new file mode 100644 index 0000000..3a1b2d1 --- /dev/null +++ b/examples/v0.3/seed-upgrades/v2/_types/note.md @@ -0,0 +1,21 @@ +--- +kind: mdbase.type +name: note +version: 2 +description: A note with a creation date. +match: + where: + type: note +schema: + dialect: json-schema-2020-12 + value: + type: object + required: [title] + properties: + title: { type: string } + created: { type: string, format: date-time } + additionalProperties: true +--- +# Note + +A note in this collection. diff --git a/examples/v0.3/seed-upgrades/v2/mdbase-pack.yaml b/examples/v0.3/seed-upgrades/v2/mdbase-pack.yaml new file mode 100644 index 0000000..8377cd3 --- /dev/null +++ b/examples/v0.3/seed-upgrades/v2/mdbase-pack.yaml @@ -0,0 +1,34 @@ +kind: mdbase.type-pack +id: example.seed-notes +version: 2.0.0 +name: Seed note example +resources: + - kind: type + mode: seed + source: _types/note.md + target: _types/note.md + digest: sha256:28887fbf68c97de99bc271209466bbf457a3635b3ed2ee93e6c5bf8213f53c10 + upgrade_from: + digest: sha256:9fcf75786fa90108e1d0183de472e589adce9a0883157b68c8aaf2f036b03c13 + version: 1 + document: | + --- + kind: mdbase.type + name: note + version: 1 + description: A note. + match: + where: + type: note + schema: + dialect: json-schema-2020-12 + value: + type: object + required: [title] + properties: + title: { type: string } + additionalProperties: true + --- + # Note + + A note in this collection. diff --git a/examples/v0.3/seed-upgrades/v3-only-v2/_types/note.md b/examples/v0.3/seed-upgrades/v3-only-v2/_types/note.md new file mode 100644 index 0000000..1d283b5 --- /dev/null +++ b/examples/v0.3/seed-upgrades/v3-only-v2/_types/note.md @@ -0,0 +1,22 @@ +--- +kind: mdbase.type +name: note +version: 3 +description: A note with a creation date and tags. +match: + where: + type: note +schema: + dialect: json-schema-2020-12 + value: + type: object + required: [title] + properties: + title: { type: string } + created: { type: string, format: date-time } + tags: { type: array, items: { type: string } } + additionalProperties: true +--- +# Note + +A note in this collection. diff --git a/examples/v0.3/seed-upgrades/v3-only-v2/mdbase-pack.yaml b/examples/v0.3/seed-upgrades/v3-only-v2/mdbase-pack.yaml new file mode 100644 index 0000000..b20d774 --- /dev/null +++ b/examples/v0.3/seed-upgrades/v3-only-v2/mdbase-pack.yaml @@ -0,0 +1,36 @@ +# Supports upgrading only from the v2 starter. +kind: mdbase.type-pack +id: example.seed-notes +version: 3.0.0 +name: Seed note example +resources: + - kind: type + mode: seed + source: _types/note.md + target: _types/note.md + digest: sha256:57160cce297903e123f876d789d518ed83b1cf790edafae660f43f3d44744065 + upgrade_from: + - digest: sha256:28887fbf68c97de99bc271209466bbf457a3635b3ed2ee93e6c5bf8213f53c10 + version: 2 + document: | + --- + kind: mdbase.type + name: note + version: 2 + description: A note with a creation date. + match: + where: + type: note + schema: + dialect: json-schema-2020-12 + value: + type: object + required: [title] + properties: + title: { type: string } + created: { type: string, format: date-time } + additionalProperties: true + --- + # Note + + A note in this collection. diff --git a/examples/v0.3/seed-upgrades/v3/_types/note.md b/examples/v0.3/seed-upgrades/v3/_types/note.md new file mode 100644 index 0000000..1d283b5 --- /dev/null +++ b/examples/v0.3/seed-upgrades/v3/_types/note.md @@ -0,0 +1,22 @@ +--- +kind: mdbase.type +name: note +version: 3 +description: A note with a creation date and tags. +match: + where: + type: note +schema: + dialect: json-schema-2020-12 + value: + type: object + required: [title] + properties: + title: { type: string } + created: { type: string, format: date-time } + tags: { type: array, items: { type: string } } + additionalProperties: true +--- +# Note + +A note in this collection. diff --git a/examples/v0.3/seed-upgrades/v3/mdbase-pack.yaml b/examples/v0.3/seed-upgrades/v3/mdbase-pack.yaml new file mode 100644 index 0000000..0778d93 --- /dev/null +++ b/examples/v0.3/seed-upgrades/v3/mdbase-pack.yaml @@ -0,0 +1,58 @@ +kind: mdbase.type-pack +id: example.seed-notes +version: 3.0.0 +name: Seed note example +resources: + - kind: type + mode: seed + source: _types/note.md + target: _types/note.md + digest: sha256:57160cce297903e123f876d789d518ed83b1cf790edafae660f43f3d44744065 + upgrade_from: + - digest: sha256:28887fbf68c97de99bc271209466bbf457a3635b3ed2ee93e6c5bf8213f53c10 + version: 2 + document: | + --- + kind: mdbase.type + name: note + version: 2 + description: A note with a creation date. + match: + where: + type: note + schema: + dialect: json-schema-2020-12 + value: + type: object + required: [title] + properties: + title: { type: string } + created: { type: string, format: date-time } + additionalProperties: true + --- + # Note + + A note in this collection. + - digest: sha256:9fcf75786fa90108e1d0183de472e589adce9a0883157b68c8aaf2f036b03c13 + version: 1 + document: | + --- + kind: mdbase.type + name: note + version: 1 + description: A note. + match: + where: + type: note + schema: + dialect: json-schema-2020-12 + value: + type: object + required: [title] + properties: + title: { type: string } + additionalProperties: true + --- + # Note + + A note in this collection. diff --git a/schemas/v0.3/type-pack-lock.schema.json b/schemas/v0.3/type-pack-lock.schema.json index 0d5be20..7a4aa23 100644 --- a/schemas/v0.3/type-pack-lock.schema.json +++ b/schemas/v0.3/type-pack-lock.schema.json @@ -56,8 +56,11 @@ "mode": { "enum": ["managed", "seed"] }, "source": { "$ref": "#/$defs/safeRelativePath" }, "target": { "$ref": "#/$defs/safeRelativePath" }, - "digest": { "$ref": "#/$defs/digest" } + "digest": { "$ref": "#/$defs/digest" }, + "origin_digest": { "$ref": "#/$defs/digest" } }, + "if": { "properties": { "mode": { "const": "managed" } } }, + "then": { "not": { "required": ["origin_digest"] } }, "additionalProperties": false } } diff --git a/schemas/v0.3/type-pack.schema.json b/schemas/v0.3/type-pack.schema.json index 04db984..4e49fd8 100644 --- a/schemas/v0.3/type-pack.schema.json +++ b/schemas/v0.3/type-pack.schema.json @@ -53,6 +53,16 @@ "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, + "upgradeBaseline": { + "type": "object", + "required": ["digest", "document"], + "additionalProperties": false, + "properties": { + "digest": { "$ref": "#/$defs/digest" }, + "document": { "type": "string", "maxLength": 262144 }, + "version": { "type": "integer", "minimum": 1 } + } + }, "resource": { "type": "object", "required": ["kind", "mode", "source", "target", "digest"], @@ -64,13 +74,14 @@ "enum": ["managed", "seed"] }, "upgrade_from": { - "type": "object", - "required": ["digest", "document"], - "additionalProperties": false, - "properties": { - "digest": { "$ref": "#/$defs/digest" }, - "document": { "type": "string", "maxLength": 262144 } - } + "oneOf": [ + { "$ref": "#/$defs/upgradeBaseline" }, + { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/upgradeBaseline" } + } + ] }, "source": { "$ref": "#/$defs/safeRelativePath" diff --git a/scripts/check_v03_tests.py b/scripts/check_v03_tests.py index 8262fad..3a6648b 100755 --- a/scripts/check_v03_tests.py +++ b/scripts/check_v03_tests.py @@ -532,6 +532,9 @@ def run_apply_type_pack_test( """Simulate the normative preflight/diff/atomicity rules without an engine.""" manifest_path = resolve(input_data["pack"]) manifest = load_yaml(manifest_path) + if uses_seed_model(input_data, manifest): + run_seed_pack_test("apply_type_pack", input_data, expect, setup) + return resources = manifest.get("resources", []) or [] live = { str(target): str(content).encode() @@ -654,6 +657,9 @@ def run_assess_type_pack_test( """Exercise the structured managed-resource conflict shape used before apply.""" manifest_path = resolve(input_data["pack"]) manifest = load_yaml(manifest_path) + if uses_seed_model(input_data, manifest): + run_seed_pack_test("assess_type_pack", input_data, expect, setup) + return resources = manifest.get("resources", []) or [] live: dict[str, bytes] = {} installed: dict[str, str] = {} @@ -694,6 +700,105 @@ def run_assess_type_pack_test( ) +def uses_seed_model(input_data: dict[str, Any], manifest: dict[str, Any]) -> bool: + return "history" in input_data or any( + resource.get("mode") == "seed" or "upgrade_from" in resource + for resource in manifest.get("resources", []) or [] + ) + + +def run_seed_pack_test( + operation: str, input_data: dict[str, Any], expect: dict[str, Any], setup: dict[str, Any] +) -> None: + """Run seed-upgrade fixtures against the executable model in type_pack_model.""" + import type_pack_model as model # type: ignore[import-not-found] + + collection = model.Collection( + files={str(path): str(content).encode() for path, content in (setup.get("files") or {}).items()} + ) + for step in input_data.get("history", []) or []: + if "apply" in step: + applied = collection.apply(model.load_pack(resolve(step["apply"]))) + if not applied.applicable: + raise AssertionError(f"history step {step} did not apply") + elif "write" in step: + collection.files[step["write"]["path"]] = str(step["write"]["content"]).encode() + elif "replace" in step: + path, old, new = step["replace"]["path"], step["replace"]["old"], step["replace"]["new"] + current = collection.files[path].decode() + if current.count(old) != 1: + raise AssertionError(f"history replace in {path} must match exactly once") + collection.files[path] = current.replace(old, new).encode() + else: + raise AssertionError(f"unsupported history step: {step}") + before = dict(collection.files) + + try: + pack = model.load_pack(resolve(input_data["pack"])) + except model.PackInvalid as invalid: + assert_subset({"valid": False, "error": {"code": "invalid_type_pack", "message": str(invalid)}}, expect) + return + + def summary(assessment: Any) -> dict[str, Any]: + return { + "valid": True, + "status": assessment.status, + "applicable": assessment.applicable, + "actions": [item.action for item in assessment.resources], + } + + if operation == "assess_type_pack": + first = collection.assess(pack) + actual: dict[str, Any] = summary(first) + else: + runs = [] + first = None + for _ in range(int(input_data.get("repeat", 1))): + assessment = collection.apply(pack) + first = first or assessment + runs.append({**summary(assessment), "valid": assessment.applicable}) + actual = {"valid": runs[-1]["valid"], "runs": runs} + assert_subset(actual, {key: value for key, value in expect.items() if key in actual}) + + for expected in expect.get("resources", []) or []: + item = next((item for item in first.resources if item.target == expected["target"]), None) + if item is None: + raise AssertionError(f"no planned resource for {expected['target']}") + if "action" in expected and item.action != expected["action"]: + raise AssertionError(f"{item.target}: expected {expected['action']}, got {item.action}") + if "upgrade_baseline_version" in expected and ( + item.baseline is None or item.baseline.version != expected["upgrade_baseline_version"] + ): + raise AssertionError(f"{item.target}: wrong upgrade baseline {item.baseline}") + if "reason" in expected and bool(item.reason) != expected["reason"]: + raise AssertionError(f"{item.target}: reason presence {bool(item.reason)}, expected {expected['reason']}") + for target, source in (expect.get("target_matches_source") or {}).items(): + if collection.files.get(target) != resolve(source).read_bytes(): + raise AssertionError(f"{target} does not match {source}") + for target in expect.get("target_unchanged", []) or []: + if collection.files.get(target) != before.get(target): + raise AssertionError(f"{target} changed") + for target, pointers in (expect.get("target_frontmatter") or {}).items(): + frontmatter, _ = model.split_document(collection.files[target]) + for pointer, value in pointers.items(): + current: Any = frontmatter + for token in pointer.strip("/").split("/"): + current = current[token.replace("~1", "/").replace("~0", "~")] + if current != value: + raise AssertionError(f"{target}{pointer}: expected {value!r}, got {current!r}") + for target, texts in (expect.get("target_body_contains") or {}).items(): + _, body = model.split_document(collection.files[target]) + for text in texts: + if text not in body: + raise AssertionError(f"{target} body lacks {text!r}") + for target, source in (expect.get("lock_origin") or {}).items(): + entries = [receipt["resources"].get(target) for receipt in collection.lock.values()] + origin = next((entry.get("origin_digest") for entry in entries if entry), None) + wanted = None if source == "absent" else model.digest(resolve(source).read_bytes()) + if origin != wanted: + raise AssertionError(f"{target}: lock origin {origin}, expected {wanted}") + + def run_data_contract_implementation_test( input_data: dict[str, Any], expect: dict[str, Any] ) -> None: diff --git a/scripts/type_pack_model.py b/scripts/type_pack_model.py new file mode 100644 index 0000000..f87a1d1 --- /dev/null +++ b/scripts/type_pack_model.py @@ -0,0 +1,260 @@ +"""Executable model of type-pack assessment and apply for seed upgrades (05A). + +This is not an engine. It models the normative rules the seed-upgrade +conformance fixtures exercise: manifest validation of `upgrade_from`, the lock's +seed `origin_digest`, the ordered choice of an upgrade baseline, and a +structural three-way merge of type frontmatter that keeps the live body. +Contract-implementation merging and other engine detail are out of scope. +""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml +from jsonschema import Draft202012Validator + +ROOT = Path(__file__).resolve().parents[1] +PACK_SCHEMA = Draft202012Validator( + __import__("json").loads((ROOT / "schemas/v0.3/type-pack.schema.json").read_text()) +) +MISSING = object() + + +class PackInvalid(Exception): + """The manifest is invalid (`invalid_type_pack`).""" + + +class MergeConflict(Exception): + """Competing changes; the merge fails closed.""" + + +def digest(data: bytes) -> str: + return "sha256:" + hashlib.sha256(data).hexdigest() + + +def split_document(data: bytes) -> tuple[dict[str, Any], str]: + text = data.decode() + if not text.startswith("---\n"): + raise ValueError("document has no frontmatter") + end = text.index("\n---\n", 4) + frontmatter = yaml.safe_load(text[4:end + 1]) or {} + if not isinstance(frontmatter, dict): + raise ValueError("frontmatter is not a mapping") + return frontmatter, text[end + 5:] + + +def join_document(frontmatter: dict[str, Any], body: str) -> bytes: + rendered = yaml.safe_dump(frontmatter, sort_keys=False, allow_unicode=True) + return f"---\n{rendered}---\n{body}".encode() + + +@dataclass +class Baseline: + digest: str + document: bytes + version: int | None + + +@dataclass +class Resource: + kind: str + mode: str + source: str + target: str + digest: str + document: bytes + baselines: list[Baseline] = field(default_factory=list) + declares_upgrade: bool = False + + +@dataclass +class Pack: + id: str + version: str + digest: str + resources: list[Resource] + + +def load_pack(path: Path) -> Pack: + manifest = yaml.safe_load(path.read_text()) + errors = sorted(PACK_SCHEMA.iter_errors(manifest), key=lambda error: list(error.path)) + if errors: + raise PackInvalid(errors[0].message) + resources = [] + for entry in manifest["resources"]: + document = (path.parent / entry["source"]).read_bytes() + if digest(document) != entry["digest"]: + raise PackInvalid(f"digest mismatch for {entry['source']}") + resource = Resource( + entry["kind"], entry["mode"], entry["source"], entry["target"], entry["digest"], document + ) + if "upgrade_from" in entry: + resource.declares_upgrade = True + resource.baselines = validate_baselines(resource, entry["upgrade_from"]) + resources.append(resource) + canonical = __import__("json").dumps(manifest, sort_keys=True, separators=(",", ":")) + return Pack(manifest["id"], manifest["version"], digest(canonical.encode()), resources) + + +def validate_baselines(resource: Resource, declared: Any) -> list[Baseline]: + if resource.kind != "type" or resource.mode != "seed": + raise PackInvalid("upgrade_from is only valid on seed type resources") + desired, _ = split_document(resource.document) + entries = declared if isinstance(declared, list) else [declared] + baselines: list[Baseline] = [] + seen: set[str] = set() + for entry in entries: + document = entry["document"].encode() + if digest(document) != entry["digest"]: + raise PackInvalid("an upgrade baseline's digest does not match its document") + if entry["digest"] in seen: + raise PackInvalid("upgrade baselines must have distinct digests") + if entry["digest"] == resource.digest: + raise PackInvalid("an upgrade baseline cannot be the desired document") + frontmatter, _ = split_document(document) + if frontmatter.get("kind") != desired.get("kind") or frontmatter.get("name") != desired.get("name"): + raise PackInvalid("an upgrade baseline must be the same type kind and name") + version = entry.get("version") + if version is not None and frontmatter.get("version") != version: + raise PackInvalid("an upgrade baseline's version differs from its document") + seen.add(entry["digest"]) + baselines.append(Baseline(entry["digest"], document, version)) + return baselines + + +@dataclass +class Planned: + target: str + action: str + bytes: bytes | None + origin: str | None + reason: str | None = None + baseline: Baseline | None = None + + +@dataclass +class Assessment: + status: str + resources: list[Planned] + + @property + def applicable(self) -> bool: + return self.status != "conflict" + + +@dataclass +class Collection: + files: dict[str, bytes] = field(default_factory=dict) + lock: dict[str, dict[str, Any]] = field(default_factory=dict) + + def assess(self, pack: Pack) -> Assessment: + receipt = self.lock.get(pack.id) + installed = (receipt or {}).get("resources", {}) + planned = [self._plan(resource, installed.get(resource.target)) for resource in pack.resources] + if any(item.action == "conflict" for item in planned): + status = "conflict" + elif receipt is None: + status = "install" + elif receipt["version"] == pack.version and receipt["digest"] == pack.digest: + status = "current" + else: + status = "upgrade" + return Assessment(status, planned) + + def apply(self, pack: Pack) -> Assessment: + assessment = self.assess(pack) + if not assessment.applicable: + return assessment + for item in assessment.resources: + if item.bytes is not None: + self.files[item.target] = item.bytes + self.lock[pack.id] = { + "version": pack.version, + "digest": pack.digest, + "resources": { + resource.target: { + "mode": resource.mode, + "digest": resource.digest, + **({"origin_digest": item.origin} if resource.mode == "seed" and item.origin else {}), + } + for resource, item in zip(pack.resources, assessment.resources) + }, + } + return assessment + + def _plan(self, resource: Resource, entry: dict[str, Any] | None) -> Planned: + live = self.files.get(resource.target) + if resource.mode == "managed": + return self._plan_managed(resource, entry, live) + previous_origin = (entry or {}).get("origin_digest") + if live is None: + if entry is None: + return Planned(resource.target, "create", resource.document, resource.digest) + return Planned(resource.target, "preserve", None, previous_origin) + if live == resource.document: + return Planned(resource.target, "preserve", None, resource.digest) + if not resource.declares_upgrade: + return Planned(resource.target, "preserve", None, previous_origin) + for baseline in resource.baselines: + if live == baseline.document: + return Planned(resource.target, "update", resource.document, resource.digest, baseline=baseline) + if previous_origin == resource.digest: + return Planned(resource.target, "preserve", None, previous_origin) + baseline = next((item for item in resource.baselines if item.digest == previous_origin), None) + if baseline is None: + return Planned( + resource.target, "preserve", None, previous_origin, + reason=f"{resource.target}: no upgrade baseline applies to this type's origin", + ) + try: + merged = merge_documents(baseline.document, live, resource.document) + except MergeConflict as conflict: + return Planned(resource.target, "conflict", None, previous_origin, reason=f"{resource.target}: {conflict}") + return Planned(resource.target, "update", merged, resource.digest, baseline=baseline) + + def _plan_managed(self, resource: Resource, entry: dict[str, Any] | None, live: bytes | None) -> Planned: + if live is None: + return Planned(resource.target, "create", resource.document, None) + if live == resource.document: + return Planned(resource.target, "unchanged" if entry else "adopt", None, None) + if entry is not None and digest(live) == entry["digest"]: + return Planned(resource.target, "update", resource.document, None) + return Planned(resource.target, "conflict", None, None, reason=f"{resource.target} changed") + + +def merge_documents(base: bytes, live: bytes, desired: bytes) -> bytes: + base_frontmatter, _ = split_document(base) + live_frontmatter, live_body = split_document(live) + desired_frontmatter, _ = split_document(desired) + for key in ("kind", "name"): + if live_frontmatter.get(key) != desired_frontmatter.get(key): + raise MergeConflict(f"type {key} differs") + for key in base_frontmatter: + if key not in desired_frontmatter and key in live_frontmatter: + raise MergeConflict(f"removing top-level setting {key} requires manual review") + merged = merge_values(base_frontmatter, live_frontmatter, desired_frontmatter, "") + return join_document(merged, live_body) + + +def merge_values(base: Any, live: Any, desired: Any, path: str) -> Any: + if live == base: + return desired + if desired == base or live == desired: + return live + if all(isinstance(value, dict) for value in (base, live, desired)) or ( + base is MISSING and isinstance(live, dict) and isinstance(desired, dict) + ): + base = {} if base is MISSING else base + merged: dict[str, Any] = {} + for key in list(live) + [key for key in desired if key not in live]: + value = merge_values( + base.get(key, MISSING), live.get(key, MISSING), desired.get(key, MISSING), f"{path}/{key}" + ) + if value is not MISSING: + merged[key] = value + return merged + raise MergeConflict(f"competing changes at {path or '/'}") diff --git a/tests/v0.3/README.md b/tests/v0.3/README.md index ee03a02..37d9150 100644 --- a/tests/v0.3/README.md +++ b/tests/v0.3/README.md @@ -104,6 +104,32 @@ Future v0.3 adapters should support these operations: - `assess_type_pack` - `apply_type_pack` +### Type-pack history + +`assess_type_pack` and `apply_type_pack` tests may prepare the collection with +`input.history`, steps applied in order after `setup` and before the operation: + +- `apply: ` assesses and applies a pack; the step must succeed. +- `write: { path, content }` writes exact bytes. +- `replace: { path, old, new }` replaces the single occurrence of `old` in the + current bytes; the step fails unless `old` occurs exactly once. + +Seed-upgrade expectations, all keyed by collection target: + +- `resources`: entries matched by `target`, asserting `action`, optionally + `upgrade_baseline_version` (the assessment's `upgrade_baseline.version`), and + `reason` (`true` when a reason must be present, `false` when it must not). + For `apply_type_pack`, these refer to the first run's assessment. +- `target_matches_source`: the target's bytes equal the named source file. +- `target_unchanged`: the target's bytes equal what they were before the + operation. +- `target_frontmatter`: JSON Pointer to expected value in the target's parsed + frontmatter. Merged output may be reformatted, so merges are compared + structurally rather than by bytes. +- `target_body_contains`: text that must appear in the target's body. +- `lock_origin`: the target's lock `origin_digest` is the SHA-256 of the named + source file, or `absent`. + The repository also includes `scripts/check_v03_tests.py`, which validates the suite structure and executes local artifact checks that do not require a full v0.3 implementation. It also runs the prototype TaskNotes migration checks for diff --git a/tests/v0.3/manifest.yaml b/tests/v0.3/manifest.yaml index 1f79f26..af3cfdf 100644 --- a/tests/v0.3/manifest.yaml +++ b/tests/v0.3/manifest.yaml @@ -111,6 +111,8 @@ claim_profiles: - exact_type_pack_diff - idempotent_type_pack_apply - type_pack_atomicity + - seed_type_upgrade + - seed_type_upgrade_validation - id: lifecycle status: draft requires: [core_write, cel] @@ -191,7 +193,7 @@ fixture_sets: files: - data-contracts/data-contracts.yaml - id: type_packs - description: Complete type packs validate, report exact diffs, install atomically, and reinstall idempotently. + description: Complete type packs validate, report exact diffs, install atomically, reinstall idempotently, and upgrade seed types from their recorded origin. coverage_targets: [type_packs] files: - type-packs/type-packs.yaml diff --git a/tests/v0.3/type-packs/type-packs.yaml b/tests/v0.3/type-packs/type-packs.yaml index 44ddf31..86d13dc 100644 --- a/tests/v0.3/type-packs/type-packs.yaml +++ b/tests/v0.3/type-packs/type-packs.yaml @@ -139,3 +139,297 @@ groups: implementations: 1 covers: - type_packs.managed_type_pack_adoption + + - name: "seed type upgrades follow the seed's origin" + setup: + config: | + spec_version: "0.3.0" + settings: + validation: error + tests: + - id: seed-upgrade-unedited-older-baseline + name: "an unedited seed from an older listed baseline is replaced with the exact desired bytes" + operation: apply_type_pack + input: + pack: "examples/v0.3/seed-upgrades/v3/mdbase-pack.yaml" + history: + - apply: "examples/v0.3/seed-upgrades/v1/mdbase-pack.yaml" + repeat: 2 + expect: + valid: true + runs: + - valid: true + status: upgrade + actions: [update] + - valid: true + status: current + actions: [preserve] + resources: + - target: _types/note.md + action: update + upgrade_baseline_version: 1 + target_matches_source: + _types/note.md: "examples/v0.3/seed-upgrades/v3/_types/note.md" + lock_origin: + _types/note.md: "examples/v0.3/seed-upgrades/v3/_types/note.md" + covers: + - type_packs.seed_type_upgrade + + - id: seed-upgrade-single-baseline-object + name: "a single baseline object is a list of one" + operation: apply_type_pack + input: + pack: "examples/v0.3/seed-upgrades/v2/mdbase-pack.yaml" + history: + - apply: "examples/v0.3/seed-upgrades/v1/mdbase-pack.yaml" + expect: + valid: true + runs: + - valid: true + status: upgrade + actions: [update] + resources: + - target: _types/note.md + action: update + upgrade_baseline_version: 1 + target_matches_source: + _types/note.md: "examples/v0.3/seed-upgrades/v2/_types/note.md" + covers: + - type_packs.seed_type_upgrade + + - id: seed-upgrade-edited-merges-against-origin + name: "an edited seed merges against the baseline it was installed from, keeping the user's edits" + operation: apply_type_pack + input: + pack: "examples/v0.3/seed-upgrades/v3/mdbase-pack.yaml" + history: + - apply: "examples/v0.3/seed-upgrades/v1/mdbase-pack.yaml" + - replace: + path: _types/note.md + old: " title: { type: string }\n" + new: " title: { type: string }\n mood: { type: string }\n" + - replace: + path: _types/note.md + old: "A note in this collection.\n" + new: "My notes, as I keep them.\n" + expect: + valid: true + runs: + - valid: true + status: upgrade + actions: [update] + resources: + - target: _types/note.md + action: update + upgrade_baseline_version: 1 + target_frontmatter: + _types/note.md: + /version: 3 + /description: "A note with a creation date and tags." + /schema/value/properties/created: { type: string, format: date-time } + /schema/value/properties/tags: { type: array, items: { type: string } } + /schema/value/properties/mood: { type: string } + target_body_contains: + _types/note.md: ["My notes, as I keep them."] + lock_origin: + _types/note.md: "examples/v0.3/seed-upgrades/v3/_types/note.md" + covers: + - type_packs.seed_type_upgrade + + - id: seed-upgrade-origin-survives-preserve + name: "a pack version that preserves a seed keeps its origin, so a later upgrade merges against the true baseline" + operation: apply_type_pack + input: + pack: "examples/v0.3/seed-upgrades/v3/mdbase-pack.yaml" + history: + - apply: "examples/v0.3/seed-upgrades/v1/mdbase-pack.yaml" + - apply: "examples/v0.3/seed-upgrades/v1.5-plain/mdbase-pack.yaml" + - replace: + path: _types/note.md + old: " title: { type: string }\n" + new: " title: { type: string }\n mood: { type: string }\n" + expect: + valid: true + runs: + - valid: true + status: upgrade + actions: [update] + resources: + - target: _types/note.md + action: update + upgrade_baseline_version: 1 + target_frontmatter: + _types/note.md: + /version: 3 + /schema/value/properties/created: { type: string, format: date-time } + /schema/value/properties/mood: { type: string } + covers: + - type_packs.seed_type_upgrade + + - id: seed-upgrade-unlisted-origin-preserved + name: "an edited seed whose origin is not a listed baseline is preserved with a reason, without a conflict" + operation: apply_type_pack + input: + pack: "examples/v0.3/seed-upgrades/v3-only-v2/mdbase-pack.yaml" + history: + - apply: "examples/v0.3/seed-upgrades/v1/mdbase-pack.yaml" + - replace: + path: _types/note.md + old: " title: { type: string }\n" + new: " title: { type: string }\n mood: { type: string }\n" + expect: + valid: true + runs: + - valid: true + status: upgrade + actions: [preserve] + resources: + - target: _types/note.md + action: preserve + reason: true + target_unchanged: [_types/note.md] + lock_origin: + _types/note.md: "examples/v0.3/seed-upgrades/v1/_types/note.md" + covers: + - type_packs.seed_type_upgrade + + - id: seed-upgrade-edited-after-upgrade-preserved + name: "a seed edited after its upgrade is preserved without a reason" + operation: assess_type_pack + input: + pack: "examples/v0.3/seed-upgrades/v3/mdbase-pack.yaml" + history: + - apply: "examples/v0.3/seed-upgrades/v1/mdbase-pack.yaml" + - apply: "examples/v0.3/seed-upgrades/v3/mdbase-pack.yaml" + - replace: + path: _types/note.md + old: " title: { type: string }\n" + new: " title: { type: string }\n mood: { type: string }\n" + expect: + valid: true + status: current + applicable: true + actions: [preserve] + resources: + - target: _types/note.md + action: preserve + reason: false + covers: + - type_packs.seed_type_upgrade + + - name: "a seed target that existed before installation has no origin" + setup: + config: | + spec_version: "0.3.0" + settings: + validation: error + files: + _types/note.md: | + --- + kind: mdbase.type + name: note + version: 1 + description: The collection's own note type. + schema: + dialect: json-schema-2020-12 + value: + type: object + additionalProperties: true + --- + tests: + - id: seed-upgrade-unknown-origin-preserved + name: "a user-owned type at a seed target is never merged into a starter" + operation: apply_type_pack + input: + pack: "examples/v0.3/seed-upgrades/v3/mdbase-pack.yaml" + history: + - apply: "examples/v0.3/seed-upgrades/v1/mdbase-pack.yaml" + expect: + valid: true + runs: + - valid: true + status: upgrade + actions: [preserve] + resources: + - target: _types/note.md + action: preserve + reason: true + target_unchanged: [_types/note.md] + lock_origin: + _types/note.md: absent + covers: + - type_packs.seed_type_upgrade + + - name: "upgrade baselines are validated" + setup: + config: | + spec_version: "0.3.0" + settings: + validation: error + tests: + - id: seed-upgrade-invalid-duplicate-baseline + name: "invalid upgrade_from (duplicate-baseline) rejects the manifest" + operation: assess_type_pack + input: + pack: "examples/v0.3/seed-upgrades/invalid-duplicate-baseline/mdbase-pack.yaml" + expect: + valid: false + error: + code: invalid_type_pack + covers: + - type_packs.seed_type_upgrade_validation + - id: seed-upgrade-invalid-self-baseline + name: "invalid upgrade_from (self-baseline) rejects the manifest" + operation: assess_type_pack + input: + pack: "examples/v0.3/seed-upgrades/invalid-self-baseline/mdbase-pack.yaml" + expect: + valid: false + error: + code: invalid_type_pack + covers: + - type_packs.seed_type_upgrade_validation + - id: seed-upgrade-invalid-version-mismatch + name: "invalid upgrade_from (version-mismatch) rejects the manifest" + operation: assess_type_pack + input: + pack: "examples/v0.3/seed-upgrades/invalid-version-mismatch/mdbase-pack.yaml" + expect: + valid: false + error: + code: invalid_type_pack + covers: + - type_packs.seed_type_upgrade_validation + - id: seed-upgrade-invalid-name-mismatch + name: "invalid upgrade_from (name-mismatch) rejects the manifest" + operation: assess_type_pack + input: + pack: "examples/v0.3/seed-upgrades/invalid-name-mismatch/mdbase-pack.yaml" + expect: + valid: false + error: + code: invalid_type_pack + covers: + - type_packs.seed_type_upgrade_validation + - id: seed-upgrade-invalid-managed-upgrade + name: "invalid upgrade_from (managed-upgrade) rejects the manifest" + operation: assess_type_pack + input: + pack: "examples/v0.3/seed-upgrades/invalid-managed-upgrade/mdbase-pack.yaml" + expect: + valid: false + error: + code: invalid_type_pack + covers: + - type_packs.seed_type_upgrade_validation + - id: seed-upgrade-invalid-baseline-digest + name: "invalid upgrade_from (baseline-digest) rejects the manifest" + operation: assess_type_pack + input: + pack: "examples/v0.3/seed-upgrades/invalid-baseline-digest/mdbase-pack.yaml" + expect: + valid: false + error: + code: invalid_type_pack + covers: + - type_packs.seed_type_upgrade_validation