properties

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: 14 Imported by: 0

Documentation

Overview

Package properties owns the per-space `properties` system dataset: the CRDT handlers that keep it consistent with the object-owned base-scope data, the variant merge (device > account > base) used by projection, and the reserved variant field names (_device, _account, _base).

Handlers in this package:

  • SystemPropertiesHandler (system.go) — the "baseProperty" handler from docs/data-structure.md, renamed to match its role: it is the single crdt.Handler registered on every user object for base-scope property writes, and it projects those writes into the space's `properties` collection while enforcing "object can only update its own record".

The account-level rewrite handler (applies `_account` variants across spaces) lives alongside the tech-space space-index handler in internal/techspace/, because its source data is a derived object in the tech space rather than a per-object write.

Not public — space.PropertiesAPI wraps the setters and projection. See docs/data-structure.md § "Object Properties".

Index

Constants

View Source
const (
	ReasonInvalidPath        = "invalid_path"
	ReasonTypeNotImplemented = "type_not_implemented"
	ReasonTypeUnknown        = "type_unknown"
	ReasonUnknownProperty    = "unknown_property"
	ReasonKindMismatch       = "kind_mismatch"
	ReasonScopeMismatch      = "scope_mismatch"
	ReasonReservedCarrier    = "reserved_carrier"
	ReasonWrongSlot          = "wrong_slot"
	ReasonTypeRequired       = "type_required"
)

Validation reasons — the machine-readable discriminant on a ValidationError. Stable strings: callers (and agents reading the surfaced message) can switch on them.

View Source
const Dataset = "objects"

Dataset is the name every regular object uses for its base-scope property writes on its own CRDT. The handler is registered against a SHARED per-space collection (also called "objects") via the Controller's shared-collection override — every regular object's values land in one row in that collection, keyed by the change's ObjectId. Type objects don't register this handler; their own metadata (any.name etc.) lives in their per-type-object storage behind a different dataset.

The row also carries the object-level `modifiedAt` / `modifiedBy` pair: the handler is a crdt.ObjectStamper, so a synced change on ANY dataset of the object (editor blocks, chat messages, runtime datasets) stamps them, not only writes to the row itself.

See docs/data-structure.md § "Storage" — the per-space `objects` collection model.

View Source
const HandlerVersion = "systemPropertyHandler-v1"

HandlerVersion is the DataVersion string stamped on every change this handler emits when a write carries no schema pairs (see docs/types-properties-proposal.md § "Change-level DataVersion").

View Source
const LocalVersion = 4

LocalVersion is the 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 / modifiedAt stamps are TypeDateTime instants, not epoch numbers. v3: modifiedAt is also stamped by changes on the object's other datasets (StampObject), so rows stamped by property writes alone are stale. v4: modifiedBy is stamped next to modifiedAt.

Variables

View Source
var (
	ErrInvalidPath        = errors.New("property write rejected: path must be {typeId}.{propId}, or {typeId}.{propId}.{key…} under an object property")
	ErrTypeNotImplemented = errors.New("property write rejected: object does not implement the type")
	ErrTypeUnknown        = errors.New("property write rejected: type schema is not resolvable on this peer")
	ErrUnknownProperty    = errors.New("property write rejected: type has no such property")
	ErrKindMismatch       = errors.New("property write rejected: value kind does not match the declared kind")
	ErrScopeMismatch      = errors.New("property write rejected: write route does not match the property's declared scope")
	ErrReservedCarrier    = errors.New("property write rejected: a type declaring a reserved module is carried only by its own root")
	ErrWrongSlot          = errors.New("property write rejected: a type goes in any.type, a collection in any.collections")
	ErrTypeRequired       = errors.New("property write rejected: an object needs a type — any.type cannot be cleared")
)

Per-reason sentinels. A ValidationError chains to exactly one of these (via Unwrap) so callers can classify the specific rejection with errors.Is — no string matching on the message — while still matching the umbrella crdt.ErrValidation. The handler package re-exports these for external callers.

Functions

This section is empty.

Types

type OwnerKind

type OwnerKind uint8

OwnerKind classifies a definition id for the slot rule: a type belongs in `any.type`, a collection in `any.collections`. Unknown means the id resolves to neither on this device — no definition, or one that has not synced yet — and passes.

const (
	OwnerUnknown OwnerKind = iota
	OwnerType
	OwnerCollection
)

func (OwnerKind) String

func (k OwnerKind) String() string

String renders the OwnerKind for messages.

type SystemPropertiesHandler

type SystemPropertiesHandler struct {
	// Registry resolves declared kinds. May be nil — when nil the
	// handler skips kind validation entirely (passes everything),
	// which is the bring-up mode before the type system is wired.
	Registry types.Registry

	// Grants extends the local-write membership set: given what the
	// object is (its type, its collections, the markers), it returns
	// the extra namespaces the row may hold — the modules its type
	// declares datasets of. Nil grants nothing beyond the members.
	Grants func(members map[string]struct{}) []string

	// Classify reports what a definition id names (a type, a
	// collection, or nothing resolvable here) so the local write
	// pre-flight refuses a known id in the wrong slot: a collection
	// set as `any.type`, a type added to `any.collections`. Unknown
	// ids pass — the definition may not have synced yet; a read
	// failure fails the write closed. Nil classifies nothing.
	Classify func(ctx context.Context, id string) (OwnerKind, error)

	// ReservedCarrier reports whether a user type declares a reserved
	// module (handler.Module.Reserved). Such a type is carried only by
	// the object that is the type itself — the consumer's own install
	// root — so a local write attaching it to any other row is
	// refused. Registered types are never reserved carriers: a static
	// part is the consumer's compiled-in declaration, attachable by
	// design. Nil reserves nothing.
	ReservedCarrier func(typeId string) bool
}

SystemPropertiesHandler validates property writes on every user object. Corresponds to the `baseProperty` handler named in docs/data-structure.md § "Handlers" — renamed to emphasize its scope (the per-space `properties` system dataset) and to distinguish it from typetype.PropertyHandler (which governs property definitions on type objects).

This handler serves the SYNCED route only — it runs on DAG-borne changes (local writes via LocalWrite included). Account-scoped values arrive via the tech-space mirror's injected applies and local-scoped values via Object.LocalSet, neither of which invokes dataset handlers; their validation is writer-side. The scope check in validateField is what keeps the three routes path-disjoint (see docs/scoped-properties-proposal.md).

Two validation paths, split along the local/inbound line:

  • PreValidate (local writes, before the DAG): STRICT. Path syntax, membership, type resolvability, unknown property, and kind. The first violation rejects the WHOLE write with an agent-readable *ValidationError.
  • BeforeCreate / BeforeModify (inbound + replay, schema known): DEFENSIVE per-op drop. Same checks minus the membership guard (which would break out-of-order tolerance); a failing op is dropped, the rest of the record still applies.

Path shape for a single-path op: `[typeId, propId]`. Multi-field $set with empty Path expects an object payload whose keys are dotted "{typeId}.{propId}" pairs; same rules per key; if any key fails the whole op drops (payloads aren't mutated to filter individual keys in v1). $unset / $delete touch no value and are always valid.

func New

New constructs a SystemPropertiesHandler bound to a Registry. Pass nil to disable kind validation (early bring-up).

func NewWithGrants

func NewWithGrants(reg types.Registry, grants func(members map[string]struct{}) []string) *SystemPropertiesHandler

NewWithGrants is New plus the namespace-grant resolver (module namespaces on the objects row).

func (*SystemPropertiesHandler) BeforeCreate

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

BeforeCreate validates every op in the creation payload, then auto-stamps the `any`-scope auto fields (author, createdAt, spaceId, modifiedAt, modifiedBy) via sink.Derive so every newly minted row in the per-space `objects` collection carries them. The stamps are derived from the change envelope (the signing identities, Timestamp from the change wire, SpaceId from the apply context), not from caller input — these fields are ScopeDerived in the `any` type, read-only by contract (validateField rejects input ops on them via the scope check).

Validation is per-op drop, same as BeforeModify: ops that fail the current schema are filtered out and the rest of the record still lands (spec §"Validation atomicity" — drop per op, skip the record only if every op drops). Local creates never hit this with bad ops (PreValidate rejected the whole write upstream); inbound creates that reach here have passed the DataVersion gate, so a dropped op means a removed-property replay or cross-peer bug, not a sync gap. No membership check (out-of-order tolerance).

func (*SystemPropertiesHandler) BeforeDelete

BeforeDelete is a no-op — deleting a property record (the per- object property store entry) is allowed; no Registry lookup applies to the record-as-a-whole.

func (*SystemPropertiesHandler) BeforeModify

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

BeforeModify validates one inbound op against the current schema. Drops the op silently (records a Rejection) on any validation failure; other ops in the same RecordChange still apply. This is the spec's defensive per-op layer (outcome 2): it only ever sees changes whose DataVersion is known (the gate parks not-synced ones), so a drop here means a removed-property replay, a cross-peer bug, or genuine junk — never data merely waiting on a schema sync.

No membership check here: an apply-time membership guard would drop values written before the attach-type change arrives, breaking out-of-order tolerance (docs/data-structure.md § Membership and the local write pre-flight).

Every op that passes validation stamps the derived `modifiedAt` / `modifiedBy` pair (deduped via DeriveOnce — one stamp per RecordChange). Stamping after the validation gate means a fully- rejected change never bumps them; an op that passes here but later loses its per-field LWW race still bumps them, which is deterministic (every peer runs the same gates in the same order) and reads as "latest valid write attempt".

func (*SystemPropertiesHandler) Init

func (*SystemPropertiesHandler) PreValidate

func (h *SystemPropertiesHandler) PreValidate(ch *crdt.Change, before *anyenc.Value) error

PreValidate is the local write-time pre-flight (LocalPreValidator). It runs strict, full validation BEFORE the change enters the DAG: path syntax, membership, type resolvability, unknown property, and kind. The FIRST violation rejects the WHOLE write with an agent-readable *ValidationError — nothing is signed or shipped to peers. `before` is the record's current value (nil on first write).

Stricter than BeforeModify by design: this catches programmer/agent mistakes locally, where rejecting is the right answer; inbound stays tolerant (gate parks not-synced; BeforeModify drops residual mismatches).

func (*SystemPropertiesHandler) StampObject

func (*SystemPropertiesHandler) StampObject(ctx *crdt.ChangeCtx, sink *crdt.Sink)

StampObject bumps the row's `modifiedAt` / `modifiedBy` for a synced change on any other dataset of the object (crdt.ObjectStamper). The controller applies the stamps to the existing row only — a row that does not exist yet gets them from BeforeCreate when it is created.

type ValidationError

type ValidationError struct {
	Reason string

	TypeId   string
	TypeName string // display name when known
	PropId   string
	PropName string // display name when known

	Expected schema.Kind // declared kind (kind_mismatch)
	Got      schema.Kind // supplied value's kind (kind_mismatch)

	// DeclaredScope / WriteRoute discriminate a scope_mismatch: the
	// property's pinned scope vs the route this write arrived on.
	DeclaredScope schema.Scope
	WriteRoute    schema.Scope

	Known []types.PropInfo // declared properties of the type (unknown_property)
	// Members is what the object is after the change — its type and
	// its collections (type_not_implemented).
	Members []string
	Path    []string // offending op path (invalid_path)

	// Slot is the membership field the id was written to and Kind
	// what the id names (wrong_slot).
	Slot string
	Kind OwnerKind

	// ObjectId is the row the write targets (reserved_carrier).
	ObjectId string
}

ValidationError describes one rejected property write in terms an agent can act on: which type and property, what was expected vs. supplied, and the set of valid choices. It wraps crdt.ErrValidation so callers can classify with errors.Is and discriminate via Reason.

Produced by the local write-time pre-flight (returned to the caller, rejecting the whole write) and by the apply-time per-op validator (recorded in ApplyResult.Rejections for dropped inbound ops).

func (*ValidationError) Error

func (e *ValidationError) Error() string

Error renders an agent-readable, single-line rejection message.

func (*ValidationError) Unwrap

func (e *ValidationError) Unwrap() []error

Unwrap ties the error to the crdt.ErrValidation umbrella sentinel (so errors.Is(err, crdt.ErrValidation) holds across both validation paths) and to the per-reason sentinel (so callers can classify the specific rejection with errors.Is). Multi-error form per Go 1.20 semantics.

Jump to

Keyboard shortcuts

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