feat(quartz): FsEventStore NIP-09 deletion + tombstones (step 4)

Tombstone files under tombstones/id/<id>.json and tombstones/addr/
<kind>/<pubkey>/<sha256(d)>.json, each a hardlink to the kind-5
event that authored the deletion. One source of truth: the tombstone
IS the deletion event, just indexed by target.

- FsTombstones — installs id tombstones unconditionally, installs
  addr tombstones with strongest-cutoff-wins semantics (later kind-5
  replaces earlier via atomic rename), and exposes hasIdTombstone /
  addrTombstoneCutoff for pre-insert checks.
- FsEventStore.insert — pre-insert guard: id tombstone always blocks;
  addr tombstone blocks when event.createdAt <= tomb.createdAt, matching
  SQLite's reject_deleted_events trigger. Kind-5 inserts trigger a
  cascade: for each e-tag target owned by the deletion author, unlink
  indexes + slot + canonical; for each a-tag (same pubkey), evict the
  slot winner if its createdAt <= deletion.createdAt. Tombstones are
  installed for every target regardless, so future re-inserts are
  blocked.

Tests: 11 new in FsDeletionTest — delete-by-id, block-reinsert-by-id,
non-author-deletion still installs tombstone but no cascade, cascade
addressable slot, newer-at-deleted-address passes, older blocked,
equal-timestamp blocked, later kind-5 raises cutoff, earlier kind-5
does not lower, deletion event stays queryable, tombstone shares
inode with kind-5 canonical. 51 fs tests green.
This commit is contained in:
Claude
2026-04-24 21:38:37 +00:00
parent 096aa88096
commit 721546b140
4 changed files with 502 additions and 0 deletions
@@ -20,14 +20,18 @@
*/
package com.vitorpamplona.quartz.nip01Core.store.fs
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.isAddressable
import com.vitorpamplona.quartz.nip01Core.core.isEphemeral
import com.vitorpamplona.quartz.nip01Core.core.isReplaceable
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.sqlite.DefaultIndexingStrategy
import com.vitorpamplona.quartz.nip01Core.store.sqlite.IndexingStrategy
import com.vitorpamplona.quartz.nip01Core.store.sqlite.TagNameValueHasher
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import java.nio.file.FileAlreadyExistsException
import java.nio.file.Files
import java.nio.file.Path
@@ -57,6 +61,7 @@ class FsEventStore(
private val hasher: TagNameValueHasher
private val indexer: FsIndexer
private val slots: FsSlots
private val tombstones: FsTombstones
private val planner: FsQueryPlanner
init {
@@ -65,6 +70,7 @@ class FsEventStore(
hasher = TagNameValueHasher(layout.readOrCreateSeed())
indexer = FsIndexer(layout, hasher, indexingStrategy)
slots = FsSlots(layout, indexer)
tombstones = FsTombstones(layout)
planner = FsQueryPlanner(layout, hasher)
}
@@ -74,6 +80,7 @@ class FsEventStore(
override fun insert(event: Event) {
if (event.kind.isEphemeral()) return
if (isBlockedByTombstone(event)) return
val slot = slots.slotPathFor(event)
val existingSlot = slot?.let { slots.readSlot(it) }
@@ -92,6 +99,7 @@ class FsEventStore(
if (slot != null && existingSlot?.id != event.id) {
slots.install(slot, canonical, event, existingSlot)
}
if (event is DeletionEvent) processDeletion(event, canonical)
return
}
@@ -112,12 +120,81 @@ class FsEventStore(
if (slot != null) {
slots.install(slot, canonical, event, existingSlot)
}
if (event is DeletionEvent) processDeletion(event, canonical)
} catch (t: Throwable) {
Files.deleteIfExists(tmp)
throw t
}
}
/**
* NIP-09 pre-insert guard. Parity with SQLite's `reject_deleted_events`
* BEFORE INSERT trigger: id-scoped tombstones always block; address-
* scoped tombstones block when the existing cutoff is `>=` the new
* event's createdAt.
*/
private fun isBlockedByTombstone(event: Event): Boolean {
if (tombstones.hasIdTombstone(event.id)) return true
if (event is AddressableEvent && event.kind.isAddressable()) {
val cutoff = tombstones.addrTombstoneCutoff(event.kind, event.pubKey, event.dTag())
if (cutoff != null && event.createdAt <= cutoff) return true
}
if (event.kind.isReplaceable()) {
val cutoff = tombstones.addrTombstoneCutoff(event.kind, event.pubKey, "")
if (cutoff != null && event.createdAt <= cutoff) return true
}
return false
}
/**
* Applies a kind-5 deletion's side effects: cascade-deletes each
* target owned by the deletion author (matching SQLite's `pubkey_
* owner_hash` check, which covers GiftWrap recipients), clears any
* slot the targets owned, and installs tombstone hardlinks so
* future re-inserts are blocked.
*/
private fun processDeletion(
deletion: DeletionEvent,
deletionCanonical: java.nio.file.Path,
) {
val ownerHashOfDeletion = hasher.hash(deletion.pubKey)
for (targetId in deletion.deleteEventIds()) {
val targetEvent = readEvent(targetId)
if (targetEvent != null && indexer.ownerHash(targetEvent) == ownerHashOfDeletion) {
indexer.unlink(targetEvent)
val targetSlot = slots.slotPathFor(targetEvent)
if (targetSlot != null && slots.readSlot(targetSlot)?.id == targetId) {
slots.clear(targetSlot)
}
layout.canonical(targetId).deleteIfExists()
}
tombstones.installId(targetId, deletionCanonical)
}
for (addr in deletion.deleteAddresses()) {
// Cascade is only honoured when the address's author matches
// the deletion author — matching SQLite's WHERE pubkey = ?.
if (addr.pubKeyHex == deletion.pubKey) {
val slot =
when {
addr.kind.isReplaceable() -> layout.replaceableSlot(addr.kind, addr.pubKeyHex)
addr.kind.isAddressable() -> layout.addressableSlot(addr.kind, addr.pubKeyHex, addr.dTag)
else -> null
}
if (slot != null) {
val winner = slots.readSlot(slot)
if (winner != null && winner.createdAt <= deletion.createdAt) {
indexer.unlink(winner)
layout.canonical(winner.id).deleteIfExists()
slots.clear(slot)
}
}
}
tombstones.installAddr(addr.kind, addr.pubKeyHex, addr.dTag, deletion, deletionCanonical)
}
}
override fun transaction(body: IEventStore.ITransaction.() -> Unit) {
val txn =
object : IEventStore.ITransaction {
@@ -47,6 +47,9 @@ internal class FsLayout(
val idxTag: Path = idx.resolve(IDX_TAG)
val replaceable: Path = root.resolve(REPLACEABLE_DIR)
val addressable: Path = root.resolve(ADDRESSABLE_DIR)
val tombstones: Path = root.resolve(TOMBSTONES_DIR)
val tombstonesId: Path = tombstones.resolve(TOMB_ID)
val tombstonesAddr: Path = tombstones.resolve(TOMB_ADDR)
fun canonical(id: HexKey): Path {
require(id.length >= 4) { "event id must be at least 4 hex chars, got '$id'" }
@@ -104,6 +107,19 @@ internal class FsLayout(
dTag: String,
): Path = addressable.resolve(kind.toString()).resolve(pubkey).resolve("${sha256Hex(dTag)}$JSON_EXT")
/** NIP-09 tombstone path keyed by target event id. */
fun idTombstonePath(id: HexKey): Path = tombstonesId.resolve("$id$JSON_EXT")
/**
* NIP-09 tombstone path keyed by address. `dTag` may be empty — used
* for replaceable-kind tombstones that have no d-tag.
*/
fun addrTombstonePath(
kind: Kind,
pubkey: HexKey,
dTag: String,
): Path = tombstonesAddr.resolve(kind.toString()).resolve(pubkey).resolve("${sha256Hex(dTag)}$JSON_EXT")
fun ensureSkeleton() {
Files.createDirectories(events)
Files.createDirectories(staging)
@@ -113,6 +129,8 @@ internal class FsLayout(
Files.createDirectories(idxTag)
Files.createDirectories(replaceable)
Files.createDirectories(addressable)
Files.createDirectories(tombstonesId)
Files.createDirectories(tombstonesAddr)
}
/**
@@ -144,6 +162,9 @@ internal class FsLayout(
const val IDX_TAG = "tag"
const val REPLACEABLE_DIR = "replaceable"
const val ADDRESSABLE_DIR = "addressable"
const val TOMBSTONES_DIR = "tombstones"
const val TOMB_ID = "id"
const val TOMB_ADDR = "addr"
const val SEED_FILE = ".seed"
const val JSON_EXT = ".json"
@@ -0,0 +1,127 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.store.fs
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import java.nio.file.FileAlreadyExistsException
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.util.UUID
import kotlin.io.path.exists
import kotlin.io.path.readText
/**
* NIP-09 deletion tombstones. Each tombstone is itself a hardlink to
* the kind-5 event that authored the deletion — so the tombstone *is*
* the deletion event, just indexed by what it targets.
*
* Two tombstone kinds:
* tombstones/id/<target_id>.json (NIP-09 by `e`-tag)
* tombstones/addr/<kind>/<pubkey>/<sha256(d)>.json (NIP-09 by `a`-tag;
* empty d for replaceables)
*
* Semantics:
* - An `id` tombstone unconditionally blocks re-insertion of the exact
* event id (matching SQLite's ExistsCheck on etag_hash).
* - An `addr` tombstone holds the kind-5 `createdAt` as a cutoff. An
* insert is blocked iff `event.createdAt <= tombstone.createdAt`.
* Newer events at the same address may pass (matching SQLite's
* `created_at >= NEW.created_at` trigger condition).
* - When multiple kind-5s target the same tombstone path, the one
* with the larger `createdAt` wins (strongest cutoff). Atomic
* rename swaps it in.
*
* The caller (FsEventStore) handles cascade-deleting the target events
* and slots; this class only manages the tombstone filesystem state.
*/
internal class FsTombstones(
private val layout: FsLayout,
) {
fun hasIdTombstone(id: HexKey): Boolean = layout.idTombstonePath(id).exists()
/** Returns the `createdAt` cutoff from the tombstone, or null if none exists. */
fun addrTombstoneCutoff(
kind: Kind,
pubkey: HexKey,
dTag: String,
): Long? {
val path = layout.addrTombstonePath(kind, pubkey, dTag)
if (!path.exists()) return null
return try {
Event.fromJson(path.readText()).createdAt
} catch (_: java.nio.file.NoSuchFileException) {
null
} catch (_: Exception) {
null
}
}
fun installId(
targetId: HexKey,
deletionCanonical: Path,
) {
val path = layout.idTombstonePath(targetId)
if (path.exists()) return
Files.createDirectories(path.parent)
try {
Files.createLink(path, deletionCanonical)
} catch (_: FileAlreadyExistsException) {
// Concurrent writer installed first — fine, either copy is equivalent.
}
}
fun installAddr(
kind: Kind,
pubkey: HexKey,
dTag: String,
deletionEvent: Event,
deletionCanonical: Path,
) {
val path = layout.addrTombstonePath(kind, pubkey, dTag)
val existing = addrTombstoneCutoff(kind, pubkey, dTag)
if (existing != null && existing >= deletionEvent.createdAt) return
Files.createDirectories(path.parent)
val tmp = path.resolveSibling("${path.fileName}.tmp.${UUID.randomUUID()}")
try {
Files.createLink(tmp, deletionCanonical)
Files.move(tmp, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING)
} catch (t: Throwable) {
Files.deleteIfExists(tmp)
throw t
}
}
fun clearId(id: HexKey) {
Files.deleteIfExists(layout.idTombstonePath(id))
}
fun clearAddr(
kind: Kind,
pubkey: HexKey,
dTag: String,
) {
Files.deleteIfExists(layout.addrTombstonePath(kind, pubkey, dTag))
}
}
@@ -0,0 +1,277 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.store.fs
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.utils.Secp256k1Instance
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.exists
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class FsDeletionTest {
private val signer = NostrSignerSync()
private val otherSigner = NostrSignerSync()
private lateinit var root: Path
private lateinit var store: FsEventStore
@BeforeTest
fun setup() {
Secp256k1Instance
root = Files.createTempDirectory("fs-del-")
store = FsEventStore(root)
}
@AfterTest
fun tearDown() {
store.close()
if (root.exists()) {
Files.walk(root).use { it.sorted(Comparator.reverseOrder()).forEach { p -> Files.deleteIfExists(p) } }
}
}
private fun note(
body: String,
ts: Long,
signer: NostrSignerSync = this.signer,
) = signer.sign<TextNoteEvent>(TextNoteEvent.build(body, createdAt = ts))
private fun article(
slug: String,
body: String,
ts: Long,
) = signer.sign<LongTextNoteEvent>(
createdAt = ts,
kind = LongTextNoteEvent.KIND,
tags = arrayOf(arrayOf("d", slug)),
content = body,
)
// ------------------------------------------------------------------
// Delete by id
// ------------------------------------------------------------------
@Test
fun `kind-5 cascade-deletes a target by id`() {
val n1 = note("one", 10)
val n2 = note("two", 20)
store.insert(n1)
store.insert(n2)
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(n1), createdAt = 30))
store.insert(del)
assertEquals(emptyList(), store.query<TextNoteEvent>(Filter(ids = listOf(n1.id))).map { it.id })
assertEquals(listOf(n2.id), store.query<TextNoteEvent>(Filter(ids = listOf(n2.id))).map { it.id })
assertEquals(listOf(del.id), store.query<DeletionEvent>(Filter(ids = listOf(del.id))).map { it.id })
}
@Test
fun `deletion blocks re-insertion of the same id`() {
val n1 = note("one", 10)
store.insert(n1)
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(n1), createdAt = 30))
store.insert(del)
store.insert(n1) // should be blocked
assertEquals(emptyList(), store.query<TextNoteEvent>(Filter(ids = listOf(n1.id))).map { it.id })
}
@Test
fun `deletion by non-author does not cascade but still installs id tombstone`() {
// other signer authors a note
val theirs = note("not yours", 10, signer = otherSigner)
store.insert(theirs)
// Our signer attempts to delete it.
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(theirs), createdAt = 30))
store.insert(del)
// Cascade did NOT run — not our author.
assertEquals(listOf(theirs.id), store.query<TextNoteEvent>(Filter(ids = listOf(theirs.id))).map { it.id })
// But a tombstone is installed for the id, blocking future re-inserts.
// Re-inserting the note is effectively a no-op because it already
// exists. Instead we delete then try to re-insert.
store.delete(theirs.id)
store.insert(theirs)
assertEquals(emptyList(), store.query<TextNoteEvent>(Filter(ids = listOf(theirs.id))).map { it.id })
}
// ------------------------------------------------------------------
// Delete by address (addressable)
// ------------------------------------------------------------------
@Test
fun `kind-5 by address cascades addressable slot`() {
val v1 = article("intro", "draft 1", 10)
val v2 = article("intro", "draft 2", 20)
store.insert(v1)
store.insert(v2)
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(v2), createdAt = 30))
store.insert(del)
// Slot cleared, canonical removed, indexes gone.
val dHash = FsLayout.sha256Hex("intro")
val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json")
assertFalse(slot.exists(), "addressable slot should be cleared")
assertEquals(emptyList(), store.query<LongTextNoteEvent>(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))).map { it.id })
}
@Test
fun `newer event at a deleted address may pass the cutoff`() {
val v1 = article("intro", "draft 1", 10)
store.insert(v1)
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(v1), createdAt = 20))
store.insert(del)
// A newer addressable at the same address should still be accepted.
val v3 = article("intro", "draft 3", 30)
store.insert(v3)
val got = store.query<LongTextNoteEvent>(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND)))
assertEquals(listOf(v3.id), got.map { it.id })
}
@Test
fun `older event at a deleted address is blocked by cutoff`() {
val v1 = article("intro", "draft 1", 10)
store.insert(v1)
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(v1), createdAt = 20))
store.insert(del)
// Attempting to re-insert an event authored earlier than the deletion should fail.
val older = article("intro", "even-older", 5)
store.insert(older)
assertEquals(emptyList(), store.query<LongTextNoteEvent>(Filter(ids = listOf(older.id))).map { it.id })
}
@Test
fun `equal-timestamp event at a deleted address is blocked`() {
val v = article("intro", "v", 10)
store.insert(v)
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(v), createdAt = 15))
store.insert(del)
val equal = article("intro", "equal", 15)
store.insert(equal)
assertEquals(emptyList(), store.query<LongTextNoteEvent>(Filter(ids = listOf(equal.id))).map { it.id })
}
// ------------------------------------------------------------------
// Multiple deletions: strongest cutoff wins
// ------------------------------------------------------------------
@Test
fun `later kind-5 raises the address cutoff`() {
val v = article("intro", "v", 10)
store.insert(v)
val del1 = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(v), createdAt = 20))
store.insert(del1)
val del2Target = article("intro", "v2", 30) // inserted only to give del2 a target
store.insert(del2Target)
val del2 = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(del2Target), createdAt = 40))
store.insert(del2)
// Cutoff should now be 40, so an event at createdAt=35 is blocked.
val mid = article("intro", "mid", 35)
store.insert(mid)
assertEquals(emptyList(), store.query<LongTextNoteEvent>(Filter(ids = listOf(mid.id))).map { it.id })
}
@Test
fun `earlier kind-5 does not lower an existing stronger cutoff`() {
val v1 = article("slug", "v1", 10)
val v2 = article("slug", "v2", 20)
store.insert(v1)
store.insert(v2)
val strongDel = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(v2), createdAt = 100))
store.insert(strongDel)
// Now insert a weaker (earlier) deletion for the same address.
val weakTarget = article("slug", "target-for-weak", 30)
store.insert(weakTarget) // this passes? No: cutoff=100, target@30 is blocked. Actually we want to
// construct a DeletionEvent that targets the slug address directly. The simplest way:
val weakDel =
signer.sign<DeletionEvent>(
DeletionEvent.buildAddressOnly(listOf(v1), createdAt = 50),
)
store.insert(weakDel)
// Cutoff should still be 100 — an event at 60 must still be blocked.
val blocked = article("slug", "should-be-blocked", 60)
store.insert(blocked)
assertEquals(emptyList(), store.query<LongTextNoteEvent>(Filter(ids = listOf(blocked.id))).map { it.id })
}
// ------------------------------------------------------------------
// Deletion event itself remains queryable
// ------------------------------------------------------------------
@Test
fun `deletion event itself is indexed and queryable`() {
val n = note("x", 10)
store.insert(n)
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(n), createdAt = 20))
store.insert(del)
val byKind = store.query<DeletionEvent>(Filter(kinds = listOf(DeletionEvent.KIND)))
assertEquals(listOf(del.id), byKind.map { it.id })
}
// ------------------------------------------------------------------
// Tombstone files use hardlinks to the kind-5 canonical
// ------------------------------------------------------------------
@Test
fun `id tombstone is a hardlink to the kind-5 event`() {
val n = note("x", 10)
store.insert(n)
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(n), createdAt = 20))
store.insert(del)
val tomb = root.resolve("tombstones/id/${n.id}.json")
assertTrue(tomb.exists())
val canonical = root.resolve("events/${del.id.substring(0, 2)}/${del.id.substring(2, 4)}/${del.id}.json")
assertEquals(
Files.readAttributes(tomb, java.nio.file.attribute.BasicFileAttributes::class.java).fileKey(),
Files.readAttributes(canonical, java.nio.file.attribute.BasicFileAttributes::class.java).fileKey(),
"tombstone and kind-5 canonical should share an inode",
)
}
}