From f5f5743735bf0f3d1a84bd017df9829f679fb4c9 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sat, 3 Oct 2026 08:14:19 +1000 Subject: [PATCH 1/3] Upgrade seed types from any listed baseline, chosen by recorded origin A seed type's `upgrade_from` held one baseline, the most recent previous starter. An edited seed installed from an older starter was merged against it, so the differences between the two starters were applied as if they were the user's edits (a removed rule came back, a new property was dropped). The lock could not help: it recorded the pack's resource digest for every seed, even a seed the pack preserved or a type that predated the pack. `upgrade_from` is now one baseline or a list, each `{ digest, document, version? }`, validated (digest, seed types only, distinct, not the desired document, same kind and name, version agrees). The lock records a seed's `origin_digest`. The engine plans, in order: preserve when live equals the desired document; replace with the exact desired bytes when live equals any baseline; preserve when the origin is already the desired document; merge against the origin baseline; otherwise preserve with a reason, never guessing a baseline and never making the whole pack conflict. The assessment reports the baseline used. Conformance gains seed-upgrade example packs, `input.history` steps, and an executable model (scripts/type_pack_model.py) that the checker runs; mutated models that merge against the newest baseline, use the pack digest as origin, or conflict instead of preserving each fail the fixtures. --- 05a-data-contracts.md | 59 +++- CHANGELOG.md | 23 +- examples/v0.3/seed-upgrades/README.md | 14 + .../invalid-baseline-digest/_types/note.md | 22 ++ .../invalid-baseline-digest/mdbase-pack.yaml | 36 +++ .../invalid-duplicate-baseline/_types/note.md | 22 ++ .../mdbase-pack.yaml | 59 ++++ .../invalid-managed-upgrade/_types/note.md | 22 ++ .../invalid-managed-upgrade/mdbase-pack.yaml | 35 +++ .../invalid-name-mismatch/_types/note.md | 22 ++ .../invalid-name-mismatch/mdbase-pack.yaml | 35 +++ .../invalid-self-baseline/_types/note.md | 22 ++ .../invalid-self-baseline/mdbase-pack.yaml | 36 +++ .../invalid-version-mismatch/_types/note.md | 22 ++ .../invalid-version-mismatch/mdbase-pack.yaml | 34 ++ .../seed-upgrades/v1.5-plain/_types/note.md | 21 ++ .../seed-upgrades/v1.5-plain/mdbase-pack.yaml | 11 + examples/v0.3/seed-upgrades/v1/_types/note.md | 20 ++ .../v0.3/seed-upgrades/v1/mdbase-pack.yaml | 10 + examples/v0.3/seed-upgrades/v2/_types/note.md | 21 ++ .../v0.3/seed-upgrades/v2/mdbase-pack.yaml | 34 ++ .../seed-upgrades/v3-only-v2/_types/note.md | 22 ++ .../seed-upgrades/v3-only-v2/mdbase-pack.yaml | 36 +++ examples/v0.3/seed-upgrades/v3/_types/note.md | 22 ++ .../v0.3/seed-upgrades/v3/mdbase-pack.yaml | 58 ++++ schemas/v0.3/type-pack-lock.schema.json | 3 +- schemas/v0.3/type-pack.schema.json | 25 +- scripts/check_v03_tests.py | 105 +++++++ scripts/type_pack_model.py | 260 ++++++++++++++++ tests/v0.3/README.md | 26 ++ tests/v0.3/manifest.yaml | 4 +- tests/v0.3/type-packs/type-packs.yaml | 294 ++++++++++++++++++ 32 files changed, 1415 insertions(+), 20 deletions(-) create mode 100644 examples/v0.3/seed-upgrades/README.md create mode 100644 examples/v0.3/seed-upgrades/invalid-baseline-digest/_types/note.md create mode 100644 examples/v0.3/seed-upgrades/invalid-baseline-digest/mdbase-pack.yaml create mode 100644 examples/v0.3/seed-upgrades/invalid-duplicate-baseline/_types/note.md create mode 100644 examples/v0.3/seed-upgrades/invalid-duplicate-baseline/mdbase-pack.yaml create mode 100644 examples/v0.3/seed-upgrades/invalid-managed-upgrade/_types/note.md create mode 100644 examples/v0.3/seed-upgrades/invalid-managed-upgrade/mdbase-pack.yaml create mode 100644 examples/v0.3/seed-upgrades/invalid-name-mismatch/_types/note.md create mode 100644 examples/v0.3/seed-upgrades/invalid-name-mismatch/mdbase-pack.yaml create mode 100644 examples/v0.3/seed-upgrades/invalid-self-baseline/_types/note.md create mode 100644 examples/v0.3/seed-upgrades/invalid-self-baseline/mdbase-pack.yaml create mode 100644 examples/v0.3/seed-upgrades/invalid-version-mismatch/_types/note.md create mode 100644 examples/v0.3/seed-upgrades/invalid-version-mismatch/mdbase-pack.yaml create mode 100644 examples/v0.3/seed-upgrades/v1.5-plain/_types/note.md create mode 100644 examples/v0.3/seed-upgrades/v1.5-plain/mdbase-pack.yaml create mode 100644 examples/v0.3/seed-upgrades/v1/_types/note.md create mode 100644 examples/v0.3/seed-upgrades/v1/mdbase-pack.yaml create mode 100644 examples/v0.3/seed-upgrades/v2/_types/note.md create mode 100644 examples/v0.3/seed-upgrades/v2/mdbase-pack.yaml create mode 100644 examples/v0.3/seed-upgrades/v3-only-v2/_types/note.md create mode 100644 examples/v0.3/seed-upgrades/v3-only-v2/mdbase-pack.yaml create mode 100644 examples/v0.3/seed-upgrades/v3/_types/note.md create mode 100644 examples/v0.3/seed-upgrades/v3/mdbase-pack.yaml create mode 100644 scripts/type_pack_model.py diff --git a/05a-data-contracts.md b/05a-data-contracts.md index 0e96383..12e9cea 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 type kind or name differs +from the desired type, or when a baseline's `version` differs from the version +its document 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,18 @@ 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 when it preserves a seed whose live bytes already +equal the desired document. In every other case it carries the previous entry's +`origin_digest` for that target forward unchanged, or omits it when there is +none, including when a seed target existed before the pack was installed and +when the seed is an intentionally preserved target. A lock entry without +`origin_digest` means the origin is unknown. + 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..6936ddd 100644 --- a/schemas/v0.3/type-pack-lock.schema.json +++ b/schemas/v0.3/type-pack-lock.schema.json @@ -56,7 +56,8 @@ "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" } }, "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 From 259e064c9e788e9174c6a6dfc01d8b13815a8127 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sat, 3 Oct 2026 08:31:27 +1000 Subject: [PATCH 2/3] Let byte equality set a seed's origin, and keep origin-only changes current A preserved target whose bytes equal the desired document descends from it, whether or not it predated the pack or is an intentionally preserved target. Recording only an origin is not a reconfiguration. --- 05a-data-contracts.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/05a-data-contracts.md b/05a-data-contracts.md index 12e9cea..eaa0cb6 100644 --- a/05a-data-contracts.md +++ b/05a-data-contracts.md @@ -468,12 +468,14 @@ 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 when it preserves a seed whose live bytes already -equal the desired document. In every other case it carries the previous entry's +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, including when a seed target existed before the pack was installed and -when the seed is an intentionally preserved target. A lock entry without -`origin_digest` means the origin is unknown. +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. 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 From 691a33ef061161e7955da1ee7719363a36a5d7bf Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sat, 3 Oct 2026 08:46:19 +1000 Subject: [PATCH 3/3] State the effect on pre-origin locks; restrict origin_digest to seeds; name the frontmatter keys Edited seeds under locks written before origin_digest are preserved with a reason until upgraded or recreated; unedited seeds still upgrade. The lock schema rejects origin_digest on managed resources, and baseline validation names the frontmatter kind, name and version keys it compares. --- 05a-data-contracts.md | 12 ++++++++---- schemas/v0.3/type-pack-lock.schema.json | 2 ++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/05a-data-contracts.md b/05a-data-contracts.md index eaa0cb6..299ae0d 100644 --- a/05a-data-contracts.md +++ b/05a-data-contracts.md @@ -385,9 +385,9 @@ Engines that do not support this member MUST reject the manifest. 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 type kind or name differs -from the desired type, or when a baseline's `version` differs from the version -its document declares. +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 @@ -474,7 +474,11 @@ document, whatever else applies. Otherwise it carries the previous entry's 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. An apply that changes only seed origins leaves the pack's +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 diff --git a/schemas/v0.3/type-pack-lock.schema.json b/schemas/v0.3/type-pack-lock.schema.json index 6936ddd..7a4aa23 100644 --- a/schemas/v0.3/type-pack-lock.schema.json +++ b/schemas/v0.3/type-pack-lock.schema.json @@ -59,6 +59,8 @@ "digest": { "$ref": "#/$defs/digest" }, "origin_digest": { "$ref": "#/$defs/digest" } }, + "if": { "properties": { "mode": { "const": "managed" } } }, + "then": { "not": { "required": ["origin_digest"] } }, "additionalProperties": false } }