Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
val allowSetting: Set<String>,
) {
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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading