From a193b9d89d5b1528f9dab1156c5b01e6bf253439 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sat, 3 Oct 2026 08:31:26 +1000 Subject: [PATCH 1/3] Upgrade seed types from any declared starter baseline mdbase-spec 05A (mdbase-dev/mdbase-spec#59) lets a seed type's `upgrade_from` list every previously shipped starter, and has engines merge an edited seed only against the baseline its lock records as origin. Protocol: `upgrade_from` accepts one baseline or a non-empty list of `{ digest, document, version? }` in both the app-manifest and protocol schemas. The provision checks move into `validateTypePackProvision`, shared by app-manifest validation and the devkit, which rejects baselines on non-seed types, digest mismatches, duplicates, the resource's own digest, a different type kind or name, and a `version` the document does not declare, at the offending baseline's path. Assessments gain `upgrade_baseline` (SDK `upgradeBaseline`) and receipts `origin_digest` (`originDigest`). The Rust manifest passes `upgrade_from` through verbatim (the pack digest covers it; mdbase validates it) instead of a single-baseline struct. Devkit: `defineTypePack` accepts `upgradeFrom: [{ document, version? }]` on seed types, computing digests and defaulting `version` to the document's. Editor: guided Person setup accepts a seed update only when the engine reports an `upgradeBaseline` the bundled pack declares, and reports a Person type the engine preserves with a reason as kept, instead of offering the update again. --- CHANGELOG.md | 17 ++ apps/editor/src/YourPersonPanel.tsx | 15 +- apps/editor/src/person-setup-pack.test.ts | 4 +- apps/editor/src/person-setup.test.tsx | 33 ++- apps/editor/src/person-setup.ts | 16 +- .../person-starter-upgrade.assessment.json | 220 +++++++++++++++++- apps/portal/src/api.ts | 4 +- apps/portal/src/authorization-view.tsx | 2 +- crates/connect-protocol/src/applications.rs | 10 +- crates/connect-protocol/src/tests.rs | 34 +++ docs/portable-people.md | 12 +- packages/client/src/collection-client.ts | 4 +- packages/client/src/index.test.ts | 33 ++- packages/client/src/operation-types.ts | 7 +- packages/devkit/README.md | 22 ++ packages/devkit/src/index.test.ts | 41 ++++ packages/devkit/src/index.ts | 34 ++- packages/protocol/package.json | 3 +- .../schemas/connect-protocol.v1.schema.json | 16 ++ .../protocol/schemas/mdbase-app.schema.json | 21 +- packages/protocol/src/manifest.ts | 157 +++++++++---- packages/protocol/src/type-packs.ts | 22 +- packages/protocol/test/manifest.test.mjs | 75 +++++- pnpm-lock.yaml | 3 + 24 files changed, 694 insertions(+), 111 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d734c9972..9f3d6cd85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ ## Unreleased +- Seed type upgrades follow mdbase-spec 05A's upgrade baselines + (mdbase-dev/mdbase-spec#59). A type pack's seed `upgrade_from` may now be one + baseline or a non-empty list of `{ digest, document, version? }`; manifest + validation (`validateAppManifest`, and the new `validateTypePackProvision` + in `@mdbase-dev/connect-protocol/manifest`) rejects baselines on non-seed + types, digest mismatches, duplicates, the resource's own digest, a different + type kind or name, and a `version` the document does not declare, at the + offending baseline's path. The single-object form is unchanged. The SDK's + type-pack assessments add `upgradeBaseline` to seed updates, and receipts add + `originDigest`. The devkit's `defineTypePack` accepts + `upgradeFrom: [{ document, version? }]` on seed types. The editor's guided + Person setup accepts a starter upgrade only when the engine reports a + baseline the bundled pack declares, and says **Person type kept as it is** + when the engine preserves the type with a reason instead of offering the + update again. Lists take effect once the engines implementing 05A are pinned; + earlier engines reject them. + - The editor's guided person setup installs `mdbase.contact` 1.3.0, whose Person v3 starter neither declares nor requires `type`. People created in collections whose `settings.explicit_type_keys` is not `[type]` (such as diff --git a/apps/editor/src/YourPersonPanel.tsx b/apps/editor/src/YourPersonPanel.tsx index 580be12ff..07f5ab4f7 100644 --- a/apps/editor/src/YourPersonPanel.tsx +++ b/apps/editor/src/YourPersonPanel.tsx @@ -32,6 +32,8 @@ export function YourPersonPanel({ gateway, description, canCreate, canEdit, canI // The grant predates People consent or the optional identity permission was declined. const [identityDenied, setIdentityDenied] = useState(false); const [revision, setRevision] = useState(0); + // The engine kept the Person type because no declared starter baseline applies. + const [starterKept, setStarterKept] = useState(false); const [setup, setSetup] = useState<{ provision: TypePackProvision; assessment: TypePackAssessment; upgrade?: ReturnType["upgrade"]; controller: AbortController }>(); const setupPanel = useRef(null); const setupButton = useRef(null); @@ -56,7 +58,7 @@ export function YourPersonPanel({ gateway, description, canCreate, canEdit, canI useEffect(() => { const controller = new AbortController(); lifecycle.current = controller; - setDirectory(undefined); setContacts([]); setConversion(undefined); setSetup(undefined); setError(""); setIdentityDenied(false); setConfirmClaimed(false); + setDirectory(undefined); setContacts([]); setConversion(undefined); setSetup(undefined); setStarterKept(false); setError(""); setIdentityDenied(false); setConfirmClaimed(false); void (async () => { try { if (!gateway.peopleDirectory) throw new Error("This collection has no Connect account identity."); @@ -104,8 +106,9 @@ export function YourPersonPanel({ gateway, description, canCreate, canEdit, canI const assessment = await gateway.assessTypePack(provision); if (controller.signal.aborted) return; assertCurrent(); - const { upgrade } = requireGuidedPersonSetup(provision, assessment); - setSetup({ provision, assessment, upgrade, controller }); + const { upgrade, kept } = requireGuidedPersonSetup(provision, assessment); + if (kept) setStarterKept(true); + else setSetup({ provision, assessment, upgrade, controller }); } catch (reason) { if (!controller.signal.aborted) setError(reason instanceof Error ? reason.message : "Could not review person setup."); } finally { setBusy(false); } @@ -218,10 +221,12 @@ export function YourPersonPanel({ gateway, description, canCreate, canEdit, canI {conversion &&

This changes only {conversion.path} to the {conversion.typeName} type, which implements both Person and Contact. Its contact information and Markdown body are retained. No other contacts or type definitions are changed.

Review fields to write
{JSON.stringify(conversion.patch, null, 2)}
} {!canEdit &&

Ask a collection editor to link your person record.

} {(implementations.length === 0 || outdatedStarter) && <> -
{implementations.length === 0 +
{starterKept + ?

Person type kept as it is

This collection's Person type was not added from a Person starter that can be upgraded automatically, so it has not been changed. {implementations.length === 0 ? "Review its Person mappings in Types before creating a person record." : "You can still use it, or compare it with the current starter in Types."}

+ : implementations.length === 0 ?

Person definitions needed

This collection needs person definitions before you can create or link a person record. Nothing will be added without your approval.

:

Person type update available

The {outdatedStarter!.typeName} type is an earlier Person starter. Review the current starter before creating your record. Nothing will change without your approval.

} - {!setup && }
+ {!setup && !starterKept && }
{!canInstall &&

Adding definitions requires permission to manage this collection's types.

} {setup &&
{setup.upgrade ? <> diff --git a/apps/editor/src/person-setup-pack.test.ts b/apps/editor/src/person-setup-pack.test.ts index d8db6694b..1b0e06ccf 100644 --- a/apps/editor/src/person-setup-pack.test.ts +++ b/apps/editor/src/person-setup-pack.test.ts @@ -27,8 +27,8 @@ it("loads the exact SHA-pinned canonical People/Contact provision without a cata expect(person).toContain(" required: [name]\n"); expect(person).not.toMatch(/^ {6}type:$/m); // Person v3 upgrades the exact Person v2 seed from 1.2.0. - expect(provision.manifest.resources.find(({ target }) => target === "_types/person.md")?.upgrade_from?.digest) - .toBe("sha256:aa81c2964285ecc69fcb96bca4b6ae80bbddeb648908d840d7c7be50df15b362"); + expect(provision.manifest.resources.find(({ target }) => target === "_types/person.md")?.upgrade_from) + .toMatchObject({ digest: "sha256:aa81c2964285ecc69fcb96bca4b6ae80bbddeb648908d840d7c7be50df15b362" }); expect(fetcher).toHaveBeenCalledTimes(1); expect(fetcher.mock.calls[0][0]).not.toContain("mdbase.dev/contracts"); }); diff --git a/apps/editor/src/person-setup.test.tsx b/apps/editor/src/person-setup.test.tsx index 2ced38aea..7bd64e8e3 100644 --- a/apps/editor/src/person-setup.test.tsx +++ b/apps/editor/src/person-setup.test.tsx @@ -9,7 +9,10 @@ import { loadPersonSetup, requireGuidedPersonSetup } from "./person-setup"; import bundled from "./person-setup.pack.json"; // Real `mdbase packs assess` results (CLI 0.1.0-beta.123) for the bundled 1.3.0 // provision in a collection with mdbase.contact 1.2.0 applied: `unedited` keeps -// the Person v2 seed as installed; `merged` has an edited Markdown body. +// the Person v2 seed as installed; `merged` has an edited Markdown body and a +// lock recording that seed's origin. `upgradeBaseline`, `originDigest`, and the +// whole `unknownOrigin` case (an edited seed under a lock without an origin) are +// hand-constructed from mdbase-spec 05A until the engines report them. import upgrades from "./test/person-starter-upgrade.assessment.json"; import { chooseOption } from "./test/select"; @@ -156,13 +159,37 @@ it("sends every other replacement, removal, conflict, or downgrade to Types", () expect(() => requireGuidedPersonSetup(provision, managed)).toThrow("No files have been changed"); const downgrade = upgrade("unedited"); downgrade.status = "downgrade"; expect(() => requireGuidedPersonSetup(provision, downgrade)).toThrow("No files have been changed"); - // A seed update the pack does not declare: no upgrade_from, or a different installed baseline. + // A seed update the pack does not declare: no upgrade_from, no reported baseline, or another one. const undeclared = structuredClone(provision); delete undeclared.manifest.resources.find((r) => r.target === "_types/person.md")!.upgrade_from; expect(() => requireGuidedPersonSetup(undeclared, upgrade("unedited"))).toThrow("No files have been changed"); - const otherBaseline = upgrade("merged"); person(otherBaseline).installedDigest = "sha256:other"; + const unreported = upgrade("merged"); delete person(unreported).upgradeBaseline; + expect(() => requireGuidedPersonSetup(provision, unreported)).toThrow("No files have been changed"); + const otherBaseline = upgrade("merged"); person(otherBaseline).upgradeBaseline = { digest: "sha256:other" }; expect(() => requireGuidedPersonSetup(provision, otherBaseline)).toThrow("No files have been changed"); }); +it("accepts an upgrade from any baseline the pack lists, as the engine reports it", () => { + const first = "---\nkind: mdbase.type\nname: person\nversion: 1\n---\n"; + const listed = structuredClone(provision); + const seed = listed.manifest.resources.find((r) => r.target === "_types/person.md")!; + seed.upgrade_from = [{ digest: "sha256:person-v1", document: first, version: 1 }, ...[seed.upgrade_from ?? []].flat()]; + const fromFirst = upgrade("merged"); + Object.assign(fromFirst.resources.find((r) => r.target === "_types/person.md")!, { upgradeBaseline: { digest: "sha256:person-v1", version: 1 } }); + expect(requireGuidedPersonSetup(listed, fromFirst)).toEqual({ upgrade: { target: "_types/person.md", merged: true } }); + expect(requireGuidedPersonSetup(listed, upgrade("unedited"))).toEqual({ upgrade: { target: "_types/person.md", merged: false } }); + expect(() => requireGuidedPersonSetup(provision, fromFirst)).toThrow("No files have been changed"); +}); +it("reports a Person type the engine keeps without a known starter, instead of offering it again", async () => { + expect(requireGuidedPersonSetup(provision, upgrade("unknownOrigin"))).toEqual({ kept: true }); + const f = fixture(true, personAt(2), upgrade("unknownOrigin")); + fireEvent.click(await screen.findByRole("button", { name: "Review Person type update" })); + expect(await screen.findByRole("heading", { name: "Person type kept as it is" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Review Person type update" })).toBeNull(); + expect(screen.queryByRole("region", { name: "Review person setup" })).toBeNull(); + expect(screen.queryByRole("alert")).toBeNull(); + expect(screen.getByRole("button", { name: "Create my person record" })).toBeInTheDocument(); + expect(f.gateway.applyTypePack).not.toHaveBeenCalled(); +}); it("reviews an earlier Person starter as an upgrade that keeps collection edits", async () => { const f = fixture(true, personAt(2), upgrade("merged")); f.refresh.mockImplementationOnce(async () => { f.view.rerender(f.panel(personAt(3))); return personAt(3); }); diff --git a/apps/editor/src/person-setup.ts b/apps/editor/src/person-setup.ts index 2fd6baa62..10fca2904 100644 --- a/apps/editor/src/person-setup.ts +++ b/apps/editor/src/person-setup.ts @@ -31,18 +31,19 @@ interface PersonStarterUpgrade { merged: boolean; } -/** A seed type replaced through the provision's own reviewed `upgrade_from` baseline. */ +/** A seed type the engine upgrades from one of the bundled provision's own reviewed baselines. */ function starterUpgrade(provision: TypePackProvision, resource: TypePackResourceDiff): PersonStarterUpgrade | undefined { const seed = provision.manifest.resources.find((candidate) => candidate.source === resource.source && candidate.target === resource.target); if (resource.action !== "update" || resource.kind !== "type" || resource.mode !== "seed" || seed?.kind !== "type" || seed.mode !== "seed") return undefined; - if (!seed.upgrade_from || resource.installedDigest !== seed.upgrade_from.digest) return undefined; - return { target: resource.target, merged: resource.currentDigest !== resource.installedDigest }; + const baseline = resource.upgradeBaseline; + if (!baseline || ![seed.upgrade_from ?? []].flat().some(({ digest }) => digest === baseline.digest)) return undefined; + return { target: resource.target, merged: resource.currentDigest !== baseline.digest }; } -export function requireGuidedPersonSetup(provision: TypePackProvision, assessment: TypePackAssessment) { +export function requireGuidedPersonSetup(provision: TypePackProvision, assessment: TypePackAssessment): { upgrade?: PersonStarterUpgrade; kept?: true } { // This guided flow adds missing definitions, records ownership of identical - // existing bytes, or upgrades an older Person starter the pack declares it - // supersedes. Other updates, adoptions, and downgrades belong in Types. + // existing bytes, or upgrades an older Person starter from a baseline the pack + // declares. Other updates, adoptions, and downgrades belong in Types. const unsafe = assessment.resources.find((resource) => (["update", "delete", "conflict"].includes(resource.action) && !starterUpgrade(provision, resource)) || (resource.action === "adopt" && resource.currentDigest !== resource.digest)); @@ -50,6 +51,9 @@ export function requireGuidedPersonSetup(provision: TypePackProvision, assessmen throw new Error(unsafe?.reason ?? "These definitions need review in Types. No files have been changed."); } const person = assessment.resources.find((resource) => resource.target === PERSON_STARTER.target); + // The engine keeps a Person type it cannot trace to a declared starter, with a + // reason. Retrying would only repeat that, so setup reports it and stops. + if (person?.action === "preserve" && person.mode === "seed" && person.reason) return { kept: true }; const upgrade = person && starterUpgrade(provision, person); if (person?.action !== "create" && !upgrade) { throw new Error("A Person type already exists but needs compatible mappings. Review it in Types; this setup will not replace it."); diff --git a/apps/editor/src/test/person-starter-upgrade.assessment.json b/apps/editor/src/test/person-starter-upgrade.assessment.json index abf2d2f3b..2c9127da6 100644 --- a/apps/editor/src/test/person-starter-upgrade.assessment.json +++ b/apps/editor/src/test/person-starter-upgrade.assessment.json @@ -113,7 +113,8 @@ "target": "_types/person.md", "kind": "type", "mode": "seed", - "digest": "sha256:e74ebbce6c67e195a52d005c29ad63f93ae044800a857d33fb3bbc73135adb72" + "digest": "sha256:e74ebbce6c67e195a52d005c29ad63f93ae044800a857d33fb3bbc73135adb72", + "originDigest": "sha256:e74ebbce6c67e195a52d005c29ad63f93ae044800a857d33fb3bbc73135adb72" } ] }, @@ -186,7 +187,10 @@ "action": "update", "digest": "sha256:35ac054e30e2f5c22c2af19e821ea4477e714741f8763e32e778cb4139ed6c37", "currentDigest": "sha256:aa81c2964285ecc69fcb96bca4b6ae80bbddeb648908d840d7c7be50df15b362", - "installedDigest": "sha256:aa81c2964285ecc69fcb96bca4b6ae80bbddeb648908d840d7c7be50df15b362" + "installedDigest": "sha256:aa81c2964285ecc69fcb96bca4b6ae80bbddeb648908d840d7c7be50df15b362", + "upgradeBaseline": { + "digest": "sha256:aa81c2964285ecc69fcb96bca4b6ae80bbddeb648908d840d7c7be50df15b362" + } } ], "lock": { @@ -256,7 +260,8 @@ "target": "_types/person.md", "kind": "type", "mode": "seed", - "digest": "sha256:aa81c2964285ecc69fcb96bca4b6ae80bbddeb648908d840d7c7be50df15b362" + "digest": "sha256:aa81c2964285ecc69fcb96bca4b6ae80bbddeb648908d840d7c7be50df15b362", + "originDigest": "sha256:aa81c2964285ecc69fcb96bca4b6ae80bbddeb648908d840d7c7be50df15b362" } ] }, @@ -313,7 +318,8 @@ "target": "_types/person.md", "kind": "type", "mode": "seed", - "digest": "sha256:e74ebbce6c67e195a52d005c29ad63f93ae044800a857d33fb3bbc73135adb72" + "digest": "sha256:e74ebbce6c67e195a52d005c29ad63f93ae044800a857d33fb3bbc73135adb72", + "originDigest": "sha256:e74ebbce6c67e195a52d005c29ad63f93ae044800a857d33fb3bbc73135adb72" } ] }, @@ -386,7 +392,211 @@ "action": "update", "digest": "sha256:0d6734dd763d1b4836ec9f45664bf10998eb35a696c62efcdc24ab0ac43f4259", "currentDigest": "sha256:717ce99eb0dd296d96b956e907b780b8adef0d5dc896115aec213e9eb33a52ef", - "installedDigest": "sha256:aa81c2964285ecc69fcb96bca4b6ae80bbddeb648908d840d7c7be50df15b362" + "installedDigest": "sha256:aa81c2964285ecc69fcb96bca4b6ae80bbddeb648908d840d7c7be50df15b362", + "upgradeBaseline": { + "digest": "sha256:aa81c2964285ecc69fcb96bca4b6ae80bbddeb648908d840d7c7be50df15b362" + } + } + ], + "lock": { + "action": "update", + "digest": "sha256:cfaf68f3e5f3efa4a8edd7be7c9a791e868f52aa5aa5db2d19de1b7ea37e7ece", + "target": "mdbase.lock.yaml" + }, + "contractSetups": { + "choices": [], + "resources": [] + } + }, + "unknownOrigin": { + "status": "upgrade", + "applicable": true, + "assessmentDigest": "sha256:a21c6a4a8a1321fbda1f4e6ecdb0c99b3ddec66526a685b3ae912bc8323f51f7", + "current": { + "id": "mdbase.contact", + "version": "1.2.0", + "digest": "sha256:486280774dc8e18f28cd314cc6fe3336ed9b923224050fa4dd76a34b0f157249", + "installedBy": "dev.mdbase.editor", + "resources": [ + { + "source": "schemas/mdbase.person/1.0.0.schema.json", + "target": "schemas/mdbase.person/1.0.0.schema.json", + "kind": "schema", + "mode": "managed", + "digest": "sha256:3254afaa98c895e84fdc0aba299e67a0f3b594681c5581313ece5fcf4e2c6d93" + }, + { + "source": "contracts/mdbase.person/1.0.0.md", + "target": "_contracts/mdbase.person/1.0.0.md", + "kind": "contract", + "mode": "managed", + "digest": "sha256:e2a9435ad01a2c06c151bf6af62a9d04aa30faa8e6128e4b5fe39378c767465c" + }, + { + "source": "schemas/mdbase.person/2.0.0.schema.json", + "target": "schemas/mdbase.person/2.0.0.schema.json", + "kind": "schema", + "mode": "managed", + "digest": "sha256:f9705e5869a2814086b907323749f63dce60de535728d6725601f02901ae60ac" + }, + { + "source": "contracts/mdbase.person/2.0.0.md", + "target": "_contracts/mdbase.person/2.0.0.md", + "kind": "contract", + "mode": "managed", + "digest": "sha256:2899b149b1602bd4e51ff8e87c82dc92da3f690f4e89b1a6caf5b2b555540fe5" + }, + { + "source": "schemas/mdbase.contact/1.0.0.schema.json", + "target": "schemas/mdbase.contact/1.0.0.schema.json", + "kind": "schema", + "mode": "managed", + "digest": "sha256:bd8a011d18c71164b633f4e82cb33faa5b658cdba4f89312a34c13bf46a350d2" + }, + { + "source": "contracts/mdbase.contact/1.0.0.md", + "target": "_contracts/mdbase.contact/1.0.0.md", + "kind": "contract", + "mode": "managed", + "digest": "sha256:e3634a35737ada5eb62e3e15f1c0c9d2e27c55a3f98dfab94bbc14c40404903d" + }, + { + "source": "types/person/2.md", + "target": "_types/person.md", + "kind": "type", + "mode": "seed", + "digest": "sha256:aa81c2964285ecc69fcb96bca4b6ae80bbddeb648908d840d7c7be50df15b362" + } + ] + }, + "desired": { + "id": "mdbase.contact", + "version": "1.3.0", + "digest": "sha256:5f1e399c1802c89bd0ac14f49570ce9ce70a9cb2fe6e396551bfab2a93b970b4", + "installedBy": "dev.mdbase.editor", + "resources": [ + { + "source": "schemas/mdbase.person/1.0.0.schema.json", + "target": "schemas/mdbase.person/1.0.0.schema.json", + "kind": "schema", + "mode": "managed", + "digest": "sha256:3254afaa98c895e84fdc0aba299e67a0f3b594681c5581313ece5fcf4e2c6d93" + }, + { + "source": "contracts/mdbase.person/1.0.0.md", + "target": "_contracts/mdbase.person/1.0.0.md", + "kind": "contract", + "mode": "managed", + "digest": "sha256:e2a9435ad01a2c06c151bf6af62a9d04aa30faa8e6128e4b5fe39378c767465c" + }, + { + "source": "schemas/mdbase.person/2.0.0.schema.json", + "target": "schemas/mdbase.person/2.0.0.schema.json", + "kind": "schema", + "mode": "managed", + "digest": "sha256:f9705e5869a2814086b907323749f63dce60de535728d6725601f02901ae60ac" + }, + { + "source": "contracts/mdbase.person/2.0.0.md", + "target": "_contracts/mdbase.person/2.0.0.md", + "kind": "contract", + "mode": "managed", + "digest": "sha256:2899b149b1602bd4e51ff8e87c82dc92da3f690f4e89b1a6caf5b2b555540fe5" + }, + { + "source": "schemas/mdbase.contact/1.0.0.schema.json", + "target": "schemas/mdbase.contact/1.0.0.schema.json", + "kind": "schema", + "mode": "managed", + "digest": "sha256:bd8a011d18c71164b633f4e82cb33faa5b658cdba4f89312a34c13bf46a350d2" + }, + { + "source": "contracts/mdbase.contact/1.0.0.md", + "target": "_contracts/mdbase.contact/1.0.0.md", + "kind": "contract", + "mode": "managed", + "digest": "sha256:e3634a35737ada5eb62e3e15f1c0c9d2e27c55a3f98dfab94bbc14c40404903d" + }, + { + "source": "types/person/3.md", + "target": "_types/person.md", + "kind": "type", + "mode": "seed", + "digest": "sha256:e74ebbce6c67e195a52d005c29ad63f93ae044800a857d33fb3bbc73135adb72" + } + ] + }, + "resources": [ + { + "source": "schemas/mdbase.person/1.0.0.schema.json", + "target": "schemas/mdbase.person/1.0.0.schema.json", + "kind": "schema", + "mode": "managed", + "action": "unchanged", + "digest": "sha256:3254afaa98c895e84fdc0aba299e67a0f3b594681c5581313ece5fcf4e2c6d93", + "currentDigest": "sha256:3254afaa98c895e84fdc0aba299e67a0f3b594681c5581313ece5fcf4e2c6d93", + "installedDigest": "sha256:3254afaa98c895e84fdc0aba299e67a0f3b594681c5581313ece5fcf4e2c6d93" + }, + { + "source": "contracts/mdbase.person/1.0.0.md", + "target": "_contracts/mdbase.person/1.0.0.md", + "kind": "contract", + "mode": "managed", + "action": "unchanged", + "digest": "sha256:e2a9435ad01a2c06c151bf6af62a9d04aa30faa8e6128e4b5fe39378c767465c", + "currentDigest": "sha256:e2a9435ad01a2c06c151bf6af62a9d04aa30faa8e6128e4b5fe39378c767465c", + "installedDigest": "sha256:e2a9435ad01a2c06c151bf6af62a9d04aa30faa8e6128e4b5fe39378c767465c" + }, + { + "source": "schemas/mdbase.person/2.0.0.schema.json", + "target": "schemas/mdbase.person/2.0.0.schema.json", + "kind": "schema", + "mode": "managed", + "action": "unchanged", + "digest": "sha256:f9705e5869a2814086b907323749f63dce60de535728d6725601f02901ae60ac", + "currentDigest": "sha256:f9705e5869a2814086b907323749f63dce60de535728d6725601f02901ae60ac", + "installedDigest": "sha256:f9705e5869a2814086b907323749f63dce60de535728d6725601f02901ae60ac" + }, + { + "source": "contracts/mdbase.person/2.0.0.md", + "target": "_contracts/mdbase.person/2.0.0.md", + "kind": "contract", + "mode": "managed", + "action": "unchanged", + "digest": "sha256:2899b149b1602bd4e51ff8e87c82dc92da3f690f4e89b1a6caf5b2b555540fe5", + "currentDigest": "sha256:2899b149b1602bd4e51ff8e87c82dc92da3f690f4e89b1a6caf5b2b555540fe5", + "installedDigest": "sha256:2899b149b1602bd4e51ff8e87c82dc92da3f690f4e89b1a6caf5b2b555540fe5" + }, + { + "source": "schemas/mdbase.contact/1.0.0.schema.json", + "target": "schemas/mdbase.contact/1.0.0.schema.json", + "kind": "schema", + "mode": "managed", + "action": "unchanged", + "digest": "sha256:bd8a011d18c71164b633f4e82cb33faa5b658cdba4f89312a34c13bf46a350d2", + "currentDigest": "sha256:bd8a011d18c71164b633f4e82cb33faa5b658cdba4f89312a34c13bf46a350d2", + "installedDigest": "sha256:bd8a011d18c71164b633f4e82cb33faa5b658cdba4f89312a34c13bf46a350d2" + }, + { + "source": "contracts/mdbase.contact/1.0.0.md", + "target": "_contracts/mdbase.contact/1.0.0.md", + "kind": "contract", + "mode": "managed", + "action": "unchanged", + "digest": "sha256:e3634a35737ada5eb62e3e15f1c0c9d2e27c55a3f98dfab94bbc14c40404903d", + "currentDigest": "sha256:e3634a35737ada5eb62e3e15f1c0c9d2e27c55a3f98dfab94bbc14c40404903d", + "installedDigest": "sha256:e3634a35737ada5eb62e3e15f1c0c9d2e27c55a3f98dfab94bbc14c40404903d" + }, + { + "source": "types/person/3.md", + "target": "_types/person.md", + "kind": "type", + "mode": "seed", + "action": "preserve", + "digest": "sha256:e74ebbce6c67e195a52d005c29ad63f93ae044800a857d33fb3bbc73135adb72", + "currentDigest": "sha256:717ce99eb0dd296d96b956e907b780b8adef0d5dc896115aec213e9eb33a52ef", + "installedDigest": "sha256:aa81c2964285ecc69fcb96bca4b6ae80bbddeb648908d840d7c7be50df15b362", + "reason": "_types/person.md: no upgrade baseline applies to this type's origin" } ], "lock": { diff --git a/apps/portal/src/api.ts b/apps/portal/src/api.ts index a310dc223..e008f6743 100644 --- a/apps/portal/src/api.ts +++ b/apps/portal/src/api.ts @@ -199,6 +199,8 @@ export interface CapabilityApplicationRequirements { }; } +type TypePackUpgradeBaseline = { digest: string; document: string; version?: number }; + export interface TypePackProvision { manifest: { kind: "mdbase.type-pack"; @@ -208,7 +210,7 @@ export interface TypePackProvision { description?: string; resources: Array<{ kind: "contract" | "type" | "schema"; - upgrade_from?: { digest: string; document: string }; + upgrade_from?: TypePackUpgradeBaseline | TypePackUpgradeBaseline[]; source: string; target: string; digest: string; diff --git a/apps/portal/src/authorization-view.tsx b/apps/portal/src/authorization-view.tsx index b25ba29e9..5165e27b2 100644 --- a/apps/portal/src/authorization-view.tsx +++ b/apps/portal/src/authorization-view.tsx @@ -900,7 +900,7 @@ function SupportedApprovalForm({
{provision.manifest.name ?? provision.manifest.id} {provision.manifest.version} {provision.manifest.description ?? "Install or update the application definitions declared by this version."} - {provision.manifest.resources.some((resource) => resource.upgrade_from) && Upgrades existing starter types while keeping your customizations and notes. Conflicts, or other types still using the old contract, stop the whole upgrade.} + {provision.manifest.resources.some((resource) => resource.upgrade_from) && Upgrades starter types this application added earlier while keeping your customizations and notes. Types it cannot trace to one of those starters are left unchanged. Conflicts, or other types still using the old contract, stop the whole upgrade.}
))} diff --git a/crates/connect-protocol/src/applications.rs b/crates/connect-protocol/src/applications.rs index 2cd13ade5..c5fbd25c7 100644 --- a/crates/connect-protocol/src/applications.rs +++ b/crates/connect-protocol/src/applications.rs @@ -322,8 +322,10 @@ pub struct TypePackManifest { #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct TypePackManifestResource { + /// Seed-type upgrade baselines (one, or a non-empty list), passed through + /// verbatim so the pack digest is unchanged; mdbase validates them. #[serde(default, skip_serializing_if = "Option::is_none")] - pub upgrade_from: Option, + pub upgrade_from: Option, pub kind: String, pub mode: String, pub source: String, @@ -331,12 +333,6 @@ pub struct TypePackManifestResource { pub digest: String, } -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct TypePackSeedUpgradeBase { - pub digest: String, - pub document: String, -} - #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct TypePackSourceResource { pub source: String, diff --git a/crates/connect-protocol/src/tests.rs b/crates/connect-protocol/src/tests.rs index 7ce1d3dbd..2a0f6ba24 100644 --- a/crates/connect-protocol/src/tests.rs +++ b/crates/connect-protocol/src/tests.rs @@ -515,6 +515,40 @@ fn contract_setup_choices_have_an_explicit_discriminated_wire_shape() { ); } +#[test] +fn type_pack_seed_upgrade_baselines_round_trip_verbatim() { + let baseline = |version: u64| { + serde_json::json!({ + "digest": format!("sha256:{}", version.to_string().repeat(64)), + "document": format!("---\nkind: mdbase.type\nname: task\nversion: {version}\n---\n"), + "version": version + }) + }; + for upgrade_from in [baseline(2), serde_json::json!([baseline(1), baseline(2)])] { + let provision = serde_json::json!({ + "manifest": { + "kind": "mdbase.type-pack", + "id": "example.tasks", + "version": "1.0.0", + "resources": [{ + "upgrade_from": upgrade_from, + "kind": "type", + "mode": "seed", + "source": "types/task.md", + "target": "_types/task.md", + "digest": format!("sha256:{}", "3".repeat(64)) + }] + }, + "resources": [{ "source": "types/task.md", "document": "---\n---\n" }], + "provides": [] + }); + assert_schema("/$defs/typePackProvision", provision.clone()); + let parsed: TypePackProvision = serde_json::from_value(provision.clone()).unwrap(); + // The pack digest covers the manifest, so either form must survive unchanged. + assert_eq!(serde_json::to_value(parsed).unwrap(), provision); + } +} + #[test] fn copied_collection_registration_has_an_explicit_wire_command() { let request = ControlRequest { diff --git a/docs/portable-people.md b/docs/portable-people.md index bc7afe3a3..f4b551ad3 100644 --- a/docs/portable-people.md +++ b/docs/portable-people.md @@ -278,10 +278,14 @@ and Person types/notes remain untouched; old pack artifacts retain their exact bytes. A redundant type chooser is hidden when only one compatible type exists. The guided flow permits additions, preservation of existing seeds, ownership of -identical existing bytes, and the upgrade of a seed type whose installed digest -is the provision's declared `upgrade_from` baseline. Conflicts, downgrades, -deletions, managed-resource updates, any other seed update, or an incompatible -existing Person seed stop for review in Types. Assessment/apply +identical existing bytes, and the upgrade of a seed type the engine reports as +upgraded from one of the provision's declared `upgrade_from` baselines +(`upgradeBaseline`). Conflicts, downgrades, deletions, managed-resource +updates, any other seed update, or an incompatible existing Person seed stop +for review in Types. Under mdbase-spec 05A the engine keeps an edited Person +type it cannot trace to a declared baseline (for example, under a lock written +before seeds recorded `origin_digest`) and reports a `reason`; the review then +says **Person type kept as it is** instead of offering the update again. Assessment/apply reuse the existing atomic, digest-guarded engine path, with no auto-adoption of changed files, record migration, permission changes, or automatic retry. After approval the collection description refreshes and creation opens directly. diff --git a/packages/client/src/collection-client.ts b/packages/client/src/collection-client.ts index b004f6241..7c90e710b 100644 --- a/packages/client/src/collection-client.ts +++ b/packages/client/src/collection-client.ts @@ -836,6 +836,7 @@ function wireTypePackResource(value: import("@mdbase-dev/connect-protocol").Type ...(value.current_digest ? { currentDigest: value.current_digest } : {}), ...(value.installed_digest ? { installedDigest: value.installed_digest } : {}), ...(value.adopted_from_digest ? { adoptedFromDigest: value.adopted_from_digest } : {}), + ...(value.upgrade_baseline ? { upgradeBaseline: value.upgrade_baseline } : {}), ...(value.reason ? { reason: value.reason } : {}) }; } @@ -852,7 +853,8 @@ function wireTypePackReceipt(value: import("@mdbase-dev/connect-protocol").TypeP kind: resource.kind, mode: resource.mode, digest: resource.digest, - ...(resource.adopted_from_digest ? { adoptedFromDigest: resource.adopted_from_digest } : {}) + ...(resource.adopted_from_digest ? { adoptedFromDigest: resource.adopted_from_digest } : {}), + ...(resource.origin_digest ? { originDigest: resource.origin_digest } : {}) })) }; } diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index ec92f54cb..a08db9409 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -230,17 +230,33 @@ describe("provider-neutral collection client", () => { version: "1.0.0", digest: `sha256:${"2".repeat(64)}`, installed_by: "dev.mdbase.tests", - resources: [] + resources: [{ + source: "types/task.md", + target: "_types/task.md", + kind: "type", + mode: "seed", + digest: `sha256:${"1".repeat(64)}`, + origin_digest: `sha256:${"1".repeat(64)}` + }] }; return { valid: true, result: operation === "assess_type_pack" || operation === "apply_type_pack" ? { - status: "install", + status: "upgrade", applicable: true, assessment_digest: `sha256:${"3".repeat(64)}`, desired: receipt, - resources: [], + resources: [{ + source: "types/task.md", + target: "_types/task.md", + kind: "type", + mode: "seed", + action: "update", + digest: `sha256:${"5".repeat(64)}`, + current_digest: `sha256:${"6".repeat(64)}`, + upgrade_baseline: { digest: `sha256:${"7".repeat(64)}`, version: 2 } + }], lock: { target: "mdbase.lock.yaml", action: "create", digest: `sha256:${"4".repeat(64)}` }, contract_setups: { choices: [], resources: [] }, ...(operation === "apply_type_pack" ? { receipt, cleanup_deferred: false } : {}) @@ -280,6 +296,17 @@ describe("provider-neutral collection client", () => { provision, installedBy: "dev.mdbase.tests" })); + expect(assessment.resources[0]).toEqual({ + source: "types/task.md", + target: "_types/task.md", + kind: "type", + mode: "seed", + action: "update", + digest: `sha256:${"5".repeat(64)}`, + currentDigest: `sha256:${"6".repeat(64)}`, + upgradeBaseline: { digest: `sha256:${"7".repeat(64)}`, version: 2 } + }); + expect(assessment.desired.resources[0].originDigest).toBe(`sha256:${"1".repeat(64)}`); await client.applyTypePack({ provision, installedBy: "dev.mdbase.tests", diff --git a/packages/client/src/operation-types.ts b/packages/client/src/operation-types.ts index ece4dfb12..ee809592f 100644 --- a/packages/client/src/operation-types.ts +++ b/packages/client/src/operation-types.ts @@ -608,6 +608,8 @@ export interface TypePackResourceDiff { currentDigest?: string; installedDigest?: string; adoptedFromDigest?: string; + /** The declared baseline an engine upgraded this seed from (seed `update` only). */ + upgradeBaseline?: { digest: string; version?: number }; reason?: string; } @@ -616,7 +618,10 @@ export interface TypePackReceipt { version: string; digest: string; installedBy: string; - resources: Array>; + resources: Array & { + /** Seed only: the publisher document the target descends from, when known. */ + originDigest?: string; + }>; } export interface TypePackAssessment { diff --git a/packages/devkit/README.md b/packages/devkit/README.md index 3c8f0f73f..89b9c70a0 100644 --- a/packages/devkit/README.md +++ b/packages/devkit/README.md @@ -79,6 +79,28 @@ The helper validates each contract document and derives the exact semantic `provides` descriptors. Managed resources evolve with the pack; seed resources become collection-owned after their initial creation. +A seed type may list the exact starters previously shipped for it, so a +collection that still has any of them can be upgraded: + +```ts +{ + kind: "type", + mode: "seed", + source: "types/task/3.md", + target: "_types/task.md", + document: taskV3, + upgradeFrom: [{ document: taskV1 }, { document: taskV2 }] +} +``` + +`defineTypePack` emits these as `upgrade_from` baselines, computing each +digest and copying the `version` the document declares (an explicit `version` +must match it). It rejects baselines on anything but a seed type, duplicates, +the resource's own document, and documents of a different type kind or name, +using the same validator as application manifests. An unedited starter is +replaced exactly; an edited one is merged only against the baseline the +collection's lock records as its origin, and is otherwise left unchanged. + Native applications may add a reverse-domain private-use callback scheme that matches the v1 manifest's application ID, such as `example.tasks.desktop://auth/mdbase/callback` for diff --git a/packages/devkit/src/index.test.ts b/packages/devkit/src/index.test.ts index 1ba1ce567..fe1e87d56 100644 --- a/packages/devkit/src/index.test.ts +++ b/packages/devkit/src/index.test.ts @@ -359,6 +359,47 @@ describe("canonical developer validation", () => { })).toThrow("contract resources must be managed"); }); + it("pins every earlier seed type starter as an upgrade baseline", () => { + const starter = (version: number, extra = "") => `---\nkind: mdbase.type\nname: task\nversion: ${version}\n${extra}---\n`; + const digest = (document: string) => `sha256:${createHash("sha256").update(document).digest("hex")}`; + const seed = (upgradeFrom: Array<{ document: string; version?: number }>, kind: "type" | "schema" = "type") => ({ + id: "example.tasks", + version: "1.2.0", + resources: [{ kind, mode: "seed" as const, source: "_types/task.md", document: starter(3), upgradeFrom }] + }); + const unversioned = "---\nkind: mdbase.type\nname: task\n---\n"; + const pack = defineTypePack(seed([{ document: unversioned }, { document: starter(2) }])); + expect(pack.manifest.resources[0]).toEqual({ + upgrade_from: [ + { digest: digest(unversioned), document: unversioned }, + { digest: digest(starter(2)), document: starter(2), version: 2 } + ], + kind: "type", + mode: "seed", + source: "_types/task.md", + target: "_types/task.md", + digest: digest(starter(3)) + }); + expect(pack.resources).toEqual([{ source: "_types/task.md", document: starter(3) }]); + + const path = "/manifest/resources/0/upgrade_from"; + const rejected = (definition: ReturnType) => { + try { + defineTypePack(definition); + } catch (error) { + expect(error).toBeInstanceOf(TypePackDefinitionError); + return (error as TypePackDefinitionError).issues.map(({ path }) => path); + } + throw new Error("expected the type pack to be rejected"); + }; + expect(rejected(seed([{ document: starter(1) }, { document: starter(1) }]))).toEqual([`${path}/1/digest`]); + expect(rejected(seed([{ document: starter(1) }, { document: starter(3) }]))).toEqual([`${path}/1/digest`]); + expect(rejected(seed([{ document: starter(2), version: 1 }]))).toEqual([`${path}/0/version`]); + expect(rejected(seed([{ document: starter(2).replace("name: task", "name: other") }]))).toEqual([`${path}/0/document`]); + expect(rejected(seed([{ document: starter(2) }], "schema"))).toEqual([path]); + expect(() => defineTypePack(seed([]))).toThrow(TypePackDefinitionError); + }); + it("validates addressable wire definitions", () => { expect(validateProtocolValue({ valid: true, result: {}, diagnostics: [] }, "operationEnvelope").valid) .toBe(true); diff --git a/packages/devkit/src/index.ts b/packages/devkit/src/index.ts index ff6108b87..258e22014 100644 --- a/packages/devkit/src/index.ts +++ b/packages/devkit/src/index.ts @@ -21,7 +21,8 @@ import { validateAppManifest, type ManifestValidationIssue, type ManifestValidationOptions, - type ManifestValidationResult + type ManifestValidationResult, + validateTypePackProvision } from "@mdbase-dev/connect-protocol/manifest"; import connectProblemSchema from "@mdbase-dev/connect-protocol/schemas/connect-problem.v1.schema.json" with { type: "json" }; import dataContractSchema from "@mdbase-dev/connect-protocol/schemas/data-contract.schema.json" with { type: "json" }; @@ -111,6 +112,12 @@ export interface TypePackResourceInput { target?: string; /** Exact UTF-8 resource document included in the provision. */ document: string; + /** + * Seed types only: the exact starters previously shipped for this resource + * that a collection may be upgraded from, oldest first. Digests are computed + * from each document, and `version` defaults to the one the document declares. + */ + upgradeFrom?: Array<{ document: string; version?: number }>; } export interface TypePackDefinition { @@ -151,12 +158,13 @@ export function defineTypePack(definition: TypePackDefinition): TypePackProvisio version: definition.version, ...(definition.name ? { name: definition.name } : {}), ...(definition.description ? { description: definition.description } : {}), - resources: definition.resources.map(({ kind, mode, source, target = source, document }) => ({ + resources: definition.resources.map(({ kind, mode, source, target = source, document, upgradeFrom }) => ({ + ...(upgradeFrom ? { upgrade_from: upgradeFrom.map(upgradeBaseline) } : {}), kind, mode, source, target, - digest: `sha256:${createHash("sha256").update(document).digest("hex")}` + digest: sha256(document) })) }, resources: definition.resources.map(({ source, document }) => ({ source, document })), @@ -164,9 +172,29 @@ export function defineTypePack(definition: TypePackDefinition): TypePackProvisio }; const result = validateProtocolValue(provision, "typePackProvision"); if (!result.valid) throw new TypePackDefinitionError(result.issues); + const issues = validateTypePackProvision(provision); + if (issues.length > 0) throw new TypePackDefinitionError(issues); return provision; } +function upgradeBaseline({ document, version }: { document: string; version?: number }) { + const declared = version ?? declaredTypeVersion(document); + return { digest: sha256(document), document, ...(declared === undefined ? {} : { version: declared }) }; +} + +/** The positive integer `version` a type document declares, if any. */ +function declaredTypeVersion(document: string): number | undefined { + const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(document)?.[1]; + const parsed = frontmatter === undefined ? undefined : parseDocument(frontmatter, { uniqueKeys: true }); + if (!parsed || parsed.errors.length > 0) return undefined; + const version = (parsed.toJS({ maxAliasCount: 0 }) as { version?: unknown } | null)?.version; + return Number.isInteger(version) && Number(version) > 0 ? Number(version) : undefined; +} + +function sha256(document: string): string { + return `sha256:${createHash("sha256").update(document).digest("hex")}`; +} + function exactContractReferenceFromDocument( document: string, resourceIndex: number diff --git a/packages/protocol/package.json b/packages/protocol/package.json index b3c89a5eb..b80292795 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -33,7 +33,8 @@ }, "dependencies": { "ajv": "^8.20.0", - "ajv-formats": "^3.0.1" + "ajv-formats": "^3.0.1", + "yaml": "^2.9.1" }, "devDependencies": { "@types/node": "^24.0.0", diff --git a/packages/protocol/schemas/connect-protocol.v1.schema.json b/packages/protocol/schemas/connect-protocol.v1.schema.json index 875f30af4..4659ad142 100644 --- a/packages/protocol/schemas/connect-protocol.v1.schema.json +++ b/packages/protocol/schemas/connect-protocol.v1.schema.json @@ -410,11 +410,27 @@ "properties": { "kind": { "enum": ["contract", "type", "schema"] }, "mode": { "enum": ["managed", "seed"] }, + "upgrade_from": { + "oneOf": [ + { "$ref": "#/$defs/typePackUpgradeBaseline" }, + { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/typePackUpgradeBaseline" } } + ] + }, "source": { "type": "string", "minLength": 1, "maxLength": 500 }, "target": { "type": "string", "minLength": 1, "maxLength": 500 }, "digest": { "$ref": "#/$defs/digest" } } }, + "typePackUpgradeBaseline": { + "type": "object", + "additionalProperties": false, + "required": ["digest", "document"], + "properties": { + "digest": { "$ref": "#/$defs/digest" }, + "document": { "type": "string", "maxLength": 262144 }, + "version": { "type": "integer", "minimum": 1 } + } + }, "typePackSourceResource": { "type": "object", "additionalProperties": false, diff --git a/packages/protocol/schemas/mdbase-app.schema.json b/packages/protocol/schemas/mdbase-app.schema.json index 997e49c66..8b40da6bc 100644 --- a/packages/protocol/schemas/mdbase-app.schema.json +++ b/packages/protocol/schemas/mdbase-app.schema.json @@ -319,19 +319,26 @@ "kind": { "enum": ["contract", "type", "schema"] }, "mode": { "enum": ["managed", "seed"] }, "upgrade_from": { - "type": "object", - "additionalProperties": false, - "required": ["digest", "document"], - "properties": { - "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, - "document": { "type": "string", "maxLength": 262144 } - } + "oneOf": [ + { "$ref": "#/$defs/typePackUpgradeBaseline" }, + { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/typePackUpgradeBaseline" } } + ] }, "source": { "type": "string", "minLength": 1, "maxLength": 240 }, "target": { "type": "string", "minLength": 1, "maxLength": 240 }, "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" } } }, + "typePackUpgradeBaseline": { + "type": "object", + "additionalProperties": false, + "required": ["digest", "document"], + "properties": { + "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, + "document": { "type": "string", "maxLength": 262144 }, + "version": { "type": "integer", "minimum": 1 } + } + }, "typePackSourceResource": { "type": "object", "additionalProperties": false, diff --git a/packages/protocol/src/manifest.ts b/packages/protocol/src/manifest.ts index 3f7e82d3d..2ef75a939 100644 --- a/packages/protocol/src/manifest.ts +++ b/packages/protocol/src/manifest.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { Ajv2020, type ErrorObject, type ValidateFunction } from "ajv/dist/2020.js"; import addFormatsModule from "ajv-formats"; +import { parseDocument } from "yaml"; import appManifestSchema from "../schemas/mdbase-app.schema.json" with { type: "json" }; import legacyAppManifestSchema from "../schemas/mdbase-app.legacy-v1.schema.json" with { type: "json" }; import type { LegacyMdbaseAppManifest } from "./index.js"; @@ -411,64 +412,118 @@ function validateProvisionRequirements(value: unknown): ManifestValidationIssue[ )); } } - const packManifest = asObject(provision.manifest); - const declaredResources = Array.isArray(packManifest.resources) - ? packManifest.resources.map(asObject) - : []; - const embeddedResources = Array.isArray(provision.resources) - ? provision.resources.map(asObject) - : []; - const embedded = new Map( - embeddedResources.map((resource) => [String(resource.source), resource.document]) - ); - if ( - embedded.size !== embeddedResources.length - || new Set(declaredResources.map((resource) => String(resource.source))).size - !== declaredResources.length - || declaredResources.length !== embeddedResources.length - ) { - issues.push(issue( - `/provisions/type_packs/${packIndex}/resources`, - "resourceSet", - "must match manifest source paths exactly" - )); + issues.push(...validateTypePackProvision(provision, `/provisions/type_packs/${packIndex}`)); + } + return issues; +} + +/** + * Validate one schema-valid type-pack provision beyond its JSON Schema: the + * embedded resources are exactly the manifest's, each digest pins its bytes, + * and every seed `upgrade_from` baseline satisfies mdbase 05A. Issue paths are + * prefixed with `path`, the provision's location in its enclosing document. + */ +export function validateTypePackProvision( + value: unknown, + path = "" +): ManifestValidationIssue[] { + const provision = asObject(value); + const manifest = asObject(provision.manifest); + const declaredResources = Array.isArray(manifest.resources) + ? manifest.resources.map(asObject) + : []; + const embeddedResources = Array.isArray(provision.resources) + ? provision.resources.map(asObject) + : []; + const embedded = new Map( + embeddedResources.map((resource) => [String(resource.source), resource.document]) + ); + if ( + embedded.size !== embeddedResources.length + || new Set(declaredResources.map((resource) => String(resource.source))).size + !== declaredResources.length + || declaredResources.length !== embeddedResources.length + ) { + return [issue(`${path}/resources`, "resourceSet", "must match manifest source paths exactly")]; + } + const issues: ManifestValidationIssue[] = []; + for (const [resourceIndex, resource] of declaredResources.entries()) { + const resourcePath = `${path}/manifest/resources/${resourceIndex}`; + const source = String(resource.source); + const document = embedded.get(source); + if (typeof document !== "string") { + issues.push(issue(`${path}/resources`, "resourceSet", `is missing manifest source ${source}`)); continue; } - for (const [resourceIndex, resource] of declaredResources.entries()) { - const source = String(resource.source); - if (resource.upgrade_from !== undefined) { - const baseline = asObject(resource.upgrade_from); - const path = `/provisions/type_packs/${packIndex}/manifest/resources/${resourceIndex}/upgrade_from`; - if (resource.kind !== "type" || resource.mode !== "seed") { - issues.push(issue(path, "seedUpgrade", "is only allowed for seed types")); - } - if (typeof baseline.document === "string" - && baseline.digest !== `sha256:${createHash("sha256").update(baseline.document).digest("hex")}`) { - issues.push(issue(`${path}/digest`, "digest", "does not match the baseline document")); - } - } - const document = embedded.get(source); - if (typeof document !== "string") { - issues.push(issue( - `/provisions/type_packs/${packIndex}/resources`, - "resourceSet", - `is missing manifest source ${source}` - )); - continue; - } - const digest = `sha256:${createHash("sha256").update(document).digest("hex")}`; - if (digest !== resource.digest) { - issues.push(issue( - `/provisions/type_packs/${packIndex}/manifest/resources/${resourceIndex}/digest`, - "digest", - "does not match the embedded document" - )); - } + if (sha256(document) !== resource.digest) { + issues.push(issue(`${resourcePath}/digest`, "digest", "does not match the embedded document")); + } + if (resource.upgrade_from !== undefined) { + issues.push(...validateSeedUpgrade(resource, document, `${resourcePath}/upgrade_from`)); + } + } + return issues; +} + +/** 05A seed-upgrade baselines: exact, distinct, earlier starters of the same type. */ +function validateSeedUpgrade( + resource: Record, + document: string, + path: string +): ManifestValidationIssue[] { + if (resource.kind !== "type" || resource.mode !== "seed") { + return [issue(path, "seedUpgrade", "is only allowed for seed types")]; + } + const desired = typeFrontmatter(document); + const listed = Array.isArray(resource.upgrade_from); + const baselines = (listed ? resource.upgrade_from as unknown[] : [resource.upgrade_from]).map(asObject); + const seen = new Set(); + const issues: ManifestValidationIssue[] = []; + for (const [index, baseline] of baselines.entries()) { + const baselinePath = listed ? `${path}/${index}` : path; + const baselineDocument = String(baseline.document); + if (baseline.digest !== sha256(baselineDocument)) { + issues.push(issue(`${baselinePath}/digest`, "digest", "does not match the baseline document")); + } else if (seen.has(baseline.digest)) { + issues.push(issue(`${baselinePath}/digest`, "uniqueBaseline", "duplicates another upgrade baseline")); + } else if (baseline.digest === resource.digest) { + issues.push(issue(`${baselinePath}/digest`, "seedUpgrade", "must not be the resource's own document")); + } + seen.add(baseline.digest); + const previous = typeFrontmatter(baselineDocument); + if (!desired || !previous || previous.kind !== desired.kind || previous.name !== desired.name) { + issues.push(issue( + `${baselinePath}/document`, + "seedUpgrade", + "must be a type document with the same kind and name as the resource" + )); + } else if (baseline.version !== undefined && previous.version !== baseline.version) { + issues.push(issue( + `${baselinePath}/version`, + "seedUpgrade", + "must equal the version the baseline document declares" + )); } } return issues; } +/** Top-level frontmatter of a type document, or undefined when it has none. */ +function typeFrontmatter(document: string): Record | undefined { + const source = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(document)?.[1]; + if (source === undefined) return undefined; + const parsed = parseDocument(source, { uniqueKeys: true }); + if (parsed.errors.length > 0) return undefined; + const value = parsed.toJS({ maxAliasCount: 0 }) as unknown; + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} + +function sha256(document: string): string { + return `sha256:${createHash("sha256").update(document).digest("hex")}`; +} + function validateConfigurationSetup(value: unknown): ManifestValidationIssue[] { const manifest = asObject(value); const requirements = asObject(manifest.requirements); diff --git a/packages/protocol/src/type-packs.ts b/packages/protocol/src/type-packs.ts index 927f8967b..2ac89b193 100644 --- a/packages/protocol/src/type-packs.ts +++ b/packages/protocol/src/type-packs.ts @@ -41,9 +41,20 @@ export const MDBASE_TIMER_FIRED_CONTRACT = { digest: "sha256:41105be7a7abf33b31ced47e1e1965242236e40ccaea286b959b0a8c591f5642", } as const satisfies ContractRequirement; +/** Exact bytes of a starter the publisher previously shipped for a seed type. */ +type TypePackUpgradeBaseline = { + digest: string; + document: string; + /** The `version` the baseline document declares, for presentation only. */ + version?: number; +}; + export interface TypePackManifestResource { - /** Explicit three-way upgrade; only valid for seed type resources. */ - upgrade_from?: { digest: string; document: string }; + /** + * Previously shipped starters this seed type may be upgraded from; only valid + * for seed type resources. A single baseline is equivalent to a one-item list. + */ + upgrade_from?: TypePackUpgradeBaseline | TypePackUpgradeBaseline[]; kind: "contract" | "type" | "schema"; mode: "managed" | "seed"; source: string; @@ -85,6 +96,8 @@ export interface TypePackResourceDiff { current_digest?: string; installed_digest?: string; adopted_from_digest?: string; + /** The declared baseline an engine upgraded this seed from (seed `update` only). */ + upgrade_baseline?: { digest: string; version?: number }; reason?: string; } @@ -93,7 +106,10 @@ export interface TypePackReceipt { version: string; digest: string; installed_by: string; - resources: Omit[]; + resources: Array & { + /** Seed only: the publisher document the target descends from, when known. */ + origin_digest?: string; + }>; } export interface TypePackAssessment { diff --git a/packages/protocol/test/manifest.test.mjs b/packages/protocol/test/manifest.test.mjs index b32c6cb89..7c636957f 100644 --- a/packages/protocol/test/manifest.test.mjs +++ b/packages/protocol/test/manifest.test.mjs @@ -79,17 +79,78 @@ test("semantic capabilities and provision ownership share one canonical validato ); }); -test("seed upgrades retain the exact reviewed baseline and reject tampering", () => { +function sha256(text) { + return `sha256:${createHash("sha256").update(text).digest("hex")}`; +} + +function starter(version, extra = "") { + const text = `---\nkind: mdbase.type\nname: scratch\nversion: ${version}\n${extra}---\n`; + return { digest: sha256(text), document: text }; +} + +function upgradeManifest(upgradeFrom) { const value = manifest(); - const resource = value.provisions.type_packs[0].manifest.resources[0]; - resource.upgrade_from = { digest, document }; + const desired = starter(3); + const pack = value.provisions.type_packs[0]; + Object.assign(pack.manifest.resources[0], { digest: desired.digest, upgrade_from: upgradeFrom }); + pack.resources[0].document = desired.document; + return value; +} + +function issuesOf(value) { + const result = validateAppManifest(value); + return result.valid ? [] : result.issues.map(({ path, keyword }) => `${keyword} ${path}`); +} + +const upgradePath = "/provisions/type_packs/0/manifest/resources/0/upgrade_from"; + +test("seed upgrades retain a single exact reviewed baseline and reject tampering", () => { + const baseline = starter(2); + const value = upgradeManifest({ ...baseline }); assert.deepEqual(validateAppManifest(value), { valid: true, issues: [] }); - assert.deepEqual(parseAppManifest(value).provisions.type_packs[0].manifest.resources[0].upgrade_from, { digest, document }); + assert.deepEqual(parseAppManifest(value).provisions.type_packs[0].manifest.resources[0].upgrade_from, baseline); + const resource = value.provisions.type_packs[0].manifest.resources[0]; resource.upgrade_from.document += "changed"; - assert.equal(validateAppManifest(value).valid, false); - resource.upgrade_from.document = document; + assert.deepEqual(issuesOf(value), [`digest ${upgradePath}/digest`]); + resource.upgrade_from.document = baseline.document; resource.mode = "managed"; - assert.equal(validateAppManifest(value).valid, false); + assert.deepEqual(issuesOf(value), [`seedUpgrade ${upgradePath}`]); +}); + +test("seed upgrades accept a non-empty list of distinct earlier starters", () => { + const list = [{ ...starter(1), version: 1 }, starter(2, "description: second\n")]; + const value = upgradeManifest(list); + assert.deepEqual(validateAppManifest(value), { valid: true, issues: [] }); + assert.deepEqual(parseAppManifest(value).provisions.type_packs[0].manifest.resources[0].upgrade_from, list); + assert.ok(issuesOf(upgradeManifest([])).some((entry) => entry.startsWith("minItems "))); +}); + +test("seed upgrade baselines are rejected with the offending baseline's path", () => { + const first = starter(1); + const second = starter(2); + const desired = starter(3); + const cases = [ + [[first, { ...second, document: `${second.document}changed` }], `digest ${upgradePath}/1/digest`], + [[first, second, first], `uniqueBaseline ${upgradePath}/2/digest`], + [[first, desired], `seedUpgrade ${upgradePath}/1/digest`], + [[first, { ...second, version: 1 }], `seedUpgrade ${upgradePath}/1/version`] + ]; + for (const [upgradeFrom, expected] of cases) { + assert.deepEqual(issuesOf(upgradeManifest(upgradeFrom)), [expected]); + } + for (const document of [ + "---\nkind: mdbase.type\nname: other\nversion: 2\n---\n", + "---\nkind: mdbase.contract\nname: scratch\nversion: 2\n---\n", + "no frontmatter\n" + ]) { + assert.deepEqual( + issuesOf(upgradeManifest([first, { digest: sha256(document), document }])), + [`seedUpgrade ${upgradePath}/1/document`] + ); + } + const managed = upgradeManifest([first]); + managed.provisions.type_packs[0].manifest.resources[0].kind = "schema"; + assert.deepEqual(issuesOf(managed), [`seedUpgrade ${upgradePath}`]); }); test("legacy contract-scoped declarations are rejected rather than widened", () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff6792992..9371489c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -387,6 +387,9 @@ importers: ajv-formats: specifier: ^3.0.1 version: 3.0.1(ajv@8.20.0) + yaml: + specifier: ^2.9.1 + version: 2.9.1 devDependencies: '@types/node': specifier: ^24.0.0 From 7e1ad325ae1603eea818f196f1dfb1230de0392a Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sat, 3 Oct 2026 08:51:51 +1000 Subject: [PATCH 2/3] Account for the shared type-pack provision validator in the reviewed export budget validateTypePackProvision lets devkit reuse the protocol's 05A validation instead of keeping a second copy; it is the one added export. --- config/architecture-budgets.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/architecture-budgets.json b/config/architecture-budgets.json index 1e4dfb736..9ae8b67ee 100644 --- a/config/architecture-budgets.json +++ b/config/architecture-budgets.json @@ -48,7 +48,7 @@ "relativeImports": 1630, "workspacePackages": 25, "rustPublicDeclarations": 3368, - "typeScriptExportDeclarations": 2691, + "typeScriptExportDeclarations": 2692, "mdbaseCollectionReferences": 17, "typedCollectionReferences": 1 } From 4797ba085423b1a74be5cfa76cdab7cc68cb7545 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sat, 3 Oct 2026 09:09:23 +1000 Subject: [PATCH 3/3] Pin mdbase-rs 056db73 for seed upgrades from listed baselines The engine now plans seed upgrades from any listed baseline chosen by the lock's origin_digest and reports upgrade_baseline, which the Editor gate in this PR relies on. --- CHANGELOG.md | 3 +++ deploy/docker/mdbase-rs-revision | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f3d6cd85..dfbf3b8d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Pin mdbase-rs 056db73 (callumalpass/mdbase-rs#108): engines plan seed upgrades + from any listed baseline chosen by the lock's `origin_digest`, and report + `upgrade_baseline`, which Editor's guided setup relies on. - Seed type upgrades follow mdbase-spec 05A's upgrade baselines (mdbase-dev/mdbase-spec#59). A type pack's seed `upgrade_from` may now be one baseline or a non-empty list of `{ digest, document, version? }`; manifest diff --git a/deploy/docker/mdbase-rs-revision b/deploy/docker/mdbase-rs-revision index dcc5c8133..32881b8aa 100644 --- a/deploy/docker/mdbase-rs-revision +++ b/deploy/docker/mdbase-rs-revision @@ -1 +1 @@ -47890a9d4bd615ba66a9acfe235afe38f4ea8bfa +056db7334a0f49f10373225ae54c1abaedc14e37