techspace

package
v0.4.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 23, 2026 License: MIT Imports: 27 Imported by: 0

Documentation

Overview

Package techspace is the hidden account-level space: a derived space with owner-only ACL, used as the account's index of spaces, chat-read tracker, and (later) account preferences.

Derived deterministically from the account key, so it is always re-creatable. Never returned as a Space from the public API — callers interact through dedicated space-level methods (the space list is exposed via space.SpaceService).

Implements space.Indexer, which is wired at sdk.Open: Space creation and deletion flow through techspace to update the space index.

Handlers in this package:

  • SpaceIndexHandler (spaceindex.go) — validates writes to the derived space-index object (one record per space). Unique to the tech space; does not appear on regular user objects.

See docs/tech-space.md.

Index

Constants

View Source
const (
	// CRDTVersionDataset is the mark's dataset on the space-index object.
	CRDTVersionDataset = "crdtVersion"
	// CRDTVersionHandlerVersion is stamped on every change to it.
	CRDTVersionHandlerVersion = "crdtVersionHandler-v1"
	// CRDTVersionRecordId is the single record's id.
	CRDTVersionRecordId = "crdtVersion"
	// FieldCRDTVersion holds the version number.
	FieldCRDTVersion = "version"
)
View Source
const (
	// DevicesDataset name on the space-index tree. Piggybacks on the
	// index object like profile/identities — one extra handler reg, no
	// second derived tree.
	DevicesDataset = "devices"

	// DevicesHandlerVersion stamped on every change.
	DevicesHandlerVersion = "devicesHandler-v1"

	// FieldDeviceName is the device's display name (hostname or
	// user-set).
	FieldDeviceName = "name"
	// FieldDeviceOS is the device's operating system (runtime.GOOS
	// vocabulary by convention).
	FieldDeviceOS = "os"
	// FieldDeviceVersion is the device's engine build version.
	FieldDeviceVersion = "version"
	// FieldDeviceApps is the set of installed apps: a free-form object
	// keyed by app slug (an open set — nothing app-specific is
	// hardcoded), each value a free-form scalar bag ({version: ...}).
	// Presence of the slug = installed; written / removed per-slug
	// (apps.<slug>) so devices touching different slugs merge.
	FieldDeviceApps = "apps"
	// FieldDeviceActiveClaims holds the per-app active claims, keyed by
	// app slug: {seq, at} written by Service.ClaimActive. The claim is
	// writer-supplied data — NOT a CRDT version id, which is
	// peer-locally allocated and therefore not comparable across
	// devices. The winner is computed by every reader with the same
	// deterministic rule (space.ActiveDevice); concurrent claims may
	// collide on seq and are survivable via the tiebreak.
	FieldDeviceActiveClaims = "activeClaims"
)
View Source
const (
	// DeviceClaimSeq is the writer-supplied claim sequence:
	// max(existing seqs for the slug across all rows) + 1 at claim
	// time. Highest wins.
	DeviceClaimSeq = "seq"
	// DeviceClaimAt is the claim wall-clock time in unix seconds —
	// first tiebreak on equal seq (advisory, writer-supplied).
	DeviceClaimAt = "at"
)

FieldDeviceActiveClaims subkeys — the claim shape.

View Source
const (
	// IdentitiesDataset name on the space-index tree. Piggybacks on the
	// space-index tree like ProfileDataset — one extra handler reg.
	IdentitiesDataset = "identities"

	// IdentitiesHandlerVersion stamped on every change.
	IdentitiesHandlerVersion = "identitiesHandler-v1"

	// FieldIdentitySymKey holds the contact's metadata symkey (synced).
	FieldIdentitySymKey = "symKey"
	// FieldIdentityName / Description / Icon hold the cached identityRepo
	// profile (device-local).
	FieldIdentityName        = "name"
	FieldIdentityDescription = "description"
	FieldIdentityIcon        = "iconCID"
	// FieldIdentitySpaceIds is the set of spaces where we've seen this
	// identity (device-local array of space ids).
	FieldIdentitySpaceIds = "spaceIds"
)
View Source
const (
	// InboxCursorDataset name on the space-index tree. Piggybacks on the
	// space-index tree like ProfileDataset — one extra handler reg.
	InboxCursorDataset = "inboxCursor"

	// InboxCursorSelfId is the only valid record id (account-private, one
	// writer per field at the ACL layer).
	InboxCursorSelfId = "self"

	// InboxCursorHandlerVersion stamped on every change.
	InboxCursorHandlerVersion = "inboxCursorHandler-v1"

	// FieldInboxCursorOffset holds the ObjectID-hex offset.
	FieldInboxCursorOffset = "offset"
)
View Source
const (
	// ProfileDataset name on the space-index tree. We piggyback on the
	// space-index tree to avoid a second derived tree just for one
	// row — the controller supports multiple datasets per tree, so the
	// extra cost is one handler registration.
	ProfileDataset = "profile"

	// ProfileSelfId is the only valid record id in the dataset. The
	// account's identityRepo profile is account-private (one writer);
	// no need for a per-id keyspace.
	ProfileSelfId = "self"

	// ProfileHandlerVersion stamped on every change. Bump when adding
	// new validated fields.
	ProfileHandlerVersion = "profileHandler-v1"

	// FieldProfileName / FieldProfileDescription / FieldProfileIcon
	// match the on-the-wire layout EncodeAccountMetadata produces.
	FieldProfileName        = "name"
	FieldProfileDescription = "description"
	FieldProfileIcon        = "iconCID"
)
View Source
const (
	FieldType        = "type"
	FieldName        = "name"
	FieldDescription = "description"
	FieldIcon        = "icon"
	// FieldLocalStatus is a DEVICE-LOCAL field (schema.ScopeLocal):
	// per-device lifecycle (active, the incoming-1-1 pending prompt, the
	// loading markers, guest revoked) that must NOT sync — a space
	// offloaded on one device must stay loaded on another, and the value
	// is meaningless offline or on a different network. Written only via
	// Service.SetLocalStatus → Object.LocalSet; never enters the DAG.
	// Absence means active.
	//
	// The join lifecycle does not live here: a pending join is
	// account-wide state (FieldRemoteStatus = JoiningRemoteStatus /
	// JoinEndedRemoteStatus). The legacy device-local shapes — "joining",
	// and "deleted" over a synced active — are still READ (mapStatus,
	// SpaceIndexRecord.JoinEnded / LocalDeleteStands) so rows written
	// before the move keep their meaning, but nothing writes them any
	// more; the next verdict on such a row lands in the synced form.
	FieldLocalStatus = "localStatus"
	// FieldRemoteStatus is synced (account-wide). It carries the
	// account-wide delete signal (StatusDeleted) so every device drops
	// the space — the handler keeps that value terminal — and the
	// account-wide pending states: the direct-add invite pair
	// (InvitePendingRemoteStatus / InviteDeclinedRemoteStatus) and the
	// request-to-join pair (JoiningRemoteStatus / JoinEndedRemoteStatus).
	// Pending states are synced so every device of the account classifies
	// the row the same way: none materializes a space the account is not
	// a member of, and a verdict observed on one device converges the
	// others.
	FieldRemoteStatus = "remoteStatus"
	// FieldAclHeadId is a DEVICE-LOCAL field (schema.ScopeLocal): an ACL
	// head id at-or-after this account's join request, recorded on the
	// joining row so this device's post-acceptance waiter can detect a
	// decline (the join record gone from the chain while this head
	// exists). A per-device credential, not lifecycle state: the
	// requesting device stores the head RequestJoin returned, and a
	// device that learns of the join from the synced row resolves one
	// from the chain before it starts a waiter — an empty head means
	// "acceptance only". Cleared whenever the row leaves joining: a head
	// from an earlier request would satisfy the waiter's decline test
	// while a later request is still pending on a lagging replica.
	// Written via Service.SetAclHeadId → Object.LocalSet. Absent on rows
	// that never went through Join.
	FieldAclHeadId = "aclHeadId"
	// FieldSpaceType mirrors the in-space spaceIndex.spaceType app tag.
	// Distinct from FieldType (the on-wire header type): not pinned, so
	// the watcher can mirror the converged value.
	FieldSpaceType = "spaceType"
	// FieldOneToOneInviteState is a DEVICE-LOCAL field (schema.ScopeLocal)
	// tracking whether this device still owes the peer an inbox
	// notification for a 1-1 it initiated. Value "toSend" means the
	// send-retry loop should (re)deliver the InboxPayloadOneToOneInvite;
	// cleared (absent/"") once the coordinator confirms the send. Local
	// because delivery is a per-device obligation — only the device that
	// initiated owes the notification, and the obligation is meaningless
	// after it's met. Written via Service.SetOneToOneInviteState.
	FieldOneToOneInviteState = "oneToOneInviteState"
	// FieldInviteNotifyPending is a DEVICE-LOCAL array (schema.ScopeLocal)
	// of account identities this device still owes a RegularInvite inbox
	// notification for, after a successful ACL AddAccounts on this space.
	// Entries are removed one-by-one on confirmed delivery (or when
	// permanently undeliverable). Local because delivery is a per-device
	// obligation — only the device that ran AddAccounts owes it. The
	// many-receiver generalization of FieldOneToOneInviteState. Written via
	// Service.AddInviteNotify / ClearInviteNotify.
	FieldInviteNotifyPending = "inviteNotifyPending"
	// FieldOneToOnePeer is the other participant's account identity on a
	// derived 1-1 row (synced, account-wide). It is the one piece of state
	// a spaceId does not encode invertibly, and it is required to
	// materialize the space's storage (DeriveOneToOneSpace needs the peer
	// pubkey). Stored on the pending row so AcceptOneToOne can build
	// storage, and on active rows so any of the account's devices can
	// re-derive. Empty on non-1-1 rows.
	FieldOneToOnePeer = "oneToOnePeer"
	// FieldSettings is the client-writable, free-form per-space settings
	// object (SYNCED): account-private via the tech-space's owner-only
	// ACL, replicated across the account's devices, invisible to other
	// space members. Keys are the client's own vocabulary — the SDK
	// stores them verbatim under per-key paths (settings.<key>) via
	// Service.SetSettings, so concurrent edits to DIFFERENT keys merge
	// instead of last-writer-wins on the whole object. SDK-owned fields
	// stay siblings of `settings`, never inside it, and the spaceIndex
	// mirror (SetSpaceMetadata) never touches it.
	FieldSettings = "settings"
	// FieldCreatedAt is the added-to-account time: a datetime instant,
	// stamped by BeforeCreate from the creating change's timestamp when
	// the row first lands locally (Create / Derive / OneToOne / Join all
	// create the row once). ScopeDerived — handler-only, no input op may
	// write it, so it's immutable for life.
	//
	// Caveats (accepted — the value is advisory ordering metadata):
	//   - stamping is per-device first-touch: a device whose store
	//     materialized the row under an older handler reads nothing until
	//     a re-index replays the row's create change (SpaceIndexLocalVersion
	//     drives exactly that), while a device replaying the same DAG with
	//     this handler stamps the real value;
	//   - two devices independently creating the same row (e.g. both
	//     Derive/Join before tech-space sync converges) each keep their
	//     own change's timestamp — typically seconds apart.
	// Callers treat an absent stamp as "unknown" (SpaceIndexRecord
	// reports it as 0).
	FieldCreatedAt = "createdAt"
)

Space-index record fields. The shape is hardcoded — tech space is account-private; no cross-version writers to negotiate with.

View Source
const (
	// FieldPushKeys is a DEVICE-LOCAL object (schema.ScopeLocal) holding
	// the space's push-notification key material, mirrored from ACL
	// state by spaceimpl's per-space ACL mirror watcher so clients can
	// read it off the row (and its subscribe stream), cache it, and
	// decrypt push payloads while the SDK process is down. Subfields:
	// PushKeySpaceKey / PushKeyEncKey / PushKeyEncKeyId. Local, not
	// synced, because every device derives the same values from the
	// same converged ACL — syncing would only add DAG writes. Written
	// via Service.SetPushKeys → Object.LocalSet; absent until the
	// mirror first runs (e.g. joiner without read access yet).
	FieldPushKeys = "push"
	// FieldOwnRole is a DEVICE-LOCAL string (schema.ScopeLocal): this
	// account's own ACL permission in the space, in the canonical
	// space.Permission wire vocabulary ("owner" / "admin" / "writer" /
	// "reader" / "guest" / "none"). Mirrored from ACL state by the same
	// per-space ACL watcher that maintains FieldPushKeys — local, not
	// synced, for the same reason (every device derives it from the
	// same converged ACL). Written via Service.SetOwnRole →
	// Object.LocalSet; absent until the mirror first runs, which
	// readers must treat as "unknown yet", not as no-access.
	FieldOwnRole = "ownRole"
	// FieldGuestKey marks a guest-mode (public-access) space: the shared
	// read-only guest identity's private key, string-encoded, written
	// once by Service (JoinGuest path) when the account adds the space
	// via a guest invite. SYNCED — any of the account's devices opens
	// the space with the same identity. Presence is the guest-mode
	// discriminator for space loading and the write gate; empty on all
	// other rows. Account-private via the tech space's owner-only ACL.
	FieldGuestKey = "guestKey"
	// FieldIssuedInviteKeys is this account's custody of the invite
	// private keys it has issued for the space, one subkey per kind
	// (IssuedKeyMember / IssuedKeyGuest), each a string-encoded private
	// key. SYNCED so every device of the issuing account can re-show or
	// revoke the same invite (the private part is not recoverable from
	// the ACL — records carry only the public key). Per-kind subkeys
	// merge per-path: minting a member invite on one device and a guest
	// key on another never clobber each other. Written / cleared via
	// Service.SetIssuedInviteKey by ACL.CreateInvite / CreateGuestKey
	// and the revoke paths. Distinct from FieldGuestKey so an issuer's
	// own row never reads as guest-mode.
	FieldIssuedInviteKeys = "issuedInviteKeys"
	// FieldDerived marks a row written by the account's own
	// Spaces().Derive (SYNCED bool, stamped at row create or healed by
	// SetDerived; set-once — the handler pins it like `type`). Gates
	// every delete refusal for derived spaces, including the handler's
	// own remoteStatus=deleted rejection; why they are permanent is on
	// space.ErrIsDerivedSpace. Absent on created / joined / tracked /
	// 1-1 rows.
	FieldDerived = "derived"
	// FieldP2PAdvertise (synced, account-wide) is the per-space p2p
	// advertising switch: false stops this account's devices from
	// publishing their global p2p row into the space, so other members
	// cannot dial them there; absent means on. Own devices find each
	// other through the account record regardless.
	FieldP2PAdvertise = "p2pAdvertise"
)
View Source
const (
	// IssuedKeyMember is the RequestToJoin member-invite key custody —
	// written by ACL.CreateInvite (any account permitted to mint).
	IssuedKeyMember = "member"
	// IssuedKeyGuest is the shared read-only guest identity custody —
	// written by ACL.CreateGuestKey (owner only).
	IssuedKeyGuest = "guest"
)

FieldIssuedInviteKeys subkeys — the issued-key kinds. Slugs, not space.InviteKind bytes, so the row stays readable.

View Source
const (
	PushKeySpaceKey = "spaceKey"
	PushKeyEncKey   = "encKey"
	PushKeyEncKeyId = "encKeyId"
)

FieldPushKeys subfield names — the wire shape of the `push` object. Values are encoded exactly like anytype-heart's space-view details (spacePushNotificationKey / spacePushNotificationEncryptionKey) so receiver-side client code is portable: spaceKey is base64(std) of the protobuf-marshalled ed25519 private key, encKey base64(std) of the raw AES key, encKeyId hex(sha256(raw)) = pushapi.Message.KeyId.

View Source
const (
	StatusActive   = "active"
	StatusArchived = "archived"
	StatusDeleted  = "deleted"

	// OneToOneDeletedStatus is the SYNCED remoteStatus written when a 1-1
	// space is deleted. Unlike StatusDeleted it is NOT terminal and never
	// drives a coordinator SpaceDelete: a derived 1-1 is not removed from
	// the nodes, only offloaded on every device. It propagates the delete
	// account-wide (each device offloads its local copy) yet stays
	// re-creatable — a later OneToOne(peer) flips the row back to active.
	// Surfaced to callers as space.StatusDeleted.
	OneToOneDeletedStatus = "oneToOneDeleted"

	// GuestDeletedRemoteStatus is the SYNCED remoteStatus written when a
	// guest-mode (public-access) space is deleted. Like the 1-1 marker
	// it is NOT terminal and never drives a coordinator SpaceDelete: a
	// guest space is not owned on the network, only offloaded on every
	// device, and it stays re-addable — a later JoinGuest with a valid
	// token flips the row back to active. Surfaced as
	// space.StatusDeleted.
	GuestDeletedRemoteStatus = "guestDeleted"

	// InvitePendingRemoteStatus is the SYNCED remoteStatus on a regular
	// space another account added us to directly (ACL AddAccounts). We are
	// already a full ACL member; approval is a local materialization gate.
	// Synced — unlike the device-local 1-1 pending — because the synced
	// inbox cursor means only ONE of our devices processes the invite
	// message, so the row itself must carry pending to the others. NOT
	// terminal: AcceptInvite flips it to active, DeclineInvite to declined.
	InvitePendingRemoteStatus = "invitePending"
	// InviteDeclinedRemoteStatus is the SYNCED, sticky, NON-terminal
	// remoteStatus written when a direct-add invite is declined. Suppresses
	// the request on every device; a later AcceptInvite overrides it. No
	// ACL write happens on decline — the account stays an ACL member.
	InviteDeclinedRemoteStatus = "inviteDeclined"

	// JoiningRemoteStatus is the SYNCED remoteStatus on a regular space
	// this account has a RequestToJoin pending on. Written by Join when
	// the request is posted (or found already on the chain), so every
	// device reads the row as joining and none materializes the space
	// before the owner's verdict. NOT terminal: the device that observes
	// acceptance and loads the space flips it to active; a decline or
	// CancelJoin moves it to JoinEndedRemoteStatus. Surfaced as
	// space.StatusJoining.
	JoiningRemoteStatus = "joining"
	// JoinEndedRemoteStatus is the SYNCED, NON-terminal remoteStatus of a
	// join that ended without membership — the owner declined, or the
	// account withdrew the request (CancelJoin). Classified as deleted
	// (IsDeleted): the row leaves the live list on every device and
	// nothing loads it, but the shape is unambiguous and re-enterable —
	// Join with a valid invite revives it to JoiningRemoteStatus, and a
	// direct add (ACL AddAccounts) registers over it as
	// InvitePendingRemoteStatus, because the account-wide truth it
	// records is "not a member". Surfaced as space.StatusDeleted.
	JoinEndedRemoteStatus = "joinEnded"
)

Status lattice values. `Deleted` is terminal — once a record's localStatus or remoteStatus reaches it, no further status edits land. Per docs/tech-space.md § "Key Decisions (continued)": "deleted spaces stay in the index with status=deleted, never physically removed."

View Source
const HandlerVersion = "spaceIndexHandler-v1"

HandlerVersion is the DataVersion string stamped on every change this handler emits. Tech-space datasets are account-private (owner-only ACL), so the only writer is the SDK itself; bump the suffix when the schema changes in a way that must reject stale writers.

View Source
const SpaceIndexDataset = "spaces"

SpaceIndexDataset is the name of the dataset on the space-index object that holds one record per space. Record id is the spaceId; fields are the caller-visible space metadata (type, name, icon, localStatus, remoteStatus, …) per docs/tech-space.md § "Space Index".

View Source
const SpaceIndexDeriveSeed = "builtin:spaceIndex"

SpaceIndexDeriveSeed mints the same space-index object id on every device for a given account. Tech space has exactly one space-index object, always derived from this seed, always owned by the account's own (owner-only) ACL.

View Source
const SpaceIndexLocalVersion = 2

SpaceIndexLocalVersion is the spaces handler's LOCAL logic version (HandlerReg.Version) — bumped when already-materialized rows would come out different, so the SDK rebuilds them from the DAG (docs/versioning.md). v2: the derived createdAt stamp is a TypeDateTime instant, not an epoch number.

View Source
const TechSpaceType = "any.techspace"

TechSpaceType is the on-the-wire SpaceType stamped into the tech-space's space header. The `any` product's own value — distinct from anytype-heart's "anytype.techspace", so an SDK account never shares a tech space with a heart client, whatever its derivation index. Headers carrying it declare fileproto v2 (coordinator- enforced; see spacestatus/changeverifier.go there).

Variables

View Source
var (
	ErrSettingsEmpty      = space.ErrSettingsEmpty
	ErrSettingsBadKey     = space.ErrSettingsBadKey
	ErrSettingsBadValue   = space.ErrSettingsBadValue
	ErrSettingsKeyOverlap = space.ErrSettingsKeyOverlap
)

Settings-patch sentinels — wrapped by SettingsOps validation errors so callers can classify with errors.Is. Canonical homes are in the public space package; aliased here for internal callers.

View Source
var (
	ErrTypeImmutable      = errors.New("techspace: `type` is pinned after first non-empty write")
	ErrStatusTerminal     = errors.New("techspace: status=deleted is terminal")
	ErrDeleteOpNotAllowed = errors.New("techspace: deletion is via remoteStatus=deleted, not a delete op")
	ErrDerivedImmutable   = errors.New("techspace: `derived` is pinned after first true write")
	ErrDerivedUndeletable = errors.New("techspace: derived rows refuse status=deleted")
)

Sentinels — wrap crdt.ErrValidation in handler returns.

View Source
var ErrSpaceRegistryUnknown = errors.New("techspace: unknown (spaceId, treeId)")

Functions

func CRDTVersionSchema

func CRDTVersionSchema() schema.Dataset

CRDTVersionSchema declares the mark: one synced integer.

func DecodeDeviceRecord

func DecodeDeviceRecord(v *anyenc.Value) space.Device

DecodeDeviceRecord lifts a devices row (as returned by Controller.Get / Records) into the public space.Device. The row id (the peer id) is read from the CRDT-stamped "id" field. Returns the zero value for nil input.

func DeviceUpsertOps

func DeviceUpsertOps(a *anyenc.Arena, up space.DeviceUpsert) ([]crdt.Op, error)

DeviceUpsertOps encodes a space.DeviceUpsert into per-path CRDT ops on the caller's own row: one OpSet per non-empty scalar field, one OpSet at [apps, slug] per app entry (or OpUnset for a nil entry — the uninstall signal). Per-path so writes touching different fields / slugs merge instead of clobbering. Exported for the handler-level tests. App info values are scalars only, same vocabulary as settings (string / bool / number, stored as float64).

func DevicesSchema

func DevicesSchema() schema.Dataset

DevicesSchema declares the devices dataset. All fields synced — the per-device fields elsewhere in the tech space (localStatus, ownRole, …) are ScopeLocal and never cross devices; that scope would make this registry invisible exactly where it's needed.

func IdentitiesIndexes

func IdentitiesIndexes() []anystore.IndexInfo

IdentitiesIndexes backs by-space queries ("which identities are in space X") — an array (multi-key) index on the spaceIds set, used by RemoveSpaceFromIdentities and consumer queries.

func IdentitiesSchema

func IdentitiesSchema() schema.Dataset

IdentitiesSchema declares the synced symkey field plus the device-local profile/sightings cache.

func InboxCursorSchema

func InboxCursorSchema() schema.Dataset

InboxCursorSchema declares the single synced offset field.

func ProfileSchema

func ProfileSchema() schema.Dataset

ProfileSchema declares the `profile` dataset fields — the account's own profile, synced across its devices.

func SettingsOps

func SettingsOps(a *anyenc.Arena, set map[string]any, unset []string) ([]crdt.Op, error)

SettingsOps encodes a settings patch into per-path CRDT ops: one OpSet at ["settings", key] per set entry, one OpUnset per unset entry. Exported so the handler-level tests can apply the exact ops SetSettings emits without booting the full service.

Validation (v1 keeps the shape deliberately narrow):

  • at least one entry across set+unset;
  • keys non-empty and dot-free (single-level keys under `settings` — a dotted key would silently become a deeper path);
  • no key in both set and unset (op order within one change would otherwise decide the winner);
  • values are scalars only: string, bool, or any Go numeric type (encoded as anyenc float64, the wire's only number shape).

Set keys are emitted in sorted order (map iteration is random; deterministic ops keep changes reproducible), unset keys in caller order.

func SpaceIndexSchema

func SpaceIndexSchema() schema.Dataset

SpaceIndexSchema declares the `spaces` dataset fields and their class. Synced metadata mirrors across the account's devices; localStatus is per-device (never synced); remoteStatus carries the account-wide delete and the account-wide pending states (invite, join). Used as the controller's enforced schema and surfaced to consumers via discovery.

func SystemDatasets

func SystemDatasets() []spaceobjects.SystemDataset

SystemDatasets lists the tech-space datasets registered on every controller of the tech Store as ungated built-ins, with the DataVersion stamp their typed writers use. Also the set the public write surface of the tech-space handle refuses. The CRDT version handler here carries no service hook; a live service registers its own through systemDatasets.

Types

type CRDTVersionHandler

type CRDTVersionHandler struct {
	OnVersion func(version int)
}

CRDTVersionHandler validates ops on the crdtVersion dataset: the one record, a positive integer version that never decreases, no deletes. OnVersion, when set, is told every version the handler admits — the service's hook for noticing a newer mark arriving through sync. It never influences the verdict, so replicas stay deterministic.

func (CRDTVersionHandler) BeforeCreate

func (h CRDTVersionHandler) BeforeCreate(_ *crdt.ChangeCtx, rec *crdt.RecordChange, _ *crdt.Sink) error

func (CRDTVersionHandler) BeforeDelete

func (CRDTVersionHandler) BeforeDelete(_ *crdt.ChangeCtx, _ *crdt.RecordChange, _ *crdt.Sink) error

BeforeDelete refuses: the mark is permanent.

func (CRDTVersionHandler) BeforeModify

func (h CRDTVersionHandler) BeforeModify(ctx *crdt.ChangeCtx, rec *crdt.RecordChange, op *crdt.Op, _ *crdt.Sink) error

func (CRDTVersionHandler) Init

type DevicesHandler

type DevicesHandler struct{}

DevicesHandler validates ops on the devices dataset: any non-empty id (the peer id). Deletes are ALLOWED — pruning a decommissioned device row is the "device doesn't exist" signal the election rule keys off. Self-row-only writing is NOT enforceable here (a CRDT handler can't know which peer authored a change's row id); it is a write-path convention — Service.SetDevice / ClaimActive only ever target the local peer id — and the restricted HTTP surface enforces it at the API boundary.

func (DevicesHandler) BeforeCreate

func (DevicesHandler) BeforeCreate(_ *crdt.ChangeCtx, rec *crdt.RecordChange, _ *crdt.Sink) error

func (DevicesHandler) BeforeDelete

func (DevicesHandler) BeforeDelete(ctx *crdt.ChangeCtx, _ *crdt.RecordChange, _ *crdt.Sink) error

BeforeDelete rejects tombstoning a row that was never created: deletes are otherwise allowed (pruning is the "device doesn't exist" signal), but the tombstone is sticky, and a delete on an absent id would permanently ban a possibly-mistyped peer id. A never-created id carries no creation marker (_ver) in ctx.Before — the same test the apply path's own create detection uses. The gate is deterministic (every replica evaluates it on the same causal prefix) and so covers every writer, not just the exists-check in Service.DeleteDevice.

func (DevicesHandler) BeforeModify

func (DevicesHandler) BeforeModify(_ *crdt.ChangeCtx, rec *crdt.RecordChange, _ *crdt.Op, _ *crdt.Sink) error

func (DevicesHandler) Init

type IdentitiesHandler

type IdentitiesHandler struct{}

IdentitiesHandler validates ops on the identities dataset: any non-empty id (the account address), no deletes (the row is a durable directory entry; profile/sightings are overwritten in place).

func (IdentitiesHandler) BeforeCreate

func (IdentitiesHandler) BeforeCreate(_ *crdt.ChangeCtx, rec *crdt.RecordChange, _ *crdt.Sink) error

func (IdentitiesHandler) BeforeDelete

func (IdentitiesHandler) BeforeDelete(_ *crdt.ChangeCtx, _ *crdt.RecordChange, _ *crdt.Sink) error

func (IdentitiesHandler) BeforeModify

func (IdentitiesHandler) BeforeModify(_ *crdt.ChangeCtx, rec *crdt.RecordChange, _ *crdt.Op, _ *crdt.Sink) error

func (IdentitiesHandler) Init

type IdentityRecord

type IdentityRecord struct {
	Identity    string
	SymKey      string
	Name        string
	Description string
	IconCID     string
	SpaceIds    []string
}

IdentityRecord is the typed view of one identities row.

func DecodeIdentityRecord

func DecodeIdentityRecord(v *anyenc.Value) IdentityRecord

DecodeIdentityRecord lifts an anyenc value (as returned by Controller.Get / Records) into an IdentityRecord. The row id (the account identity) is read from the CRDT-stamped "id" field.

type InboxCursorHandler

type InboxCursorHandler struct{}

InboxCursorHandler validates ops on the inbox-cursor dataset: exactly one row (id = InboxCursorSelfId), no deletes. Mirrors ProfileHandler.

func (InboxCursorHandler) BeforeCreate

func (InboxCursorHandler) BeforeCreate(_ *crdt.ChangeCtx, rec *crdt.RecordChange, _ *crdt.Sink) error

func (InboxCursorHandler) BeforeDelete

func (InboxCursorHandler) BeforeDelete(_ *crdt.ChangeCtx, _ *crdt.RecordChange, _ *crdt.Sink) error

func (InboxCursorHandler) BeforeModify

func (InboxCursorHandler) BeforeModify(_ *crdt.ChangeCtx, rec *crdt.RecordChange, _ *crdt.Op, _ *crdt.Sink) error

func (InboxCursorHandler) Init

type ProfileHandler

type ProfileHandler struct{}

ProfileHandler validates ops on the profile dataset. The dataset holds exactly one row (id = ProfileSelfId); we reject deletes and any non-self id wholesale. Every other op passes — the schema is flat strings, no immutability rules, owner-only at the ACL layer.

func (ProfileHandler) BeforeCreate

func (ProfileHandler) BeforeCreate(_ *crdt.ChangeCtx, rec *crdt.RecordChange, _ *crdt.Sink) error

func (ProfileHandler) BeforeDelete

func (ProfileHandler) BeforeDelete(_ *crdt.ChangeCtx, _ *crdt.RecordChange, _ *crdt.Sink) error

func (ProfileHandler) BeforeModify

func (ProfileHandler) BeforeModify(_ *crdt.ChangeCtx, rec *crdt.RecordChange, _ *crdt.Op, _ *crdt.Sink) error

func (ProfileHandler) Init

type ProfileRecord

type ProfileRecord struct {
	Name        string
	Description string
	IconCID     string
}

ProfileRecord is the typed view of the single profile row.

func DecodeProfileRecord

func DecodeProfileRecord(v *anyenc.Value) ProfileRecord

DecodeProfileRecord pulls fields off an anyenc value as returned by Controller.Get. Returns the zero value when v is nil.

func (ProfileRecord) IsEmpty

func (r ProfileRecord) IsEmpty() bool

IsEmpty reports whether the record has nothing worth pushing.

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service runs the account-private tech space — derived from the account key, owner-only ACL — and exposes typed access to the space-index dataset.

Loads the underlying any-sync space lazily through anysyncx's space cache. The CRDT controller stays loaded for the life of the SDK (cheap, DB-backed); the any-sync side evicts on TTL like any other space. Each write rebinds an *object.Object to the freshly loaded tree before issuing tree.AddContent.

func New

func New(app *anysyncx.App, db anystore.DB) *Service

New returns a Service ready for Open.

func (*Service) AccountValuesObject

func (s *Service) AccountValuesObject(ctx context.Context, targetSpaceId string) (*object.Object, error)

AccountValuesObject derives (idempotently) and returns the carrier object for targetSpaceId, keeping it resident in the tech store's ocache. Every device of the account mints the same object id for the same target space. The id is cached after the first call.

func (*Service) Add

Add writes a new space-index record.

func (*Service) AddIdentitySpace

func (s *Service) AddIdentitySpace(ctx context.Context, identity, spaceId string) error

AddIdentitySpace records that we've seen identity in spaceId. DEVICE-LOCAL ($addToSet on the spaceIds set). No-op if already present.

func (*Service) AddInviteNotify

func (s *Service) AddInviteNotify(ctx context.Context, spaceId, identity string) error

AddInviteNotify queues identity into the device-local direct-add send outbox on spaceId's row ($addToSet on FieldInviteNotifyPending; no-op when already queued). Local-set path — never enters the DAG.

func (*Service) CRDTVersion

func (s *Service) CRDTVersion() space.CRDTVersionState

CRDTVersion reports the mark: the supported version, the stored one (the highest this replica has seen applied — the record's value, or the peak an inbound change carried) and whether the stored one is newer, which is the read-only state.

func (*Service) ClaimActive

func (s *Service) ClaimActive(ctx context.Context, app string) (object.WriteResult, error)

ClaimActive marks THIS device as the active instance of app: it writes activeClaims.<app> = {seq, at} on the own row, with seq = max(existing seqs for the slug across all live rows) + 1 and at = now (unix seconds). The read-then-write is not atomic — two devices claiming concurrently can mint the same seq — but the reader-side rule (space.ActiveDevice: highest seq, then at, then peer id) makes that survivable by design; see SYN-165.

Known limit (v1): seq is minted from THIS replica's view — on a device that hasn't synced the latest claims yet, a fresh claim can mint a lower seq than an unseen earlier one and lose the election once heads converge, inverting the user's newest intent (the same caveat space.ActiveDevice documents). Claims are cheap: re-claim after sync.

The same change also marks the app installed (apps.<app> = {}) when the own row doesn't carry it yet, so a claim can never dangle on a row the election filter would skip.

func (*Service) ClearInviteNotify

func (s *Service) ClearInviteNotify(ctx context.Context, spaceId, identity string) error

ClearInviteNotify removes identity from spaceId's send outbox ($pull) once the coordinator confirms delivery — or when the entry is permanently undeliverable. Local-set path.

func (*Service) Close

func (s *Service) Close(_ context.Context) error

Close marks the service inactive and tears down the index Store (which closes the resident object + the subscribe engine). Underlying space cleanup happens via the App's space cache on its own schedule.

func (*Service) DeleteAccountValuesForObject

func (s *Service) DeleteAccountValuesForObject(ctx context.Context, targetSpaceId, targetObjectId string) error

DeleteAccountValuesForObject tombstones every carrier record of one target object (any dataset/recordId) — the object-delete GC. Every device may issue the same deletes: CRDT deletes are idempotent and tombstones sticky, so a racing account write loses cleanly. No-op when the object has no carrier records.

func (*Service) DeleteDevice

func (s *Service) DeleteDevice(ctx context.Context, peerId string) (object.WriteResult, error)

DeleteDevice prunes peerId's row — the "device doesn't exist" signal that moves the active election away from it. SYNCED, and a sticky CRDT tombstone: the id can never re-register (a pruned device that comes back online stays unlisted until it re-derives its peer keys). The row must exist — a delete on an absent id would still mint a tombstone, permanently banning a possibly-mistyped id, so the unknown case errors instead (double-guarded by the handler's BeforeDelete, which covers non-service writers too). The local device's OWN row is refused (ErrDeviceSelfDelete): the sticky tombstone would permanently lock this installation out of the registry — prune it from another device.

func (*Service) DeleteTree

func (s *Service) DeleteTree(ctx context.Context, spaceId, treeId string) error

DeleteTree handles the deletion-manager's per-tree cleanup — fired for carrier objects dropped on space leave/delete. Same contract as the regular-space registry, with ONE deliberate exception: the index tree is refused. It is the account's space list — storage-deleting it is unrecoverable locally (the deterministic re-derive hits the deleted-storage mark) and no SDK path ever legitimately requests it, so a request can only be a bug we'd rather surface than obey.

func (*Service) DropAccountValues

func (s *Service) DropAccountValues(ctx context.Context, targetSpaceId string) error

DropAccountValues deletes the whole carrier object for targetSpaceId — the space-leave/delete GC.

KNOWN PROTOCOL VIOLATION, to be replaced (docs/scoped-properties-proposal.md): any-sync forbids deleting DERIVED trees — deterministic ids mean delete + re-derive = the same identity with fresh history (history replacement). This must become record-level GC (tombstone every carrier record, keep the empty derived tree). Kept for now because the target space is being deleted account-wide anyway and the call is best-effort.

func (*Service) EnsureCRDTVersion

func (s *Service) EnsureCRDTVersion(ctx context.Context) error

EnsureCRDTVersion is the boot check: a stored version above space.CRDTVersion refuses with CRDTVersionNewerError; a lower or absent one is raised to the supported version in one synced write. Boot-serial (sdk.Open, after Open of this service).

func (*Service) Get

func (s *Service) Get(ctx context.Context, spaceId string) (SpaceIndexRecord, bool)

Get returns the current state of one space-index record. Reads off the resident index object's controller; inbound changes are kept current by the object's deferred-updater listener (no drain needed).

func (*Service) GetAccountValues

func (s *Service) GetAccountValues(ctx context.Context, targetSpaceId, recordKey string) (*anyenc.Value, error)

GetAccountValues returns one carrier record (cloned), or nil when it doesn't exist. Pure any-store read.

func (*Service) GetIdentity

func (s *Service) GetIdentity(ctx context.Context, identity string) (IdentityRecord, bool)

GetIdentity returns the full identities-directory record for an account identity, or (zero, false) when we've never encountered it.

func (*Service) GetIdentityMetaKey

func (s *Service) GetIdentityMetaKey(ctx context.Context, identity string) (string, bool)

GetIdentityMetaKey returns the cached metadata symkey (string form, per space.MarshalSymKey) for a contact identity, or "" + false when this account has never received it. Account-scoped and SYNCED, so a key learned on any device or in any space is available here.

func (*Service) GetInboxCursor

func (s *Service) GetInboxCursor(ctx context.Context) string

GetInboxCursor returns the SYNCED account-wide coordinator-inbox read position (ObjectID-hex offset), or "" when nothing has been processed yet (or the value hasn't synced to this device). Account-scoped: the inbox notifier seeds from it so a fresh device skips already-processed history.

func (*Service) GetProfile

func (s *Service) GetProfile(ctx context.Context) (ProfileRecord, bool)

GetProfile returns the locally-stored profile (the source-of-truth the SDK pushes to identityRepo on boot). The second return is true when a profile has ever been written; false on a fresh device that hasn't seen UpdateMetadata yet.

func (*Service) GetTree

func (s *Service) GetTree(ctx context.Context, spaceId, treeId string) (objecttree.ObjectTree, error)

GetTree resolves any tech-space tree via the Store. The Store returns the listener-bound, deferred-updater, cold-restored object (reloading it if ocache evicted), so the synctree's AddRawChangesFromPeer path fires Update → replayLocked and inbound changes project live — for the index object (the cold-sync path TestE2E_ColdSyncSameKey covers) AND the account-values carriers (TestE2E_AccountScopeSync): restricting this to the index id used to silently skip carrier trees in every headsync round, so account values never crossed devices.

Accepting arbitrary tree ids is safe: the tech space is owner-only — every tree in it is this account's, and the Store registers the full handler set (built-ins + system datasets) on every controller. An id any-sync can't resolve fails inside Store.Get and the syncer skips it.

func (*Service) HasTree

func (s *Service) HasTree(ctx context.Context, spaceId, treeId string) (bool, error)

PutTree binds a remote-delivered tech-space tree payload — a carrier created by another of the account's devices that this device hasn't derived yet. (The index object is always derived locally at Open, so it never arrives this way, but accepting it is harmless: Derive and PutTree converge on the same deterministic tree.) HasTree reports whether the tech space already stores treeId.

func (*Service) IndexObjectId

func (s *Service) IndexObjectId() string

IndexObjectId returns the space-index object id once Open has run.

func (*Service) IterAccountValues

func (s *Service) IterAccountValues(ctx context.Context, targetSpaceId string, fn func(rec *anyenc.Value) error) error

IterAccountValues streams every live carrier record of targetSpaceId to fn (rows are valid only during the callback — clone to retain). Backs the mirror's state re-mirror. A space with no carrier collection yet yields nothing.

func (*Service) List

func (s *Service) List(ctx context.Context) []SpaceIndexRecord

List returns every live space-index record. Inbound head-sync changes are projected live by the resident index object's deferred-updater listener, so a plain controller read is current — the old read-time drain is gone.

func (*Service) ListDevices

func (s *Service) ListDevices(ctx context.Context) ([]space.Device, error)

ListDevices returns every live devices row (tombstones excluded by Controller.Records). Inbound head-sync changes are projected live by the resident index object's listener, so a plain read is current. Unavailability (service not open, index object not loadable) is an error, never an empty slice — an empty registry and a closed service must stay distinguishable, or an election consumer reading during boot would wrongly self-claim against an "empty" registry.

func (*Service) ListIdentities

func (s *Service) ListIdentities(ctx context.Context) []IdentityRecord

ListIdentities returns every identities-directory row.

func (*Service) MarkTreeDeleted

func (s *Service) MarkTreeDeleted(_ context.Context, spaceId, treeId string) error

MarkTreeDeleted is the soft-delete hook fired when the settings tree announces a deletion — for the tech space that means a carrier object dropped by another of the account's devices. Same contract as the regular-space registry: drop the cached object so reads/mirrors stop touching it; the storage delete follows via DeleteTree. Unconditional like the regular path — dropping the index object would merely force a reload on next use.

func (*Service) OnSpaceCreated

func (s *Service) OnSpaceCreated(ctx context.Context, spaceId string, meta space.SpaceInfo) error

OnSpaceCreated satisfies space.Indexer. Adds a fresh record to the space-index dataset with the supplied metadata. The existing spaceimpl.Service.Create still calls Add() directly for the eager caller-round-trip seed; this method exists for future callers that route everything through the Indexer seam.

func (*Service) OnSpaceDeleted

func (s *Service) OnSpaceDeleted(ctx context.Context, spaceId string) error

OnSpaceDeleted satisfies space.Indexer. Flips the row to the synced remoteStatus=deleted (account-wide — propagates to every device); the record is never physically removed.

func (*Service) OnSpaceMetadataUpdated

func (s *Service) OnSpaceMetadataUpdated(ctx context.Context, spaceId string, meta space.SpaceInfo) error

OnSpaceMetadataUpdated satisfies space.Indexer. Idempotent overwrite of the row's name / description / icon — the mirror from the in-space spaceIndex derived object into this device's tech-space row. Skipped when:

  • The row is absent (joiner without a tech-space record yet, or a peer that hasn't completed Join).
  • All three fields already equal what we would write. This is the common case for the initial seed (Service.Create's tsp.Add wrote the row first; the in-space spaceIndex apply then triggers the watcher to re-write the SAME values). Dedup avoids a pointless second CRDT change on the tech-space tree, which would otherwise race with caller-initiated writes like SetLocalStatus on the apply lock.

func (*Service) Open

func (s *Service) Open(ctx context.Context) error

Open derives the tech-space id, ensures storage exists, and computes the space-index object id. The space itself is loaded via the cache as needed (and on the first call to do an initial cold restore so subscribed callers see persisted state immediately).

func (*Service) PeerId

func (s *Service) PeerId() string

PeerId returns this device's libp2p peer id — the devices-dataset row id every self-targeted write uses. Empty before Open.

func (*Service) PutTree

func (s *Service) PutTree(ctx context.Context, spaceId string, payload treestorage.TreeStorageCreatePayload) error

func (*Service) RemoveIdentitySpace

func (s *Service) RemoveIdentitySpace(ctx context.Context, identity, spaceId string) error

RemoveIdentitySpace drops spaceId from identity's sightings ($pull). DEVICE-LOCAL. Used when a single member leaves a space.

func (*Service) RemoveSpaceFromIdentities

func (s *Service) RemoveSpaceFromIdentities(ctx context.Context, spaceId string) error

RemoveSpaceFromIdentities drops spaceId from every identity's sightings — used when the account leaves/offloads a space so spaceIds keeps reflecting live memberships. DEVICE-LOCAL.

func (*Service) SetAclHeadId

func (s *Service) SetAclHeadId(ctx context.Context, spaceId, aclHeadId string) (object.WriteResult, error)

SetAclHeadId records the ACL head id from RequestJoin on the joining row via the local-set path (device-local; never enters the DAG). Read back by the joiner-side post-acceptance waiter to detect a decline.

func (*Service) SetDerived

func (s *Service) SetDerived(ctx context.Context, spaceId string) (object.WriteResult, error)

SetDerived flags a row as seed-derived (FieldDerived, synced) — the heal path for rows that predate the flag (Derive stamps it at row create otherwise). Set-once: the handler pins it after the first true write.

func (*Service) SetDevice

func (s *Service) SetDevice(ctx context.Context, up space.DeviceUpsert) (object.WriteResult, error)

SetDevice upserts THIS device's row in the devices registry — the row id is always the local peer id, never caller-supplied (the self-row-only convention). SYNCED (LocalWrite → DAG → the account's other devices). Per-path ops per DeviceUpsertOps; at least one field must be non-empty.

func (*Service) SetGuestKey

func (s *Service) SetGuestKey(ctx context.Context, spaceId, encodedKey string) (object.WriteResult, error)

SetGuestKey updates the SYNCED guest-mode key on an existing row — the re-join path after the owner rotated the guest key (JoinGuest with a fresh invite). Row creation writes the field via EncodeCreate.

func (*Service) SetIdentityMetaKey

func (s *Service) SetIdentityMetaKey(ctx context.Context, identity, symKey string) error

SetIdentityMetaKey caches a contact's metadata symkey (string form). SYNCED (LocalWrite → DAG) so the account's other devices can decrypt that contact's profile too. Write-once in practice (the contact's deterministic key never changes); a no-op when already stored.

func (*Service) SetIdentityProfile

func (s *Service) SetIdentityProfile(ctx context.Context, identity, name, description, iconCID string) error

SetIdentityProfile caches a contact's resolved identityRepo profile. DEVICE-LOCAL (LocalSet → never synced): each device re-resolves from the synced symkey + identityRepo. No-op when unchanged.

func (*Service) SetInboxCursor

func (s *Service) SetInboxCursor(ctx context.Context, offset string) error

SetInboxCursor advances the synced inbox read position to offset. SYNCED (LocalWrite → enters the DAG, propagates to the account's other devices). Monotonic-forward: a no-op when offset is not lexically greater than the current value (ObjectID-hex compares in coordinator order), so a stale write from a lagging device can't rewind the account-wide cursor. Best-effort against the rare concurrent-write race — a regression only costs a harmless, deduplicated re-fetch.

func (*Service) SetIssuedInviteKey

func (s *Service) SetIssuedInviteKey(ctx context.Context, spaceId, kind, encodedKey string) (object.WriteResult, error)

SetIssuedInviteKey writes (or clears, with "") the SYNCED custody of one issued invite key kind (IssuedKeyMember / IssuedKeyGuest) at [issuedInviteKeys, kind] — every device of the issuing account can then re-show or revoke the same invite. Per-path so kinds written on different devices merge instead of clobbering. Written / cleared by the ACL invite mint and revoke paths.

func (*Service) SetLocalStatus

func (s *Service) SetLocalStatus(ctx context.Context, spaceId, status string) (object.WriteResult, error)

SetLocalStatus updates the device-local localStatus field via the local-set path: it does NOT enter the DAG and never syncs to other devices (each device owns its own value). Use for per-device lifecycle — active / joining / offloaded.

func (*Service) SetOneToOneInviteState

func (s *Service) SetOneToOneInviteState(ctx context.Context, spaceId, state string) (object.WriteResult, error)

SetOneToOneInviteState writes the device-local 1-1 invite-send marker (FieldOneToOneInviteState) via the local-set path — never enters the DAG. Pass "toSend" to flag a pending notification, "" to clear it once the coordinator confirms delivery.

func (*Service) SetOwnRole

func (s *Service) SetOwnRole(ctx context.Context, spaceId string, role space.Permission) (object.WriteResult, error)

SetOwnRole mirrors this account's own ACL permission onto the space's row via the local-set path (FieldOwnRole, device-local; never enters the DAG — every device derives the same role from the same converged ACL). Stored as the canonical wire label (space.Permission.String). Caller (the ACL mirror watcher) is responsible for the row-exists check and for skipping no-op writes.

func (*Service) SetP2PAdvertise

func (s *Service) SetP2PAdvertise(ctx context.Context, spaceId string, on bool) (object.WriteResult, error)

SetP2PAdvertise writes the per-space p2p advertising switch (FieldP2PAdvertise, synced account-wide).

func (*Service) SetProfile

func (s *Service) SetProfile(ctx context.Context, rec ProfileRecord) error

SetProfile writes (or replaces) the local profile row. Called by Account.UpdateMetadata to persist the value before pushing it to identityRepo, so a subsequent boot can republish without the user re-calling UpdateMetadata.

func (*Service) SetPushKeys

func (s *Service) SetPushKeys(ctx context.Context, spaceId string, keys space.PushKeys) (object.WriteResult, error)

SetPushKeys mirrors the space's derived push-notification key material onto its row via the local-set path (FieldPushKeys, device-local; never enters the DAG — every device derives the same values from the same converged ACL). The whole `push` object is replaced in one op: encKey/encKeyId always rotate together and spaceKey never changes, so per-subfield merging buys nothing. Caller (the ACL mirror watcher) is responsible for the row-exists check and for skipping no-op writes.

func (*Service) SetRemoteStatus

func (s *Service) SetRemoteStatus(ctx context.Context, spaceId, status string) (object.WriteResult, error)

SetRemoteStatus updates the SYNCED remoteStatus field — account-wide state that propagates to every device. Used for account-wide delete (status=StatusDeleted); the handler keeps deleted terminal.

func (*Service) SetSettings

func (s *Service) SetSettings(ctx context.Context, spaceId string, set map[string]any, unset []string) (object.WriteResult, error)

SetSettings applies a per-key patch to spaceId's `settings` subtree: $set each set entry at settings.<key>, $unset each unset key — all under one SYNCED change (LocalWrite → DAG → the account's other devices). No upsert: the row must already exist (a modify on an absent id is a silent no-op by CRDT rules; the spaceimpl wrapper guards with an existence check so callers get an error instead).

Handler note: SpaceIndexHandler guards only `type` (pinned) and the status fields (terminal delete) — `settings` passes untouched, so a deleted row's settings stay editable by design (the tombstone row is still the account's record of the space).

func (*Service) SetSpaceMetadata

func (s *Service) SetSpaceMetadata(ctx context.Context, spaceId, name, description, iconCID, spaceType string) (object.WriteResult, error)

SetSpaceMetadata is the idempotent overwrite of the row's name / description / icon fields — driven by the per-space spaceIndex watcher whenever the in-space spaceIndex object's converged state changes. `type` is intentionally left untouched (pinned on first write by SpaceIndexHandler.BeforeModify); status fields stay under their own setters.

The write is a single multi-field $set so all three columns land under one VersionId. No-op when every field equals the empty string (rare — the spaceIndex object can carry an empty mirror state right after Create, before the initial property write has applied).

func (*Service) SetType

func (s *Service) SetType(ctx context.Context, spaceId, typ string) (object.WriteResult, error)

SetType backfills the on-wire header type onto a row created with an unknown type (join/track register rows before the space's header is readable). Synced write; the handler's set-once rule makes it a no-op race loser everywhere the value is already non-empty.

func (*Service) SpaceId

func (s *Service) SpaceId() string

SpaceId returns the tech-space id once Open has run.

func (*Service) Store

func (s *Service) Store() *spaceobjects.Store

Store exposes the tech-space object Store so the space layer can build generic Query/Subscribe over the system datasets (spaces, profile, future system objects) by object id.

func (*Service) SubEngine

func (s *Service) SubEngine() *subscribe.Engine

SubEngine exposes the index object's live-query engine so the space layer can back space.Service.Subscribe over the spaces dataset.

func (*Service) SyncHeads

func (s *Service) SyncHeads(ctx context.Context) error

SyncHeads forces an immediate head-sync round on the tech space, so remotely-added or -removed spaces land in the local index without waiting for the periodic headsync timer. No-op before Open.

func (*Service) WriteAccountValues

func (s *Service) WriteAccountValues(ctx context.Context, targetSpaceId string, rec crdt.RecordChange) (object.WriteResult, error)

WriteAccountValues applies one RecordChange to the carrier of targetSpaceId — the account route's tech-space write. The returned WriteResult.VersionId is the carrier tree's orderId: the version the mirror stamps onto target rows, and what Properties.Set reports.

func (*Service) WriteGate

func (s *Service) WriteGate() error

WriteGate is the account-wide write gate every store consults: nil while the stored CRDT version is at or below the supported one, the CRDTVersionNewerError once a higher mark has been seen.

type SpaceIndexHandler

type SpaceIndexHandler struct{}

SpaceIndexHandler validates ops on the tech-space space-index dataset. Per docs/tech-space.md § "Space Index" and § "Key Decisions (continued)":

  • `type` is set-once: empty/absent means "unknown" (rows created on join/track before the space's header is readable) and may be filled exactly once — from the loaded space's on-wire header — after which it is immutable;
  • `localStatus` / `remoteStatus` cannot move OUT of "deleted" (terminal — deleted spaces stay in the index);
  • `derived` is set-once like `type`, and derived rows refuse remoteStatus=deleted from any writer (see FieldDerived);
  • delete ops are rejected wholesale; deletion is a status edit, not a CRDT delete.

Owner-only writes are guaranteed by the tech space's ACL at the any-sync layer; this handler is defence in depth on the apply side.

func (SpaceIndexHandler) BeforeCreate

func (SpaceIndexHandler) BeforeCreate(ctx *crdt.ChangeCtx, rec *crdt.RecordChange, sink *crdt.Sink) error

BeforeCreate accepts any `type` value, including empty/absent — rows registered on join/track are created before the space's header is readable, so their type is unknown until the first load backfills it (set-once, enforced by BeforeModify). Strict allow-listing of type values is deferred until the canonical space-type enum is consolidated.

It also stamps `createdAt` (added-to-account time) from the change's timestamp via sink.Derive — derived from the change envelope, so every device replaying the same create lands on the same value. (Convergence caveats in the FieldCreatedAt doc.)

func (SpaceIndexHandler) BeforeDelete

func (SpaceIndexHandler) BeforeDelete(_ *crdt.ChangeCtx, _ *crdt.RecordChange, _ *crdt.Sink) error

BeforeDelete rejects every delete attempt — there is no physical removal in the space index. Callers wanting to remove a space write `localStatus = deleted` instead.

func (SpaceIndexHandler) BeforeModify

func (SpaceIndexHandler) BeforeModify(ctx *crdt.ChangeCtx, _ *crdt.RecordChange, op *crdt.Op, _ *crdt.Sink) error

BeforeModify enforces the headRuleErr rule table on single-path ops.

The set-once gate reads the LOCAL pre-op state, so two concurrent fills with different values would pin divergently per device. That is safe only because the sole writer (load's header backfill) writes a pure function of the immutable space header — identical on every device. Do not add a second `type` writer that isn't.

func (SpaceIndexHandler) Init

type SpaceIndexRecord

type SpaceIndexRecord struct {
	// Id is the spaceId — primary key in the dataset.
	Id string

	// Type mirrors the space header's SpaceType — one of the
	// space.SpaceType* constants (anytype.space / anytype.chatspace
	// / anytype.onetoone). First-write-wins: pinned for life by
	// SpaceIndexHandler.BeforeModify.
	Type string

	// SpaceType is the app-level tag mirrored from the in-space
	// spaceIndex.spaceType (surfaced as space.SpaceInfo.SpaceType).
	// Independent of Type and not pinned — the watcher overwrites it
	// with the converged in-space value.
	SpaceType string

	Name        string
	IconCID     string
	Description string

	// LocalStatus / RemoteStatus carry the StatusActive / StatusArchived
	// / StatusDeleted vocabulary. Once either reaches StatusDeleted the
	// handler refuses moves out of it.
	LocalStatus  string
	RemoteStatus string

	// AclHeadId is the ACL head id from RequestJoin, recorded on a
	// joining row so the post-acceptance waiter can detect a decline.
	// Device-local (FieldAclHeadId, ScopeLocal); empty on non-joining
	// rows. Written via Service.SetAclHeadId after the row exists.
	AclHeadId string

	// OneToOnePeer is the other participant's account identity on a
	// derived 1-1 row (FieldOneToOnePeer, synced). Required to materialize
	// the 1-1 storage on accept. Empty on non-1-1 rows.
	OneToOnePeer string

	// OneToOneInviteState is the device-local send obligation marker
	// (FieldOneToOneInviteState, ScopeLocal): "toSend" while this device
	// still owes the peer an inbox notification, cleared once delivered.
	OneToOneInviteState string

	// InviteNotifyPending is the device-local direct-add send outbox
	// (FieldInviteNotifyPending, ScopeLocal): identities this device still
	// owes a RegularInvite inbox notification after AddAccounts. Entries
	// are cleared one-by-one on confirmed delivery.
	InviteNotifyPending []string

	// CreatedAt is the added-to-account time in unix seconds, stamped by
	// SpaceIndexHandler.BeforeCreate when the row first lands (see
	// FieldCreatedAt). Zero on rows created before the field existed —
	// treat 0 as "unknown".
	CreatedAt int64

	// Settings is the client-writable free-form settings object
	// (FieldSettings, synced account-wide), decoded to Go natives —
	// string / float64 / bool per JSON semantics. Nil when never
	// written. Edited per key via Service.SetSettings.
	Settings map[string]any

	// PushKeys is the device-local push-notification key material
	// (FieldPushKeys, ScopeLocal), mirrored from ACL state by the
	// per-space ACL mirror watcher. Nil until the mirror first runs.
	// Written via Service.SetPushKeys.
	PushKeys *space.PushKeys

	// OwnRole is this account's own ACL permission in the space
	// (FieldOwnRole, ScopeLocal), mirrored from ACL state by the same
	// per-space watcher as PushKeys. PermissionNone until the mirror
	// first runs — "unknown yet", not a verdict. Written via
	// Service.SetOwnRole.
	OwnRole space.Permission

	// GuestKey is the shared read-only guest identity's private key
	// (FieldGuestKey, synced) on a guest-mode row — the space was added
	// via a guest invite and loads signing as this identity. Empty on
	// all other rows; presence is the guest-mode discriminator.
	GuestKey string

	// Derived marks a row written by the account's own Spaces().Derive
	// (FieldDerived, synced, set-once). Gates the Delete refusal —
	// derived spaces are permanent. False on created / joined /
	// tracked / 1-1 rows.
	Derived bool

	// P2PAdvertise is the per-space p2p advertising switch
	// (FieldP2PAdvertise, synced): true (the default, absent field
	// included) publishes this account's devices into the space's global
	// p2p records.
	P2PAdvertise bool

	// IssuedInviteKeys is this account's custody of the invite private
	// keys it issued for the space, keyed by kind — IssuedKeyMember /
	// IssuedKeyGuest (FieldIssuedInviteKeys, synced). Nil / missing kind
	// means no custody: this account never minted that invite here, or
	// revoked it.
	IssuedInviteKeys map[string]string
}

SpaceIndexRecord is the typed view of one row in the space-index dataset. Mirrors docs/tech-space.md § "Space Index" + the field constants in spaceindex.go (Field*).

Decoded from the on-disk anyenc value via DecodeSpaceIndexRecord; produced for writes via NewSpaceIndexCreate / Encode helpers.

Every field is optional on read because tech-space rules allow records that started small and grew. Type and at least one of the status fields are populated on a normal Create path.

func DecodeSpaceIndexRecord

func DecodeSpaceIndexRecord(v *anyenc.Value) SpaceIndexRecord

DecodeSpaceIndexRecord pulls the fields off an anyenc value as returned by Controller.Get / iterator.Doc().Value(). Returns the zero value for missing fields. The id is read from the reserved crdt.IdField — the CRDT layer always stamps it on UpsertId.

func (SpaceIndexRecord) EncodeCreate

func (r SpaceIndexRecord) EncodeCreate(a *anyenc.Arena) *anyenc.Value

EncodeCreate packs a fresh SpaceIndexRecord into the multi-field $set payload that SpaceIndexHandler.BeforeCreate expects. Caller supplies the arena so the resulting *anyenc.Value can be embedded in a larger Change payload without an extra copy.

Empty-string fields are omitted from the payload; an omitted Type means "unknown", backfilled set-once from the space header after the first load.

func (SpaceIndexRecord) IsDeleted

func (r SpaceIndexRecord) IsDeleted() bool

IsDeleted reports whether a row is in any deleted/offloaded state — the account-wide tombstone, the 1-1 and guest synced offload markers, the ended-join marker, or a standing device-local deleted marker (LocalDeleteStands). Used by the boot eager-loader, the Subscribe translator, and the deletion reconciler to treat them uniformly.

func (SpaceIndexRecord) IssuedInviteKey

func (r SpaceIndexRecord) IssuedInviteKey(kind string) string

IssuedInviteKey returns the custody entry for one issued-key kind, "" when absent.

func (SpaceIndexRecord) JoinEnded

func (r SpaceIndexRecord) JoinEnded() bool

JoinEnded reports a row left by a join that ended without membership — the owner declined, or CancelJoin withdrew it: the synced JoinEndedRemoteStatus, or the legacy device-local deleted marker over the synced active that the old Join wrote. Both classify as deleted (IsDeleted), so Get refuses them and the eager-loader skips them, and both are the one deleted shape a re-request revives. 1-1 and guest rows never go through Join and keep their own delete markers; excluding them keeps the legacy shape theirs to claim.

func (SpaceIndexRecord) LocalDeleteStands

func (r SpaceIndexRecord) LocalDeleteStands() bool

LocalDeleteStands reports whether the DEVICE-LOCAL deleted marker decides the row: only while the synced status carries no lifecycle of its own (active, or absent). Nothing writes the marker any more — it is the legacy ended join, declined or withdrawn before the lifecycle was synced — so a synced pending state that lands over it (joining from a re-request on another device, invitePending from a direct add) is the newer, account-wide truth and outranks it, and a synced tombstone classifies the row on its own.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL