From c8ece954e6de33f073cb2d1feacd1f366f6eba66 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 18 Sep 2026 21:38:56 +0700 Subject: [PATCH 1/2] feat(kotlin-sdk): show and lock immutable document properties in the example app Parity for the protocol v14 `immutable` / `immutableAllowSetting` document-type keywords (#4815), the same treatment `indexOnly` got: - ContractJson: `documentTypeImmutability` reads both lists off the raw schema (an allowance outside `immutable` is dropped, DPP refuses such contracts), and `immutablePropertyLock` resolves a property to EDITABLE / FROZEN / SETTABLE_ONCE against the stored document. - DocumentTypeDetailsScreen lists the frozen and settable-once properties under Document Settings. - DocumentActionsScreen's replace form disables frozen fields and captions settable-once ones, so a user is not charged for a replace consensus is guaranteed to reject (code 40128). - Unit tests for the helpers. Co-Authored-By: Claude Fable 5.1 --- .../example/ui/contracts/ContractJson.kt | 68 ++++++++++ .../ui/contracts/DocumentActionsScreen.kt | 29 ++++- .../ui/contracts/DocumentTypeDetailsScreen.kt | 13 ++ .../contracts/DocumentTypeImmutabilityTest.kt | 117 ++++++++++++++++++ 4 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/DocumentTypeImmutabilityTest.kt diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/ContractJson.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/ContractJson.kt index c4b4d685da8..5d28ec01338 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/ContractJson.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/ContractJson.kt @@ -93,6 +93,74 @@ internal fun documentTypeCapabilities( ?: config?.boolField("documentsCanBeDeletedContractDefault") ?: true, ) +/** + * The per-property immutability a mutable document type declares (protocol + * version 14): [immutable] lists the top-level properties frozen at document + * creation, [allowSetting] the subset a replace may still set while the + * stored document has no value for them (frozen from then on). Consensus + * enforces both on every replace (code 40128), so the UI locks the fields + * up front instead of paying for a guaranteed rejection. + */ +internal data class DocumentTypeImmutability( + val immutable: Set, + val allowSetting: Set, +) { + val isEmpty: Boolean get() = immutable.isEmpty() + + companion object { + val NONE = DocumentTypeImmutability(emptySet(), emptySet()) + } +} + +/** + * Read the `immutable` and `immutableAllowSetting` keywords off a document + * type schema. Non-string entries are ignored, and an `immutableAllowSetting` + * entry outside `immutable` is dropped: DPP refuses such a contract at + * registration, so it can only appear in hand-edited JSON. + */ +internal fun documentTypeImmutability(schema: JsonObject?): DocumentTypeImmutability { + val immutable = schema?.arrayField("immutable") + ?.mapNotNull { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content } + ?.toSet() + .orEmpty() + val allowSetting = schema?.arrayField("immutableAllowSetting") + ?.mapNotNull { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content } + ?.filter { it in immutable } + ?.toSet() + .orEmpty() + return DocumentTypeImmutability(immutable, allowSetting) +} + +/** How the replace form treats one property of a document. */ +internal enum class ImmutablePropertyLock { + /** Not immutable: editable as usual. */ + EDITABLE, + + /** Frozen: any change, addition or removal is rejected by consensus. */ + FROZEN, + + /** + * Listed under `immutableAllowSetting` and absent from the stored + * document: may be set exactly once, after which it is frozen. + */ + SETTABLE_ONCE, +} + +/** + * The lock state of [property] for a replace of a document that + * [hasStoredValue] for it. A settable-once property that already has a value + * is frozen: the allowance covers only the transition from absent to present. + */ +internal fun immutablePropertyLock( + property: String, + immutability: DocumentTypeImmutability, + hasStoredValue: Boolean, +): ImmutablePropertyLock = when { + property !in immutability.immutable -> ImmutablePropertyLock.EDITABLE + property in immutability.allowSetting && !hasStoredValue -> ImmutablePropertyLock.SETTABLE_ONCE + else -> ImmutablePropertyLock.FROZEN +} + /** * Human-readable descriptors for an index's protocol-v14 count / sum / * ranking axes, in display order. Empty for a pre-v14 index. `countable` diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentActionsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentActionsScreen.kt index 47d7c118bcd..1629dd366a5 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentActionsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentActionsScreen.kt @@ -142,6 +142,7 @@ fun DocumentActionsScreen( } val capabilities = documentTypeCapabilities(schema, contractConfig) val documentsMutable = capabilities.documentsMutable + val immutability = remember(schema) { documentTypeImmutability(schema) } val canBeDeleted = capabilities.canBeDeleted // Default acting identity to the on-chain owner when it's one of ours, @@ -315,16 +316,42 @@ fun DocumentActionsScreen( ) } else { sortedProps.forEach { (name, propEl) -> + // Frozen properties are locked up front: consensus + // rejects a change, addition or removal of one as a + // PAID invalid transition (code 40128). A settable-once + // property stays open only while the stored document + // has no value for it. + val lock = immutablePropertyLock( + property = name, + immutability = immutability, + hasStoredValue = probedDoc?.fields?.containsKey(name) == true, + ) DocumentPropertyField( name = name, prop = propEl as? JsonObject ?: JsonObject(emptyMap()), isRequired = name in required, - enabled = !isSubmitting, + enabled = !isSubmitting && lock != ImmutablePropertyLock.FROZEN, textValues = textValues, boolValues = boolValues, touchedBools = touchedBools, tagPrefix = "replaceDocument.field", ) + when (lock) { + ImmutablePropertyLock.FROZEN -> Text( + "Immutable: frozen at creation, a replace cannot change it.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag("replaceDocument.immutable.$name"), + ) + ImmutablePropertyLock.SETTABLE_ONCE -> Text( + "Immutable once set: this document has no value yet, so it " + + "can be set exactly once.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.testTag("replaceDocument.settableOnce.$name"), + ) + ImmutablePropertyLock.EDITABLE -> Unit + } } } replaceSuccess?.let { diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentTypeDetailsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentTypeDetailsScreen.kt index e14a093b072..fa347f08927 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentTypeDetailsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentTypeDetailsScreen.kt @@ -155,6 +155,19 @@ fun DocumentTypeDetailsScreen( "Mutable", if (capabilities.documentsMutable) "Yes" else "No", ) + val immutability = documentTypeImmutability(schema) + if (!immutability.isEmpty) { + LabeledContent( + "Immutable Properties", + immutability.immutable.sorted().joinToString(", "), + ) + if (immutability.allowSetting.isNotEmpty()) { + LabeledContent( + "Settable Once While Absent", + immutability.allowSetting.sorted().joinToString(", "), + ) + } + } LabeledContent( "Can Be Deleted", if (capabilities.canBeDeleted) "Yes" else "No", diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/DocumentTypeImmutabilityTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/DocumentTypeImmutabilityTest.kt new file mode 100644 index 00000000000..3882b8e1049 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/DocumentTypeImmutabilityTest.kt @@ -0,0 +1,117 @@ +package org.dashfoundation.example.ui.contracts + +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.add +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pins the protocol-v14 `immutable` / `immutableAllowSetting` helpers + * ([documentTypeImmutability], [immutablePropertyLock]) behind the + * DocumentTypeDetailsScreen labels and the DocumentActionsScreen replace-form + * locks. Consensus rejects a replace that touches a frozen property as a PAID + * invalid transition (code 40128), so the form must lock exactly the + * properties DPP freezes: every `immutable` entry, except an + * `immutableAllowSetting` entry on a document that has no value for it yet. + */ +class DocumentTypeImmutabilityTest { + + private val schema = buildJsonObject { + put("documentsMutable", true) + put( + "immutable", + buildJsonArray { + add("mood") + add("author") + }, + ) + put("immutableAllowSetting", buildJsonArray { add("mood") }) + } + + @Test + fun parsesBothLists() { + val immutability = documentTypeImmutability(schema) + assertEquals(setOf("author", "mood"), immutability.immutable) + assertEquals(setOf("mood"), immutability.allowSetting) + } + + @Test + fun absentKeywordsFreezeNothing() { + val immutability = documentTypeImmutability(buildJsonObject { put("type", "object") }) + assertTrue(immutability.isEmpty) + assertTrue(immutability.allowSetting.isEmpty()) + assertTrue(documentTypeImmutability(null).isEmpty) + } + + @Test + fun allowSettingOutsideImmutableIsDropped() { + // DPP refuses such a contract at registration; hand-edited JSON is the + // only way to see it, and the allowance must not unlock anything. + val immutability = documentTypeImmutability( + buildJsonObject { + put("immutable", buildJsonArray { add("author") }) + put("immutableAllowSetting", buildJsonArray { add("body") }) + }, + ) + assertEquals(setOf("author"), immutability.immutable) + assertTrue(immutability.allowSetting.isEmpty()) + } + + @Test + fun nonStringEntriesAreIgnored() { + val immutability = documentTypeImmutability( + buildJsonObject { + put( + "immutable", + buildJsonArray { + add("author") + add(7) + }, + ) + }, + ) + assertEquals(setOf("author"), immutability.immutable) + } + + @Test + fun frozenPropertyIsLockedRegardlessOfStoredValue() { + val immutability = documentTypeImmutability(schema) + assertEquals( + ImmutablePropertyLock.FROZEN, + immutablePropertyLock("author", immutability, hasStoredValue = true), + ) + assertEquals( + ImmutablePropertyLock.FROZEN, + immutablePropertyLock("author", immutability, hasStoredValue = false), + ) + } + + @Test + fun settableOncePropertyOpensOnlyWhileAbsent() { + val immutability = documentTypeImmutability(schema) + assertEquals( + ImmutablePropertyLock.SETTABLE_ONCE, + immutablePropertyLock("mood", immutability, hasStoredValue = false), + ) + assertEquals( + ImmutablePropertyLock.FROZEN, + immutablePropertyLock("mood", immutability, hasStoredValue = true), + ) + } + + @Test + fun otherPropertiesStayEditable() { + val immutability = documentTypeImmutability(schema) + assertEquals( + ImmutablePropertyLock.EDITABLE, + immutablePropertyLock("body", immutability, hasStoredValue = true), + ) + assertEquals( + ImmutablePropertyLock.EDITABLE, + immutablePropertyLock("body", DocumentTypeImmutability.NONE, hasStoredValue = false), + ) + } +} From 3ec195c8bca69232ba2b9b80a62cdd96f24d6d3c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 18 Sep 2026 21:39:47 +0700 Subject: [PATCH 2/2] feat(swift-sdk): show and lock immutable document properties in the example app Swift half of the protocol v14 `immutable` / `immutableAllowSetting` parity (#4815), mirroring the Kotlin commit: - New `DocumentTypeImmutability` value type (deduped, sorted lists + `lockState(for:hasStoredValue:)` -> editable / frozen / settableOnce), exposed on `PersistentDocumentType` as computed accessors read off the persisted `schemaJSON`. No new stored column: one would move the entity hash and cost a DashSchema version plus a fixture store, which a display-only keyword does not justify. - DocumentTypeDetailsView and StorageRecordDetailViews list the frozen and settable-once properties. - DocumentFieldsView badges each property and disables frozen fields on the replace flow (TransitionDetailView passes the type's immutability only for documentReplace); ReplaceDocumentView shows which frozen properties the stored document already has a value for. - 9 unit tests; migration tests unchanged and passing. Co-Authored-By: Claude Fable 5.1 --- .../Core/Utils/DataContractParser.swift | 6 + .../Core/Utils/DocumentTypeImmutability.swift | 82 +++++++++ .../Models/PersistentDocumentType.swift | 26 +++ .../Views/DocumentFieldsView.swift | 43 +++++ .../Views/DocumentTypeDetailsView.swift | 22 +++ .../SwiftExampleApp/Views/DocumentsView.swift | 46 +++++ .../Views/StorageRecordDetailViews.swift | 16 ++ .../Views/TransitionDetailView.swift | 12 +- .../DocumentTypeImmutabilityTests.swift | 167 ++++++++++++++++++ 9 files changed, 419 insertions(+), 1 deletion(-) create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentTypeImmutability.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentTypeImmutabilityTests.swift diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift index 7d5e937a047..322aa64767d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift @@ -169,6 +169,12 @@ public struct DataContractParser { docType.indexOnly = indexOnly } + // The protocol-version-14 `immutable` / `immutableAllowSetting` + // keywords need no column of their own: `schemaJSON` above is the + // whole `typeDict`, so they are persisted with it and read back + // through `PersistentDocumentType.immutability`. Keep that true + // when touching the schema stored here. + // The actual field name is just "canBeDeleted" not "documentsCanBeDeleted" if let canDelete = typeDict["canBeDeleted"] as? Bool { docType.documentsCanBeDeleted = canDelete diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentTypeImmutability.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentTypeImmutability.swift new file mode 100644 index 00000000000..db6837d898d --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentTypeImmutability.swift @@ -0,0 +1,82 @@ +import Foundation + +/// The per-property immutability a mutable document type declares +/// (meta-schema v3, protocol version 14). +/// +/// `immutableProperties` is the type's `immutable` keyword: top-level +/// properties frozen at document creation while the rest of the document +/// stays replaceable. `immutableAllowSetting` is its `immutableAllowSetting` +/// keyword: the subset a replace may still SET while the stored document has +/// no value for the property, frozen from then on (it can then neither change +/// nor be removed). +/// +/// Both lists name TOP-LEVEL properties only: listing an object freezes it +/// whole, nested values included. Consensus enforces both on every replace +/// (`DocumentImmutablePropertyChangedError`, state error code 40128), and a +/// rejected state transition is still paid for, so the UI locks the fields up +/// front rather than letting a user build a transition that cannot pass. +public struct DocumentTypeImmutability: Equatable, Sendable { + /// Nothing frozen: every pre-v14 document type, and every v14 type that + /// declares no `immutable` list. + public static let none = DocumentTypeImmutability(immutable: [], allowSetting: []) + + /// The `immutable` keyword, deduplicated and sorted for display. + public let immutableProperties: [String] + + /// The `immutableAllowSetting` keyword, deduplicated and sorted, kept + /// exactly as authored rather than validated: DPP refuses a contract whose + /// allowance names a property outside `immutable`, so such an entry can + /// only reach a client through hand-edited JSON. `lockState(for:hasStoredValue:)` + /// ignores it instead of unlocking anything. + public let immutableAllowSetting: [String] + + /// True when the type freezes no property at all. + public var isEmpty: Bool { immutableProperties.isEmpty } + + public init(immutable: [String], allowSetting: [String]) { + self.immutableProperties = Set(immutable).sorted() + self.immutableAllowSetting = Set(allowSetting).sorted() + } + + /// Read both keywords off a document type's schema dictionary, which is + /// the whole type object as authored in the contract. A missing keyword + /// freezes nothing, and non-string entries are ignored. + public init(documentTypeSchema: [String: Any]?) { + self.init( + immutable: DocumentTypeImmutability.names( + documentTypeSchema?["immutable"]), + allowSetting: DocumentTypeImmutability.names( + documentTypeSchema?["immutableAllowSetting"]) + ) + } + + private static func names(_ value: Any?) -> [String] { + guard let entries = value as? [Any] else { return [] } + return entries.compactMap { $0 as? String } + } + + /// How a replace form must treat one property of a document. + public enum PropertyLock: Equatable, Sendable { + /// Not immutable: editable as usual. + case editable + + /// Frozen: any change, addition or removal is rejected by consensus. + case frozen + + /// Listed under `immutableAllowSetting` and absent from the stored + /// document: may be set exactly once, and is frozen from then on. + case settableOnce + } + + /// The lock state of `property` for a replace of a document that + /// `hasStoredValue` for it. A settable-once property that already has a + /// value is frozen: the allowance covers only the step from absent to + /// present. + public func lockState(for property: String, hasStoredValue: Bool) -> PropertyLock { + guard immutableProperties.contains(property) else { return .editable } + if !hasStoredValue && immutableAllowSetting.contains(property) { + return .settableOnce + } + return .frozen + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift index fa454060f22..08277a8bb37 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift @@ -102,6 +102,32 @@ extension PersistentDocumentType { return try? JSONSerialization.jsonObject(with: data, options: []) as? [String] } + /// The type's `immutable` / `immutableAllowSetting` keywords (protocol + /// version 14), read off the persisted schema. + /// + /// Derived rather than stored in columns of its own: `schemaJSON` already + /// holds the whole document type dictionary as authored, so the keywords + /// are persisted with every contract the parser writes, and a new stored + /// property would move this model's entity hash. That costs a schema + /// version and a fixture store (see `DashModelContainer.modelTypes` and + /// `DashModelMigrationTests`), which a display-only keyword does not + /// justify. `indexOnly` predates that discipline and kept its column. + public var immutability: DocumentTypeImmutability { + DocumentTypeImmutability(documentTypeSchema: schema) + } + + /// Top-level properties frozen at document creation, sorted. Empty when + /// the type declares no `immutable` list. + public var immutableProperties: [String] { + immutability.immutableProperties + } + + /// The `immutable` entries a replace may still set while the stored + /// document has no value for them, sorted. Empty when none are declared. + public var immutableAllowSetting: [String] { + immutability.immutableAllowSetting + } + public var documentCount: Int { documents?.count ?? 0 } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentFieldsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentFieldsView.swift index f64283c54bf..59f4c1f899a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentFieldsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentFieldsView.swift @@ -6,6 +6,18 @@ struct DocumentFieldsView: View { let documentType: PersistentDocumentType @Binding var fieldValues: [String: Any] + /// The per-property freeze a REPLACE must respect (protocol version 14). + /// `.none` for a create, which writes every property for the first time + /// and is never refused on these grounds. + var immutability: DocumentTypeImmutability = .none + + /// Top-level property names the document being replaced already has a + /// value for. Empty when the form cannot see the stored document, which + /// leaves a settable-once property editable: setting one that is in fact + /// already present is the only case consensus would then refuse, and the + /// caption says so. + var storedPropertyNames: Set = [] + @State private var textFields: [String: String] = [:] @State private var numberFields: [String: String] = [:] @State private var boolFields: [String: Bool] = [:] @@ -40,6 +52,15 @@ struct DocumentFieldsView: View { @ViewBuilder private func fieldView(for property: PersistentProperty) -> some View { + // Frozen properties are locked rather than validated on submit: a + // replace that touches one is refused by consensus with + // `DocumentImmutablePropertyChangedError` (code 40128), and the + // rejected transition is still paid for. + let lock = immutability.lockState( + for: property.name, + hasStoredValue: storedPropertyNames.contains(property.name) + ) + VStack(alignment: .leading, spacing: 8) { HStack { Text(property.name) @@ -49,6 +70,16 @@ struct DocumentFieldsView: View { Text("*") .foregroundColor(.red) } + if lock != .editable { + Text(lock == .frozen ? "Immutable" : "Immutable once set") + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.orange.opacity(0.2)) + .foregroundColor(.orange) + .cornerRadius(4) + .accessibilityIdentifier("createDocument.lock.\(property.name)") + } } // Check if this is an identifier field (contentMediaType contains identifier) @@ -124,7 +155,19 @@ struct DocumentFieldsView: View { .font(.caption2) .foregroundColor(.secondary) } + + if lock == .frozen { + Text("Frozen at creation: a replace cannot change, add or remove it.") + .font(.caption2) + .foregroundColor(.secondary) + } else if lock == .settableOnce { + Text("May be set once while the stored document has no value for it.") + .font(.caption2) + .foregroundColor(.secondary) + } } + .disabled(lock == .frozen) + .opacity(lock == .frozen ? 0.6 : 1) } private func placeholderText(for property: PersistentProperty) -> String { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift index 512f6e02d6a..84e104ec214 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift @@ -105,6 +105,28 @@ struct DocumentTypeDetailsView: View { Spacer() } + // Protocol version 14: a mutable type can still freeze + // individual top-level properties at creation. A replace that + // touches one is rejected by consensus (code 40128), so name + // them here and in the replace form. + let immutability = documentType.immutability + if !immutability.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Label( + "Immutable: \(immutability.immutableProperties.joined(separator: ", "))", + systemImage: "lock.fill" + ) + .foregroundColor(.orange) + + if !immutability.immutableAllowSetting.isEmpty { + Text("Settable once while absent: \(immutability.immutableAllowSetting.joined(separator: ", "))") + .font(.caption) + .foregroundColor(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + HStack { Label("Can Be Deleted", systemImage: documentType.documentsCanBeDeleted ? "trash.circle.fill" : "trash.circle") .foregroundColor(documentType.documentsCanBeDeleted ? .red : .secondary) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift index dd4ddff5352..c38bfbfd51d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift @@ -533,6 +533,7 @@ struct ReplaceDocumentView: View { DetailRow(label: "Document ID", value: document.documentId) DetailRow(label: "Type", value: document.documentType) } + immutabilitySection Section { TextEditor(text: $propertiesText) .font(.system(.body, design: .monospaced)) @@ -562,6 +563,51 @@ struct ReplaceDocumentView: View { } } + /// The protocol-version-14 freeze declared by this document's type. The + /// editor is one free-form JSON object rather than per-property fields, so + /// there is nothing to disable: name the frozen properties instead, which + /// the seeded text already carries at their stored values. Consensus + /// refuses a replace that changes, adds or removes one + /// (`DocumentImmutablePropertyChangedError`, code 40128), and the rejected + /// transition is still paid for. This surface knows the stored document, + /// so a settable-once property is listed as still open only while the + /// document really has no value for it. + @ViewBuilder + private var immutabilitySection: some View { + let immutability = document.documentType_relation?.immutability + ?? DocumentTypeImmutability.none + if !immutability.isEmpty { + let storedNames = Set((document.properties ?? [:]).keys) + let settableNow = immutability.immutableProperties.filter { + immutability.lockState( + for: $0, hasStoredValue: storedNames.contains($0)) == .settableOnce + } + let frozen = immutability.immutableProperties.filter { !settableNow.contains($0) } + Section { + if !frozen.isEmpty { + Label( + "Immutable: \(frozen.joined(separator: ", "))", + systemImage: "lock.fill" + ) + .font(.caption) + .foregroundColor(.orange) + } + if !settableNow.isEmpty { + Label( + "Settable once: \(settableNow.joined(separator: ", "))", + systemImage: "lock.open" + ) + .font(.caption) + .foregroundColor(.secondary) + } + } header: { + Text("Immutable Properties") + } footer: { + Text("Keep the immutable values exactly as seeded. A settable-once property may still be given a value while the document has none.") + } + } + } + private var submitSection: some View { Section { Button { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index e70f8ce4abe..2d98cb49e5f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -1140,6 +1140,22 @@ struct DocumentTypeStorageDetailView: View { Section("Flags") { FieldRow(label: "Keeps History", value: record.documentsKeepHistory ? "Yes" : "No") FieldRow(label: "Mutable", value: record.documentsMutable ? "Yes" : "No") + // Protocol version 14 per-property freeze, read off the + // stored schema; rows appear only when the type declares it, + // so a pre-v14 type renders as before. + let immutability = record.immutability + if !immutability.isEmpty { + FieldRow( + label: "Immutable", + value: immutability.immutableProperties.joined(separator: ", ") + ) + if !immutability.immutableAllowSetting.isEmpty { + FieldRow( + label: "Settable Once While Absent", + value: immutability.immutableAllowSetting.joined(separator: ", ") + ) + } + } FieldRow(label: "Can Be Deleted", value: record.documentsCanBeDeleted ? "Yes" : "No") FieldRow(label: "Transferable", value: record.documentsTransferable ? "Yes" : "No") FieldRow( diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift index 3a63699c249..8cc48e4cd02 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift @@ -288,6 +288,15 @@ struct TransitionDetailView: View { } else if let contract = dataContracts.first(where: { $0.idBase58 == contractId }), let documentTypes = contract.documentTypes { if let documentType = documentTypes.first(where: { $0.name == documentTypeName }) { + // Only a replace can hit the protocol-version-14 freeze: a create + // writes every property for the first time. This builder types the + // replacement from scratch and never loads the stored document, so + // no property is known to have a stored value and a settable-once + // property stays editable (see `DocumentFieldsView`). + let immutability = transitionKey == "documentReplace" + ? documentType.immutability + : DocumentTypeImmutability.none + DocumentFieldsView( documentType: documentType, fieldValues: Binding( @@ -300,7 +309,8 @@ struct TransitionDetailView: View { formInputs["documentFields"] = jsonString } } - ) + ), + immutability: immutability ) } else { Text("Document type '\(documentTypeName)' not found in contract") diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentTypeImmutabilityTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentTypeImmutabilityTests.swift new file mode 100644 index 00000000000..02388668fa7 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentTypeImmutabilityTests.swift @@ -0,0 +1,167 @@ +import SwiftData +import XCTest + +@testable import SwiftDashSDK + +/// Coverage for the protocol-version-14 `immutable` / +/// `immutableAllowSetting` keywords on a mutable document type: the parse +/// (`DataContractParser` persists the whole type dictionary, and +/// `PersistentDocumentType.immutability` reads the keywords back off it) and +/// the lock the replace forms apply. +/// +/// Consensus refuses a replace that changes, adds or removes a frozen +/// property with `DocumentImmutablePropertyChangedError` (state error code +/// 40128) and still charges for the transition, so the forms must lock +/// exactly what DPP freezes: every `immutable` entry, except an +/// `immutableAllowSetting` entry on a document that has no value for it yet. +@MainActor +final class DocumentTypeImmutabilityTests: XCTestCase { + + private let contractId = Data(repeating: 0xC1, count: 32) + + // MARK: - Parsing + + func testBothListsAreParsedAndSorted() throws { + let docType = try parseSingleDocumentType([ + "type": "object", + "documentsMutable": true, + "properties": [ + "author": ["type": "string", "position": 0], + "mood": ["type": "string", "position": 1] + ], + "immutable": ["mood", "author"], + "immutableAllowSetting": ["mood"] + ]) + + XCTAssertEqual(docType.immutableProperties, ["author", "mood"]) + XCTAssertEqual(docType.immutableAllowSetting, ["mood"]) + XCTAssertFalse(docType.immutability.isEmpty) + } + + func testAbsentKeywordsFreezeNothing() throws { + let docType = try parseSingleDocumentType([ + "type": "object", + "documentsMutable": true, + "properties": ["author": ["type": "string", "position": 0]] + ]) + + XCTAssertEqual(docType.immutableProperties, []) + XCTAssertEqual(docType.immutableAllowSetting, []) + XCTAssertTrue(docType.immutability.isEmpty) + } + + /// The SDK persists the keywords as authored and does not validate them: + /// DPP refuses a contract whose allowance names a property outside + /// `immutable`, so the pairing is consensus's business, not the client's. + /// The allowance still unlocks nothing on its own, which + /// `testAllowSettingOutsideImmutableUnlocksNothing` pins. + func testAllowSettingWithoutImmutableIsParsedAsGiven() throws { + let docType = try parseSingleDocumentType([ + "type": "object", + "documentsMutable": true, + "properties": ["mood": ["type": "string", "position": 0]], + "immutableAllowSetting": ["mood"] + ]) + + XCTAssertEqual(docType.immutableProperties, []) + XCTAssertEqual(docType.immutableAllowSetting, ["mood"]) + } + + func testNonStringEntriesAreIgnored() { + let immutability = DocumentTypeImmutability(documentTypeSchema: [ + "immutable": ["author", 7] + ]) + + XCTAssertEqual(immutability.immutableProperties, ["author"]) + } + + func testDuplicateEntriesCollapse() { + let immutability = DocumentTypeImmutability(documentTypeSchema: [ + "immutable": ["author", "author", "mood"], + "immutableAllowSetting": ["mood", "mood"] + ]) + + XCTAssertEqual(immutability.immutableProperties, ["author", "mood"]) + XCTAssertEqual(immutability.immutableAllowSetting, ["mood"]) + } + + // MARK: - Lock state + + func testFrozenPropertyIsLockedWhateverTheStoredDocumentHolds() { + let immutability = DocumentTypeImmutability( + immutable: ["author"], allowSetting: []) + + XCTAssertEqual( + immutability.lockState(for: "author", hasStoredValue: false), .frozen) + XCTAssertEqual( + immutability.lockState(for: "author", hasStoredValue: true), .frozen) + } + + func testSettableOnceOnlyWhileTheStoredDocumentHasNoValue() { + let immutability = DocumentTypeImmutability( + immutable: ["author", "mood"], allowSetting: ["mood"]) + + XCTAssertEqual( + immutability.lockState(for: "mood", hasStoredValue: false), .settableOnce) + XCTAssertEqual( + immutability.lockState(for: "mood", hasStoredValue: true), .frozen) + } + + func testPropertyOutsideBothListsStaysEditable() { + let immutability = DocumentTypeImmutability( + immutable: ["author"], allowSetting: []) + + XCTAssertEqual( + immutability.lockState(for: "body", hasStoredValue: true), .editable) + XCTAssertEqual( + DocumentTypeImmutability.none.lockState(for: "body", hasStoredValue: false), + .editable) + } + + /// A hand-edited contract can list an allowance for a property that is not + /// immutable. It must not unlock anything: the property was editable + /// already, and nothing else moves. + func testAllowSettingOutsideImmutableUnlocksNothing() { + let immutability = DocumentTypeImmutability( + immutable: ["author"], allowSetting: ["body"]) + + XCTAssertEqual( + immutability.lockState(for: "body", hasStoredValue: false), .editable) + XCTAssertEqual( + immutability.lockState(for: "author", hasStoredValue: false), .frozen) + } + + // MARK: - Helpers + + /// Run the real parser over a one-document-type contract and hand back the + /// persisted row. `parseDocumentTypes` needs the `PersistentDataContract` + /// row to exist first (document types hang off that relationship). + private func parseSingleDocumentType( + _ typeDict: [String: Any] + ) throws -> PersistentDocumentType { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + + let contract = PersistentDataContract( + id: contractId, + name: "Fixture", + serializedContract: Data(), + network: .testnet + ) + context.insert(contract) + try context.save() + + try DataContractParser.parseDataContract( + contractData: ["documents": ["post": typeDict]], + contractId: contractId, + modelContext: context + ) + + let id = contractId + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.contractId == id } + ) + let types = try context.fetch(descriptor) + return try XCTUnwrap(types.first, "parser should have persisted one document type") + } +}