feat: a card that prices a bag's search before it spends anything

The last layer of the region-bag work. A kind 33330 in a feed now renders
a card that has already read its hint and says what the search costs:
"Hidden in a box of 4096 regions", or that the box is out of reach. The
quote is three subtractions on two tags, so it is free while the row
composes, and a box past the caps never becomes a button at all — §7.7
lets a hider pick the difficulty, and a bag can carry a gap-30 hint
precisely to burn a day of a reader's battery.

The plan asked for the Android cost to be measured before any number was
quoted in the UI. What ships measures it at the tap, on the device that
is about to pay: the box's own base region is derived and timed, and only
then is the rest committed to. That candidate is never wasted — a gap-0
hint names exactly it — and the estimate deliberately over-quotes, since
the first candidate carries three axis roots that every later one reuses.

Every sweep is a tap, including the gap-0 hint §7.7 itself calls "a
destination the seeker can compute or walk to directly, not a search".
A reader who learned that some bags open themselves would have learned an
expectation a hostile bag could hide inside.

Items render through the cards that already exist: a 3330 shard through
SnoObjectCard, a kind 1 as its text, anything else named and skipped.
The attribution follows §7.6 rather than intuition — placement belongs to
the bag's author, authorship only to a signed item's own pubkey — so an
unsigned item says its author is a claim instead of borrowing either name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa
This commit is contained in:
Claude
2026-09-23 02:33:03 +00:00
parent 39658625c5
commit debf361f2b
11 changed files with 971 additions and 21 deletions
+16 -11
View File
@@ -839,17 +839,22 @@ are where the risk is, and they are testable without a device.
of our code will check the wrong arbiter first.
- **D1b — `name`.** Required in §1.1's table, enforced by neither reference
implementation (§2.6). Treat as optional with a fallback.
- **D2 — `kind 3330` bag items. Settled: the container ships, the bag does not.**
`SnoShardEvent` reads a shard handed over directly — quoted in a note, or fetched
by id — through the same parser and the same card as a standalone object, because
§3.2 is two containers for one format rather than two formats. What is *not*
implemented is opening a `kind 33330` bag: its payload is AES-256-GCM ciphertext
keyed to a Cyberspace region, so reading one means §2 coordinates, §4's Cantor
trees, §7.2 key derivation and a §7.7 sweep. That is the protocol, not a renderer,
and §7.6 is explicit that a failed decryption "MUST NOT be treated as an error in
the bag". Note that "we have no position in cyberspace" is *not* the reason —
§7.7 is explicit that a seeker's position never enters the cost of a sweep. The
reason is §2.2's: the compute, and the protocol surface behind it.
- **D2 — `kind 3330` bag items. Settled twice: the container shipped first, the bag
followed.** `SnoShardEvent` reads a shard handed over directly — quoted in a note,
or fetched by id — through the same parser and the same card as a standalone
object, because §3.2 is two containers for one format rather than two formats.
Opening the `kind 33330` bag around it was deferred here and is now built; see
`quartz/plans/2026-09-22-cyberspace-region-bags.md`. The deferral's *reason* was
restated correctly before it was lifted, and the restatement is what made the work
tractable: "we have no position in cyberspace" was never the obstacle — §7.7 is
explicit that a seeker's position never enters the cost of a sweep — the obstacle
was the compute and the protocol surface behind it. Both turned out to be
affordable in the corner anyone uses: a region key is 1.2 ms at height 8 against
the spec's own 1.3 ms, and a bag's own hint prices its search before a tree is
built. A bag whose hint prices it out of reach still says so and shows nothing,
which is §7.6 working as written: a failed decryption "MUST NOT be treated as an
error in the bag".
**The v1 tag form was built and then removed, deliberately.** Every `kind 3330`
publicly reachable on a relay predates the deck: 21 events, one pubkey, all inside
@@ -158,6 +158,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderCitation
import com.vitorpamplona.amethyst.ui.note.types.RenderClassifieds
import com.vitorpamplona.amethyst.ui.note.types.RenderCodeSnippetEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCommunity
import com.vitorpamplona.amethyst.ui.note.types.RenderCyberspaceBag
import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack
import com.vitorpamplona.amethyst.ui.note.types.RenderEntityRating
import com.vitorpamplona.amethyst.ui.note.types.RenderExternalReaction
@@ -284,6 +285,7 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoShardEvent
@@ -1430,6 +1432,10 @@ private fun RenderNoteRow(
RenderSnoShard(baseNote, accountViewModel)
}
is CyberspaceBagEvent -> {
RenderCyberspaceBag(baseNote, accountViewModel)
}
is ChessGameEvent -> {
RenderChessGame(
baseNote,
@@ -0,0 +1,340 @@
/*
* 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.amethyst.ui.note.types
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.cyberspace.BagSweep
import com.vitorpamplona.amethyst.commons.cyberspace.BagSweepQuote
import com.vitorpamplona.amethyst.commons.cyberspace.BagSweepState
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_box
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_cancel
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_damaged
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_destination
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_dropped
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_dropped_many
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_empty
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_measuring
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_no_hint
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_not_found
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_opaque
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_out_of_reach
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_progress
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_search
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_title
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_too_slow_hours
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_too_slow_minutes
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_unknown_kind
import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_unsigned
import com.vitorpamplona.amethyst.commons.ui.note.SnoObjectCard
import com.vitorpamplona.amethyst.commons.ui.note.SnoObjectUnreadableCard
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.cyberspace.CyberspaceBagContents
import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent
import com.vitorpamplona.quartz.cyberspace.CyberspaceBagItem
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPaletteRef
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoResult
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoShardEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
/**
* Entry for a region bag (`CYBERSPACE_V2.md` §7.6, kind 33330) — content
* somebody hid at a place, addressed by a hash of a hash.
*
* The card's job is to say what the search costs and then not start it. §7.7's
* hint is the hider's difficulty knob, which makes the price part of the
* content: a box of 4,096 regions is a minute of a phone, and a box of 2^30 is
* a day of it, chosen by a stranger. So the gap is read off two tags while the
* row composes — arithmetic, no trees — and the card either offers a button or
* says the box is out of reach.
*
* **Every sweep is a tap, including a gap-0 hint.** §7.7 calls that one "a
* destination the seeker can compute or walk to directly, not a search", and it
* costs about as long as a frame. It still waits for the tap, because a reader
* who learns that some bags open themselves has learned an expectation a
* hostile bag can hide inside.
*
* What comes out renders through the cards that already exist: a `3330` shard
* through [SnoObjectCard], a `kind 1` as its text, anything else named and
* skipped. §7.6 decides the attribution and it is not the obvious one —
* *placement* belongs to the bag's author, *authorship* only to a signed item's
* own pubkey — so an unsigned item says so under it rather than borrowing
* either name.
*/
@Composable
fun RenderCyberspaceBag(
baseNote: Note,
accountViewModel: AccountViewModel,
) {
val noteEvent = baseNote.event as? CyberspaceBagEvent ?: return
val quote = remember(noteEvent) { BagSweep.quote(noteEvent) }
var state by remember(noteEvent) { mutableStateOf<BagSweepState?>(null) }
var job by remember(noteEvent) { mutableStateOf<Job?>(null) }
// Scrolling the row away is a cancel: this scope dies with the composition,
// and the sweep is a cold flow, so the work stops at the next candidate.
// Nothing here should outlive the card that asked for it.
val scope = rememberCoroutineScope()
Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) {
Text(
text = stringResource(Res.string.cyberspace_bag_title),
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text =
when (quote) {
is BagSweepQuote.Hidden -> stringResource(Res.string.cyberspace_bag_no_hint)
is BagSweepQuote.OutOfReach -> stringResource(Res.string.cyberspace_bag_out_of_reach, quote.gapBits)
is BagSweepQuote.Searchable ->
if (quote.destination) {
stringResource(Res.string.cyberspace_bag_destination)
} else {
stringResource(Res.string.cyberspace_bag_box, quote.candidates.toString())
}
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
)
if (quote is BagSweepQuote.Searchable) {
Sweeper(
state = state,
onStart = {
job =
scope.launch {
BagSweep
.sweep(noteEvent)
.flowOn(Dispatchers.Default)
.collect { state = it }
}
},
onCancel = {
job?.cancel()
job = null
state = null
},
)
}
when (val settled = state) {
is BagSweepState.Opened -> Contents(settled.contents, accountViewModel)
is BagSweepState.NotFound -> Footnote(stringResource(Res.string.cyberspace_bag_not_found))
is BagSweepState.OutOfReach -> Footnote(outOfReach(settled.estimateMillis))
else -> {}
}
}
}
/** The button, or the progress and the stop that replace it while a sweep runs. */
@Composable
private fun Sweeper(
state: BagSweepState?,
onStart: () -> Unit,
onCancel: () -> Unit,
) {
when (state) {
null -> TextButton(onClick = onStart) { Text(stringResource(Res.string.cyberspace_bag_search)) }
is BagSweepState.Measuring -> Progress(null, stringResource(Res.string.cyberspace_bag_measuring), onCancel)
is BagSweepState.Running ->
Progress(
fraction = if (state.candidates > 0) state.examined.toFloat() / state.candidates else null,
label = stringResource(Res.string.cyberspace_bag_progress, state.examined.toString(), state.candidates.toString()),
onCancel = onCancel,
)
// Settled. A cancel resets the state to null and the button comes back;
// an outcome does not, because sweeping the same box again derives the
// same keys and reaches the same answer.
else -> {}
}
}
@Composable
private fun Progress(
fraction: Float?,
label: String,
onCancel: () -> Unit,
) {
Row(Modifier.fillMaxWidth().padding(top = 6.dp)) {
Column(Modifier.weight(1f)) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
)
Spacer(Modifier.height(4.dp))
if (fraction == null) {
LinearProgressIndicator(Modifier.fillMaxWidth())
} else {
LinearProgressIndicator(progress = { fraction }, modifier = Modifier.fillMaxWidth())
}
}
Spacer(Modifier.width(8.dp))
TextButton(onClick = onCancel) { Text(stringResource(Res.string.cyberspace_bag_cancel)) }
}
}
/**
* What the bag held (§7.6's two plaintext shapes).
*
* A null [contents] is the one case that is genuinely a broken bag: the
* `lookup_id` matched, and §7.2 makes that a hash of the very key we tried, so
* a GCM tag that then fails is damage rather than a wrong reader.
*/
@Composable
private fun Contents(
contents: CyberspaceBagContents?,
accountViewModel: AccountViewModel,
) {
when (contents) {
null -> Footnote(stringResource(Res.string.cyberspace_bag_damaged))
is CyberspaceBagContents.Opaque ->
Footnote(asText(contents.bytes) ?: stringResource(Res.string.cyberspace_bag_opaque, contents.bytes.size))
is CyberspaceBagContents.Items -> {
if (contents.items.isEmpty() && contents.dropped == 0) {
Footnote(stringResource(Res.string.cyberspace_bag_empty))
}
contents.items.forEach { item ->
Spacer(Modifier.height(6.dp))
Item(item, accountViewModel)
}
// §7.6: a forged item costs itself and nothing else, but it is
// still worth saying that something was thrown away.
if (contents.dropped > 0) {
Spacer(Modifier.height(6.dp))
Footnote(
if (contents.dropped == 1) {
stringResource(Res.string.cyberspace_bag_dropped, contents.dropped)
} else {
stringResource(Res.string.cyberspace_bag_dropped_many, contents.dropped)
},
)
}
}
}
}
/**
* One item, through whichever card already draws its kind.
*
* The `3330` case is what a bag is usually for: DECK-0003 §3.2's shard, whose
* payload is the same format a standalone object carries, which is why
* [RenderSnoShard] and this end at the same card. An item that came out of a
* bag is never in [com.vitorpamplona.amethyst.commons.model.cache.LocalCache] —
* it was ciphertext a moment ago — so there is no `Note` to hand the usual
* renderers, and these draw from the event directly.
*/
@Composable
private fun Item(
item: CyberspaceBagItem,
accountViewModel: AccountViewModel,
) {
when (val event = item.event) {
is SnoShardEvent -> {
val first = remember(event) { event.shard() }
WithSnoPalette(first.payloadOrNull()?.paletteRef ?: SnoPaletteRef.BuiltIn, accountViewModel) { palette ->
when (val parsed = remember(event, palette) { if (palette == null) first else event.shard(palette) }) {
is SnoResult.Invalid -> SnoObjectUnreadableCard(parsed.rule)
is SnoResult.Valid -> SnoObjectCard(payload = parsed.payload, eventId = event.id)
}
}
}
is TextNoteEvent -> Text(text = event.content, style = MaterialTheme.typography.bodyMedium)
// §7.6: "a reader that does not understand an item's kind skips it and
// renders the rest". Named rather than silent, so the count adds up.
else -> Footnote(stringResource(Res.string.cyberspace_bag_unknown_kind, event.kind))
}
// §7.6: an item without a `sig` is allowed, "its `pubkey` is then a claim,
// and readers MUST NOT present it as verified". Placement still belongs to
// the bag's author either way, which is why this only disclaims authorship.
if (!item.verified) Footnote(stringResource(Res.string.cyberspace_bag_unsigned))
}
/** A small grey line: this card's only other voice. */
@Composable
private fun Footnote(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
)
}
/** The refusal, in whichever unit does not read as a wall of minutes. */
@Composable
private fun outOfReach(estimateMillis: Long): String {
val minutes = estimateMillis / 60_000L
return if (minutes >= MINUTES_PER_HOUR) {
stringResource(Res.string.cyberspace_bag_too_slow_hours, (minutes / MINUTES_PER_HOUR).toInt())
} else {
stringResource(Res.string.cyberspace_bag_too_slow_minutes, minutes.toInt())
}
}
/**
* §7.6's other shape as text, or null when it is not UTF-8 — "a text note or a
* file", and a file should not be shown as a wall of replacement characters.
*/
private fun asText(bytes: ByteArray): String? {
val text = bytes.decodeToString()
return if (text.encodeToByteArray().contentEquals(bytes)) text else null
}
private const val MINUTES_PER_HOUR = 60L
@@ -184,6 +184,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderChat
import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessageEncryptedFile
import com.vitorpamplona.amethyst.ui.note.types.RenderCitation
import com.vitorpamplona.amethyst.ui.note.types.RenderCyberspaceBag
import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack
import com.vitorpamplona.amethyst.ui.note.types.RenderEntityRating
import com.vitorpamplona.amethyst.ui.note.types.RenderFhirResource
@@ -272,6 +273,7 @@ import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.selectedNote
import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoShardEvent
@@ -1081,6 +1083,8 @@ private fun FullBleedNoteCompose(
RenderSnoAvatar(baseNote, accountViewModel)
} else if (noteEvent is SnoShardEvent) {
RenderSnoShard(baseNote, accountViewModel)
} else if (noteEvent is CyberspaceBagEvent) {
RenderCyberspaceBag(baseNote, accountViewModel)
} else if (noteEvent is Ps1SaveEvent) {
RenderPs1Save(baseNote)
} else if (noteEvent is GeocacheListingEvent) {
+2
View File
@@ -87,6 +87,8 @@ in `commonsUI`, under the same package.
| `account` | no | New-account bootstrap events. |
| `onchain` | no | On-chain zap splitting/broadcasting. |
| `marmot` | no | MLS group-chat event processing. |
| `cyberspace` | no | `CYBERSPACE_V2` §7.7 region-bag search: the free quote off a bag's `hint`/`h` tags, the device-measured budget, and the cold sweep flow over quartz's `RegionSweep`. The protocol itself (coordinates, Cantor trees, keys, the bag) is `quartz/.../cyberspace`. |
| `sno` | mixed | DECK-0003 object rendering math — rasterizer, lighting, face winding, default avatar — here; the Compose viewer/thumbnail and the Coil fetcher in `commonsUI` under `sno` and `sno/ui`. |
| `nip53LiveActivities` | mixed | Live-activity zapper aggregation (logic) + the stream card in `nip53LiveActivities/ui`. |
| `search` | no | Event search filtering/ranking, kind registry. |
| `preview` | no | OpenGraph / meta-tag link-preview parsing. |
@@ -0,0 +1,272 @@
/*
* 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.amethyst.commons.cyberspace
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.cyberspace.CyberspaceBagContents
import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent
import com.vitorpamplona.quartz.cyberspace.CyberspaceHint
import com.vitorpamplona.quartz.cyberspace.RegionSweep
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlin.time.TimeSource
/**
* What a bag's `hint` tag promises, before anything is spent on it.
*
* The whole quote is arithmetic on two tags — §7.7's three heights and §8.6's
* `h` — so a card can carry it without building a single Cantor tree. That is
* deliberate: a feed may scroll past a bag whose hider chose a gap of 75, and
* the reader has to be able to say "out of reach" without having taken a
* stranger's dare first.
*/
@Immutable
sealed class BagSweepQuote {
/** A search this reader is willing to offer, and how big it is. */
@Immutable
data class Searchable(
val gapBits: Int,
val candidates: Long,
/**
* §7.7: three heights equal to the bag's "name the region itself: the
* hint is then a destination the seeker can compute or walk to
* directly, not a search".
*
* It is still a tap. A reader who learns that some bags open themselves
* has learned something a hostile bag can hide inside, so the one
* candidate is offered exactly like the million.
*/
val destination: Boolean,
) : BagSweepQuote()
/**
* A hint whose box is past what this client sweeps, with the gap that
* decided it — so the card can say how far past, rather than only that it
* is. §7.7's own table runs from "seconds" to "days to never", and the far
* end is where a sector-only hint on a shallow bag lands: a gap of 75,
* which nobody will ever sweep. Such a hint "says where to travel, not
* where to search", and Amethyst cannot travel.
*/
@Immutable
data class OutOfReach(
val gapBits: Int,
) : BagSweepQuote()
/**
* Nothing to search from: no `hint` tag, a malformed one (§7.7 says those
* are the same thing), or no `d` tag to recognise the region by.
*
* Not an error in the bag. It is content hidden the hard way, and §7.7 is
* clear about what that means: "any given bag is equally likely to be at
* any point in the full 2^256 coordinate space".
*/
@Immutable
data object Hidden : BagSweepQuote()
}
/** Where a sweep has got to, for a card that has to show progress and a cancel. */
@Immutable
sealed class BagSweepState {
/** Pricing the first candidate on this device, before committing to the rest. */
@Immutable
data object Measuring : BagSweepState()
@Immutable
data class Running(
val examined: Long,
val candidates: Long,
) : BagSweepState()
/**
* The region key came up, and the bag opened.
*
* [contents] is null when it did not, which at this point means the
* ciphertext is damaged rather than that the key is wrong — the `lookup_id`
* already matched, and §7.2 makes that a hash of this very key.
*/
@Immutable
data class Opened(
val contents: CyberspaceBagContents?,
) : BagSweepState()
/** The whole box was swept and the bag was not in it: the hint was wrong, or it was bait. */
@Immutable
data class NotFound(
val examined: Long,
) : BagSweepState()
/**
* Measured, not guessed: the first candidate cost [millisPerCandidate] on
* *this* device, so the box would take [estimateMillis], which is past what
* this client spends without being asked again.
*/
@Immutable
data class OutOfReach(
val estimateMillis: Long,
val millisPerCandidate: Long,
) : BagSweepState()
}
/**
* §7.7's hint-and-sweep, priced for a reader with a battery.
*
* The protocol half is [RegionSweep]; this is the budget around it, and the
* budget is the entire product decision. A hint is a stranger's declaration of
* how hard they want the search to be, so everything here is arranged so the
* reader finds out the price before paying it:
*
* 1. [quote] reads the two tags and costs nothing. A box past [MAX_GAP_BITS] or
* a bag deeper than [MAX_BAG_HEIGHT] is out of reach and never becomes a
* button.
* 2. [sweep] times the first candidate on the device it is actually running on
* before committing to the rest, and stops if that measurement says the box
* is past [BUDGET_MILLIS]. The plan for this feature said to measure the
* Android cost before quoting a number in the UI; measuring it at the moment
* of the tap is the same answer without a constant that goes stale on the
* next handset.
* 3. The sweep is a cold [Flow] over a cold [Sequence], so a cancelled
* collection stops paying immediately.
*
* Nothing here talks to a relay, because the bag is already in hand: this is
* §7.7's search applied to one event someone put in front of you, not a crawl.
* And nothing starts on its own — [sweep] runs when it is collected.
*/
object BagSweep {
/**
* The largest box offered as a button, as §7.7's gap exponent.
*
* 2^20 is about a million candidate regions. §7.7's own table calls 12
* "seconds" and 24 "hours"; this sits between them, at the scale a phone
* can finish while someone watches. Past it the card says so instead, which
* is the honest answer to a hint that was chosen to be expensive.
*/
const val MAX_GAP_BITS = 20
/**
* The deepest bag this offers to search.
*
* Not about the box but about a single key: a region key is three folds of
* integers that double in width at every level, so one candidate at height
* 16 is seconds on its own and one at height 20 is minutes. ONOSENDAI draws
* the same line, capping its own discovery scan at height 12 and selling
* the deeper ones as a service.
*/
const val MAX_BAG_HEIGHT = 12
/**
* How long a sweep may be expected to take before it is refused outright.
*
* Two minutes is where §7.7's "seconds" has clearly ended, and it is
* checked against a measurement from this device rather than a table, so a
* slower phone refuses boxes a faster one accepts. That asymmetry is
* correct: the cost is the reader's, so the reader's own hardware decides.
*/
const val BUDGET_MILLIS = 120_000L
/** How many candidates pass between progress emissions. */
private const val PROGRESS_EVERY = 64L
/**
* What this bag's hint promises, from its tags alone.
*
* Costs two subtractions and an addition — safe to call while composing a
* feed row, and deliberately so, because the card has to be able to show
* the price of a search it will not run.
*/
fun quote(bag: CyberspaceBagEvent): BagSweepQuote {
if (!bag.isKnownVersion()) return BagSweepQuote.Hidden
if (bag.lookupId() == null) return BagSweepQuote.Hidden
if (bag.payload() == null) return BagSweepQuote.Hidden
val height = bag.height() ?: return BagSweepQuote.Hidden
val hint = bag.hint() ?: return BagSweepQuote.Hidden
val gap = hint.gapBits(height)
if (height > MAX_BAG_HEIGHT || gap > MAX_GAP_BITS) return BagSweepQuote.OutOfReach(gap)
val candidates = hint.candidates(height) ?: return BagSweepQuote.OutOfReach(gap)
return BagSweepQuote.Searchable(gap, candidates, hint.isDestination(height))
}
/**
* Sweep this bag's box, emitting progress, and open it if the key turns up.
*
* Cold: nothing runs until collected, and cancelling the collection stops
* the work at the next candidate. Collect it off the main thread — the
* arithmetic is arbitrary-precision and the whole point is that it is slow.
*/
fun sweep(bag: CyberspaceBagEvent): Flow<BagSweepState> =
flow {
val quote = quote(bag)
if (quote !is BagSweepQuote.Searchable) {
// Priced and declined before a tree was built. There is nothing
// to emit that the card did not already know from [quote].
return@flow
}
val height = bag.height() ?: return@flow
val hint = bag.hint() ?: return@flow
val target = bag.lookupId() ?: return@flow
emit(BagSweepState.Measuring)
// The box's own base region, priced on the way past. A gap-0 hint
// names exactly this one, so the measurement is never wasted work:
// it is the first candidate either way.
val mark = TimeSource.Monotonic.markNow()
val base = RegionSweep.of(CyberspaceHint(hint.base, height, height, height), height).first()
val perCandidate = mark.elapsedNow().inWholeMilliseconds
if (base.lookupId == target) {
emit(BagSweepState.Opened(bag.open(base.decryptionKey)))
return@flow
}
// One candidate here is three axis roots and a combine; every
// candidate after it is one combine, because §4.7's axes are reused
// across the box. So this over-quotes — by about four times at the
// shallow heights and two at the deep ones — and over-quoting is
// the safe direction for a number whose only job is to decide
// whether to spend somebody's battery.
val estimate = perCandidate * quote.candidates
if (estimate > BUDGET_MILLIS) {
emit(BagSweepState.OutOfReach(estimate, perCandidate))
return@flow
}
var examined = 0L
emit(BagSweepState.Running(examined, quote.candidates))
for (material in RegionSweep.of(hint, height)) {
currentCoroutineContext().ensureActive()
examined++
if (material.lookupId == target) {
emit(BagSweepState.Opened(bag.open(material.decryptionKey)))
return@flow
}
if (examined % PROGRESS_EVERY == 0L) emit(BagSweepState.Running(examined, quote.candidates))
}
emit(BagSweepState.NotFound(examined))
}
}
@@ -138,6 +138,7 @@ import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoShardEvent
@@ -3837,6 +3838,7 @@ open class EventCache :
is GeohashListEvent,
is SnoObjectEvent,
is SnoAvatarEvent,
is CyberspaceBagEvent,
is GitRepositoryEvent,
is GitRepositoryStateEvent,
is UserGraspListEvent,
@@ -0,0 +1,190 @@
/*
* 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.amethyst.commons.cyberspace
import com.vitorpamplona.quartz.cyberspace.CyberspaceBagContents
import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
/**
* The budget around §7.7's sweep, on a bag the reference implementation sealed.
*
* The payload and the `d` tag below are the london height-4 vector that
* `CyberspaceBagEventTest` pins against `cyberspace-cli`'s own cipher, so a
* sweep that finds it here has reproduced the hider's region key from nothing
* but a box — which is the whole claim §7.7 makes.
*/
class BagSweepTest {
/** `cyberspace-cli`'s AES-256-GCM over a plain text note, at the london height-4 key. */
private val payload = "AAECAwQFBgcICQoLu08nGyGW/7Vdw9GJIb/FveUqGhVW7YsSZuQ83LcizhHAJWNnQG/CHIcDKA=="
/** The aligned bases of the boxes around that coordinate, from the reference's own interleave. */
private val box444 = "c492492492492492492492edf5bee7267451c787d95ba4d7840c76d1e33c8000"
private val box666 = "c492492492492492492492edf5bee7267451c787d95ba4d7840c76d1e3380000"
private val box121212 = "c492492492492492492492edf5bee7267451c787d95ba4d7840c76c000000000"
private fun bag(vararg extra: Array<String>): CyberspaceBagEvent =
CyberspaceBagEvent(
id = "a".repeat(64),
pubKey = "b".repeat(64),
createdAt = 1700000000,
tags =
arrayOf(
arrayOf("d", "a1d82532c354e690c6bffdb1fb20ccda716e037586feaf70092cbc442a635916"),
arrayOf("version", "2"),
arrayOf("h", "4"),
arrayOf("encrypted", "aes-256-gcm", payload),
) + extra,
content = "",
sig = "0".repeat(128),
)
private fun hint(
base: String,
h: Int,
) = arrayOf("hint", base, h.toString(), h.toString(), h.toString())
@Test
fun aBoxIsPricedFromTwoTagsAndNothingElse() {
val quote = BagSweep.quote(bag(hint(box666, 6)))
assertIs<BagSweepQuote.Searchable>(quote)
assertEquals(6, quote.gapBits, "(6-4) three times")
assertEquals(64, quote.candidates)
assertEquals(false, quote.destination)
}
@Test
fun aHintThatNamesTheRegionIsStillOfferedAsASearch() {
// §7.7 calls three heights equal to the bag's "a destination the seeker
// can compute or walk to directly, not a search". It is still a tap: a
// reader who learned that some bags open themselves would have learned
// an expectation a hostile bag could hide inside.
val quote = BagSweep.quote(bag(hint(box444, 4)))
assertIs<BagSweepQuote.Searchable>(quote)
assertEquals(0, quote.gapBits)
assertEquals(1, quote.candidates)
assertTrue(quote.destination)
}
@Test
fun aBoxPastTheCapIsNeverOfferedAsAButton() {
// 3 x (12 - 4) = 24, which §7.7's own table calls "hours".
val quote = BagSweep.quote(bag(hint(box121212, 12)))
assertIs<BagSweepQuote.OutOfReach>(quote)
assertEquals(24, quote.gapBits)
}
@Test
fun aBagWithNoUsableHintIsHiddenRatherThanBroken() {
// §7.7: a malformed hint "MUST be treated as absent", and §7.6 says a
// bag nobody can find is not an error in the bag.
assertIs<BagSweepQuote.Hidden>(BagSweep.quote(bag()))
// Two hint tags is one of §7.7's malformed cases.
assertIs<BagSweepQuote.Hidden>(BagSweep.quote(bag(hint(box666, 6), hint(box444, 4))))
// And a box smaller than the region it claims to hold is another.
assertIs<BagSweepQuote.Hidden>(BagSweep.quote(bag(hint(box444, 3))))
}
@Test
fun aBagWhoseVersionIsUnknownIsNotSwept() {
// §8.6: "A reader MUST ignore a bag whose version it does not know."
val future =
CyberspaceBagEvent(
id = "a".repeat(64),
pubKey = "b".repeat(64),
createdAt = 1700000000,
tags =
arrayOf(
arrayOf("d", "a1d82532c354e690c6bffdb1fb20ccda716e037586feaf70092cbc442a635916"),
arrayOf("version", "3"),
arrayOf("h", "4"),
arrayOf("encrypted", "aes-256-gcm", payload),
hint(box666, 6),
),
content = "",
sig = "0".repeat(128),
)
assertIs<BagSweepQuote.Hidden>(BagSweep.quote(future))
}
@Test
fun sweepingTheBoxFindsTheRegionAndOpensTheBag() =
runTest {
val states = BagSweep.sweep(bag(hint(box666, 6))).toList()
assertIs<BagSweepState.Measuring>(states.first(), "priced on this device before the rest is spent")
val opened = assertIs<BagSweepState.Opened>(states.last())
val contents = assertIs<CyberspaceBagContents.Opaque>(opened.contents)
assertEquals("just some words, not a list", contents.bytes.decodeToString())
}
@Test
fun aDestinationHintOpensOnTheOneCandidateItNames() =
runTest {
// The measurement is never wasted: the box's own base region is the
// first candidate either way, so a gap-0 hint is answered by it.
val states = BagSweep.sweep(bag(hint(box444, 4))).toList()
assertEquals(2, states.size, "measure, then open")
assertIs<BagSweepState.Opened>(states.last())
}
@Test
fun aBoxTheHintWasWrongAboutIsSweptToTheEndAndSaysSo() =
runTest {
// The same box, against a bag addressed to a region that is not in
// it. Every candidate is derived and none matches, which is the
// honest outcome of a hint that was wrong or was bait.
val elsewhere =
CyberspaceBagEvent(
id = "a".repeat(64),
pubKey = "b".repeat(64),
createdAt = 1700000000,
tags =
arrayOf(
arrayOf("d", "f".repeat(64)),
arrayOf("version", "2"),
arrayOf("h", "4"),
arrayOf("encrypted", "aes-256-gcm", payload),
hint(box666, 6),
),
content = "",
sig = "0".repeat(128),
)
val states = BagSweep.sweep(elsewhere).toList()
val notFound = assertIs<BagSweepState.NotFound>(states.last())
assertEquals(64, notFound.examined, "the whole box, and not one region more")
}
@Test
fun aQuoteThatWasDeclinedEmitsNothingAtAll() =
runTest {
// Nothing is built for a box the card already refused, which is the
// point of pricing from the tags: the refusal costs no arithmetic.
assertTrue(BagSweep.sweep(bag(hint(box121212, 12))).toList().isEmpty())
assertTrue(BagSweep.sweep(bag()).toList().isEmpty())
}
}
@@ -5332,4 +5332,23 @@
<string name="sno_shard_ideaspace">Hidden in ideaspace · %1$s</string>
<string name="sno_avatar_default">Default avatar</string>
<string name="sno_avatar_default_details">No shape published, so everyone sees this one</string>
<string name="cyberspace_bag_title">Hidden at a place</string>
<string name="cyberspace_bag_box">Hidden in a box of %1$s regions</string>
<string name="cyberspace_bag_destination">Hidden in one region, which the hint names exactly</string>
<string name="cyberspace_bag_no_hint">No hint, so this could be anywhere in cyberspace</string>
<string name="cyberspace_bag_out_of_reach">Out of reach: the hint names 2^%1$d regions to search</string>
<string name="cyberspace_bag_search">Search for it</string>
<string name="cyberspace_bag_cancel">Stop</string>
<string name="cyberspace_bag_measuring">Pricing the search on this device…</string>
<string name="cyberspace_bag_progress">Searched %1$s of %2$s regions</string>
<string name="cyberspace_bag_too_slow_minutes">Out of reach: about %1$d minutes of searching on this device</string>
<string name="cyberspace_bag_too_slow_hours">Out of reach: about %1$d hours of searching on this device</string>
<string name="cyberspace_bag_not_found">Not in the box the hint named</string>
<string name="cyberspace_bag_damaged">Found the region, but the contents could not be read</string>
<string name="cyberspace_bag_dropped">%1$d item hidden here did not match its signature and was dropped</string>
<string name="cyberspace_bag_dropped_many">%1$d items hidden here did not match their signatures and were dropped</string>
<string name="cyberspace_bag_empty">Opened, and there was nothing inside</string>
<string name="cyberspace_bag_unsigned">Unsigned, so this author is a claim</string>
<string name="cyberspace_bag_unknown_kind">An item of kind %1$d, which this client does not draw</string>
<string name="cyberspace_bag_opaque">%1$d bytes of something this client does not read</string>
</resources>
@@ -198,9 +198,12 @@ is not offered as a button at all — it is reported as out of reach.
3. **Hints.** Parse, validate, price. `amy cyberspace hint`. The three golden
vectors. **Done** — see §10.
4. **The bag.** AES-256-GCM, plaintext shapes, item verification. `amy cyberspace
open`, round-tripped against the reference CLI's `encrypt`.
5. **The sweep**, as a budgeted cold sequence. `amy cyberspace sweep`.
open`, round-tripped against the reference CLI's `encrypt`. **Done** — see §11.
5. **The sweep**, as a budgeted cold sequence. `amy cyberspace sweep`. **Done** —
see §11.
6. **The card**, last, once every number it quotes is measured on a device.
**Done** — see §12, which measures them on the device at the tap rather than
baking in a constant.
Steps 1 to 5 have no product risk and every one of them is diffable against a
reference implementation. Step 6 is the only judgement call, and it is small.
@@ -308,3 +311,108 @@ The canonical-integer rule is worth keeping for the same reason the aligned base
is: both exist so that two hiders who hint the same box publish the same bytes,
and a reader that accepts `"05"` alongside `"5"` lets one box have two
spellings and breaks comparison by equality.
## 11. Steps 4 and 5 as built
`CyberspaceBagEvent` and `RegionSweep` in quartz, `amy cyberspace open` and
`amy cyberspace sweep` over them, and two more harness sections. The harness is
**15 of 15**.
### What the two new sections actually prove
Everything before this diffed a *number* against the reference — a key, a
lookup id, a hint tag. These diff a **ciphertext**, which is the only test that
catches a chain that agrees at every step and still cannot open a bag.
`cyberspace-cli` derives the region key with `location_encryption`, seals the
plaintext with `encrypt_with_location_key`, and writes the §8.6 tags with
`make_encrypted_content_event`. `amy` is handed the event and a coordinate and
has to reach the same 32 bytes: §2.2's interleave, §4.7's three Cantor roots,
§7.2's two hashes, §7.6's `nonce || ciphertext || tag`. Section 8 then takes the
coordinate away and makes it find the same bag from its hint box alone.
The box in section 8 comes from the reference's own `coord_to_xyz` /
`xyz_to_coord` rather than from ours, so a disagreement about alignment shows up
as a bag that is not in the box we were handed — not as a test grading its own
arithmetic.
### The two design decisions worth recording
**`open` exits 0 on the wrong key.** §7.6: "A failed decryption therefore means
only that the reader does not hold this region's key; it MUST NOT be treated as
an error in the bag." So a wrong key is a verdict (`opened: false`), the way
`sno parse` reports an invalid payload, and the harness asserts the exit code as
well as the field. What *does* fail is a bag that cannot be attempted at all: an
unknown `version` (§8.6 says ignore it) or no `aes-256-gcm` payload to try.
**`sweep` refuses before it spends.** The gap is read from the `hint` and `h`
tags and checked against `--max-gap` (default 20) before a single tree is built,
and the refusal quotes the exponent and names the flag that would buy it. A
harness case pins it: a gap-33 hint is declined rather than swept. This is the
same shape the card needs, which is why it lives in the CLI first — a budget
that can be tested in a shell script is a budget that can be trusted in a feed.
### A shell trap worth remembering
`jq`'s `//` treats `false` as empty, so `.opened // "null"` turns a correct
`false` into `"null"` and fails a passing test. Read booleans with a plain
`jq -r .field`.
## 12. Step 6 as built
`BagSweep` in `commons/cyberspace/` (headless: the quote, the budget and the
cold flow) and `RenderCyberspaceBag` in `amethyst/ui/note/types/` (the card),
wired into `NoteCompose` and `ThreadFeedView`, with kind 33330 routed through
`EventCache`'s addressable path. Nine tests in `commons`.
### Pricing without a constant
§3 of this plan ended with "Android will be slower — measure it before quoting a
number in the UI". What shipped measures it **at the moment of the tap, on the
device that is about to pay**, which is the same answer without a constant that
goes stale on the next handset. The order is:
1. **Free, while the row composes.** `BagSweep.quote` reads two tags and does
three subtractions. A box past `MAX_GAP_BITS` (20) or a bag deeper than
`MAX_BAG_HEIGHT` (12, the ceiling ONOSENDAI puts on its own discovery scan)
is reported as out of reach and never becomes a button. No Cantor tree is
built for a hint the reader has already declined — a test pins that the flow
emits nothing at all in that case.
2. **One candidate, timed.** On the tap, the box's own base region is derived
and the elapsed time recorded. That candidate is never wasted work: a gap-0
hint names exactly it, so the measurement *is* the search for a destination
hint.
3. **The estimate, then the rest.** `perCandidate × candidates` against a
two-minute budget. It over-quotes — the first candidate is three axis roots
plus a combine and every later one is a combine, so by about 4x at shallow
heights and 2x at deep ones — and over-quoting is the safe direction for a
number whose only job is to decide whether to spend somebody's battery.
A slower phone therefore refuses boxes a faster one accepts. That asymmetry is
correct: the cost is the reader's, so the reader's hardware should decide.
### What the card says, and what it never does
Every sweep is a tap, **including a gap-0 hint**, which §7.7 itself calls "a
destination the seeker can compute or walk to directly, not a search" and which
costs about a frame. A reader who learned that some bags open themselves would
have learned an expectation a hostile bag could hide inside, so the one
candidate is offered exactly like the million. A test pins it.
Scrolling the row away cancels: the flow is cold over a cold sequence and the
card disposes its job, so a sweep never outlives the card that asked for it.
Items render through the cards that already exist — a `3330` shard through
`SnoObjectCard` (palette fetched by `WithSnoPalette`, as a standalone object
would be), a `kind 1` as its text, anything else named and skipped, because
§7.6 says a reader that does not understand an item's kind "skips it and renders
the rest". The attribution is the non-obvious part and §7.6 fixes it: placement
belongs to the bag's author, authorship only to a signed item's own pubkey. An
unsigned item therefore carries a line saying its author is a claim, rather than
borrowing either name.
### One correction carried back into quartz
`SnoShardEvent`'s KDoc said Amethyst "implements none of it" and that a reader
without the key sees base64 and nothing else. Both were true when it was
written. The class now points at `RegionSweep` and says what actually decides
whether a bag opens — whether its own hint prices the search into reach.
@@ -47,14 +47,16 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
*
* Not for want of somewhere to stand — §7.7 is explicit that "the seeker's own
* position never enters this cost, because §7.1 makes looking and walking
* equivalent", so a client with no avatar could open a hinted bag. What stops
* it is the work: a key is three `O(2^h)` folds of BigInts that double in width
* every level, which the spec measures at 816 ms per key at height 16 on a
* desktop core and which grows about 2.2x per height above that. Amethyst
* implements none of it and a reader without the key sees base64 and nothing
* else — §7.6 is explicit that a failed decryption "MUST NOT be treated as an
* error in the bag". What this class reads is a shard handed over directly:
* quoted in a note, or fetched by id.
* equivalent", so a client with no avatar can open a hinted bag, and
* [com.vitorpamplona.quartz.cyberspace.RegionSweep] does. What decides whether
* it is worth trying is the work: a key is three `O(2^h)` folds of BigInts that
* double in width every level, which the spec measures at 816 ms per key at
* height 16 on a desktop core and which grows about 2.2x per height above that,
* and a hint's box multiplies that by up to 2^75. So a bag is opened only when
* its own hint prices the search into reach, and a reader who does not get
* there sees base64 and nothing else — §7.6 is explicit that a failed
* decryption "MUST NOT be treated as an error in the bag". A shard can also
* arrive with no bag at all: quoted in a note, or fetched by id.
*
* **An item MAY be unsigned** (§7.6, and §6 of the deck), in which case its
* `pubkey` is a claim and a client MUST NOT present it as verified authorship.