Bundles did not record that a template was a derived slot template, so the
importer re-created every slot as a BASE template named after the slot.
- Export: TemplateDto gains an additive, null-omitted `Derivation`
(owner path + base path by stable names); a derived template carries only
its own rows (overrides/additions, not inherited placeholders). The closure
pulls in each derived template's base/owner; base orders first.
schemaVersion 1.1 -> 1.2, bundleFormatVersion stays 1.
- Import: base templates go through the ordinary passes; derived templates are
ordered topologically (DerivedTemplatePlan; cycle / duplicate / malformed =
preview Blocker + SemanticValidationException before anything is staged),
their owner and base resolved by path (#100 root rule), and the slot is
created with TemplateService.AddCompositionAsync, synced in place when it
already exists with the same base, or deleted+recomposed when the base
differs; own rows applied as overrides, then ResyncInheritedMembersAsync.
Missing owner/base fails that record (BundleImportDerivedTemplateUnresolved
audit + ImportResult warning), never creating a base in its place.
- Preview/diff: derived rows are keyed by qualified name and compared against
the target slot at the same owner path (own rows + base), so an unchanged
slot is Identical and a same-named base template is never matched.
- Pre-#118 bundles import exactly as before (new fixture
pre-118-derived-plaintext.scadabundle, written by a13aabc7). The field is in
content, pinned by the signed manifest's contentHash.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
Exports can now carry a detached ECDSA P-256 signature over the exact
manifest.json bytes in a new optional manifest.sig zip entry. The manifest
already pins the content hash, so a verified signature covers the whole
bundle, including the plaintext source environment the confirm step relies
on. Off by default (ScadaBridge:Transport:Signing): with the defaults,
exports are unsigned and imports accept signed and unsigned bundles.
- The bundle format does not change: bundleFormatVersion stays 1,
manifest.json and the AES-GCM associated data are untouched, and older
importers open entries by name and ignore manifest.sig. Three entries fit
within the default MaxBundleEntryCount of 4.
- Import policy runs after the content-hash check and before any passphrase
work. A signature that fails to verify against a trusted key is always
refused. RequireSignedBundles additionally refuses unsigned bundles and
bundles signed by an untrusted key. A refusal is BundleSignatureException
(Commons), shown on the UI upload step and on CLI preview/import.
- The signature status, key id and signer are recorded on BundleSession and
shown in the import wizard. They are also returned on PreviewBundleResult
and recorded on the BundleImported audit row. BundleExported records
Signed/SigningKeyId.
- Key material is pluggable. IBundleSigningKeySource and IBundleTrustStore
are TryAdd'd; the defaults read Signing:PrivateKey (meant to be a
${secret:} reference) and Signing:TrustedKeys. Key ids are SPKI
fingerprints. Startup validation rejects unparseable or non-P-256 keys.
- Compatibility proof: the checked-in fixtures pre-signing-plaintext and
pre-signing-encrypted were exported by origin/main 3ff463b6, before this
change, and are imported end to end with signing off and with signing
configured.
The design leaves key-per-env vs per-org, trust distribution and rotation,
and the production default for RequireSignedBundles open. These are listed
in Component-Transport.md.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV