work

package
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Oct 18, 2026 License: Apache-2.0 Imports: 10 Imported by: 0

Documentation

Overview

Package work holds the model of what is being managed: containers, items, and the policy that says what each kind of item can do.

Index

Constants

View Source
const (
	FieldName             = "name"
	FieldDescription      = "description"
	FieldIcon             = "icon"
	FieldColorToken       = "color_token"
	FieldCompletionPolicy = "completion_policy"
	FieldAutoAssign       = "auto_assign"
	FieldPrivate          = "private"
	FieldParentID         = "parent_id"
	FieldOrderKey         = "order_key"
	FieldArchivedAt       = "archived_at"
)

Field names of a container, as the API spells them. They travel into the event's change set and into the change log, where a client matches them against the members it sent - so they are written once here rather than as a literal at each place that has to agree.

View Source
const (
	// MaxCustomFieldKeyLength is the schema's, and the pattern below is the schema's CHECK.
	MaxCustomFieldKeyLength = 50
	// MaxCustomFieldTextLength bounds a TEXT value. Generous, because a custom field is where a
	// person puts what the model did not anticipate - and bounded all the same, because the column
	// is a jsonb map that a query plans over.
	MaxCustomFieldTextLength = 2000
	// MaxCustomFieldOptionLength bounds one option of a SELECT.
	MaxCustomFieldOptionLength = 200
	// MaxCustomFieldOptions bounds how many a SELECT may offer. A list a person picks from, not a
	// table: past this it is a relation and belongs in one.
	MaxCustomFieldOptions = 200
	// MaxCustomFieldSelections bounds one MULTI_SELECT value.
	MaxCustomFieldSelections = 50
	// MaxCustomFieldURLLength bounds a URL value.
	MaxCustomFieldURLLength = 2000
	// MaxCustomFieldsPerItem bounds how many keys one entry may carry. The column is one jsonb
	// document that every read of the entry carries, so its size is the entry's size.
	MaxCustomFieldsPerItem = 100
)

The bounds. Code points rather than bytes, for the reason every length in this model is (I-W7): a person counts characters, and a limit in bytes refuses a shorter name in one language than in another.

View Source
const (
	FieldOptions    = "options"
	FieldIsRequired = "is_required"
	FieldAppliesTo  = "applies_to"
)

The field names an update reports its changes under.

View Source
const (
	FieldTitle      = "title"
	FieldNotes      = "notes"
	FieldBucketID   = "bucket_id"
	FieldAssigneeID = "assignee_id"
	FieldCover      = "cover"
	FieldStartAt    = "start_at"
	// The due trio. Three names rather than one, because each is its own change log entry: one
	// entry naming all three would give them a single clock, and the merge would then decide them
	// together (offline-sync.md §4.2).
	FieldDueAt       = "due_at"
	FieldDueDateOnly = "due_date_only"
	FieldDueTimeZone = "due_time_zone"
	// FieldCustomFields is the column. One *key* of it reaches a change log entry as
	// `custom_fields.<key>`, which is what makes the merge per key (CustomFieldPath).
	FieldCustomFields = "custom_fields"
	// FieldCompletion is the done/open state as one field - `completion` in the contract, the
	// object with is_completed, completed_at and completed_by - which merges as one
	// (offline-sync.md §4.2).
	FieldCompletion      = "completion"
	FieldContentLanguage = "content_language"
	// FieldCalendarUID is the address a calendar client knows the entry by. Not something an
	// update may set - it is written once by the create - but a field the projections spell.
	FieldCalendarUID = "calendar_uid"
	// FieldCollectionID is not something an update may set - an item changes collection by being
	// moved - but it is a field that moves, and the records of a move name it.
	FieldCollectionID = "collection_id"
)

Field names, as the API spells them. They travel into the event's change set and into the change log, where a client matches them against the members it sent - so they are written once here rather than as a literal at each of the three places that has to agree.

View Source
const (
	FieldRRULE       = "rrule"
	FieldTimeZone    = "time_zone"
	FieldMode        = "mode"
	FieldHorizonDays = "horizon_days"
	FieldEndsAt      = "ends_at"
	FieldMaxCount    = "max_count"
)

The rule's own field names, as the contract spells them. They travel into the change log, where a client matches them against the members it sent (offline-sync.md §4.2).

View Source
const (
	// MaxRRULELength is the contract's maxLength in code points, so that a refusal comes from the
	// same number the specification declares.
	MaxRRULELength = 1024
	// DefaultHorizonDays is the column's own default (0001_init) and arc42 §6.3's window.
	DefaultHorizonDays = 90
	// MinHorizonDays and MaxHorizonDays bound the window. A year is the longest that still reads
	// as a rolling window rather than as "materialise the series"; a day is the shortest that can
	// hold a daily series at all.
	MinHorizonDays = 1
	MaxHorizonDays = 365
	// MaxOccurrencesPerHorizon is the bound T-17 asks for, checked at the write by expanding the
	// rule inside its own horizon: FREQ=SECONDLY is a denial of service wearing a calendar, and
	// the place to refuse it is where somebody wrote it rather than where the scheduler meets it.
	MaxOccurrencesPerHorizon = 500
)
View Source
const (
	FieldOffsetSpec = "offset_spec"
	FieldChannels   = "channels"
	FieldRecipients = "recipients"
)

The reminder's own field names, as the contract spells them. They travel into the change log, where a client matches them against the members it sent (offline-sync.md §4.2).

View Source
const (
	DueSoonLead          = 24 * time.Hour
	DueSoonThresholdSpec = "PT24H"
)

DueSoonLead is how far ahead of a deadline `item.due_soon` is announced, and DueSoonThresholdSpec is the same value as the event carries it (domain-model.md §4).

A fixed lead rather than one derived from the entry's own reminders, and the decision is worth keeping: a rule that reacts to "due soon" has to mean the same thing for every entry in a workspace, and a threshold that followed whatever reminder somebody happened to set would make the event fire at the same moment as that reminder - telling automation exactly what the reminder already said, and saying nothing at all about an entry nobody set one on.

View Source
const (
	FieldWipLimit     = "wip_limit"
	FieldIsDoneBucket = "is_done_bucket"
	FieldDeletedAt    = "deleted_at"
)

Field names of a collection's structure, as the API spells them. They travel into the event's change set and into the change log, where a client matches them against the members it sent - so they are written once here rather than as a literal at each place that has to agree.

FieldName, FieldDescription, FieldColorToken and FieldOrderKey are shared with Container and declared there.

View Source
const (
	// MaxTemplateNameLength and MaxTemplateDescriptionLength count code points, for the reason
	// every length in this codebase does (I-W7): a limit in bytes measures the alphabet.
	MaxTemplateNameLength        = 200
	MaxTemplateDescriptionLength = 2000
	// MaxTemplateNodes is what keeps an instantiation off the job queue: it is one synchronous
	// transaction, and this is how large it may be. Enforced at the definition and again at the
	// instantiation, and published in /meta/capabilities so that a client knows the number rather
	// than discovering it.
	MaxTemplateNodes = 500
	// MaxTemplateOffset is the longest a relative date may be: ten years, the same plausibility
	// bound a reminder's offset takes.
	MaxTemplateOffset = 3650 * 24 * time.Hour
)
View Source
const (
	FieldTemplateName        = "name"
	FieldTemplateDescription = "description"
	FieldTemplateNodes       = "nodes"
)

The template's own field names, as the contract spells them.

View Source
const DefaultCompletionPolicy = CompletionManual

DefaultCompletionPolicy is what a collection that has never been configured behaves as.

View Source
const DisplacedVersionCode = "sync.displaced_version"

DisplacedVersionCode is the heading of the one system comment this build files: the version of a free-text field that lost a merge (offline-sync.md §5).

View Source
const EmergencyAccessLasts = time.Hour

EmergencyAccessLasts is how long the workspace owner's emergency access to a private hub lasts (ADR-0073 §4). It is not extended: a second access while one runs is refused, and one after it ended is a new act, told to the members again.

View Source
const MaxBulkOperations = 500

MaxBulkOperations is how many single-entry operations one bulk may carry, the cap api-guidelines.md §5 states.

Here rather than beside the use case that enforces it, for the reason MaxTemplateNodes is in Template.go: it is a shape of the product, and /meta/capabilities publishes it so that a client knows the number rather than discovering it from a refusal. A selection bar that let somebody pick six hundred entries and told them at the end is exactly what publishing it prevents, and core/application/service/meta may not import another service package to read it.

View Source
const MaxCalendarUIDLength = 255

MaxCalendarUIDLength bounds a calendar UID. RFC 5545 bounds a UID by nothing; an address has to fit in a URL a client stores, and the value is opaque, so the bound is a generous one.

View Source
const MaxCommentBodyLength = 20000

MaxCommentBodyLength counts code points rather than bytes, for the reason the title limit does (I-W7): a limit in bytes would measure the alphabet rather than the text.

View Source
const MaxContainerNameLength = 200

MaxContainerNameLength counts code points rather than bytes. A limit in bytes would accept "Übersicht" and reject the same name written with a combining diaeresis, which is not a distinction a user can see (domain-model.md §3.3, I-W7).

View Source
const MaxEmergencyReason = 500

MaxEmergencyReason bounds the reason in characters: a sentence or two the members read in a mail and on their hub, not a document.

View Source
const MaxItemTitleLength = 500

MaxItemTitleLength counts code points rather than bytes, for the reason given at MaxContainerNameLength: a limit in bytes draws a line a user cannot see (I-W7).

View Source
const MaxReminderOffset = 3650 * 24 * time.Hour

MaxReminderOffset is the longest relative offset that means anything: ten years before or after a due date. Not a technical limit but a plausibility one - a duration beyond it is a typo or an attempt to overflow the arithmetic, and neither should reach the scheduler.

View Source
const MaxReminderOffsetSpecLength = 64

MaxReminderOffsetSpecLength is the contract's maxLength in code points, so that a refusal comes from the same number the specification declares.

View Source
const MaxReminderRecipients = 50

MaxReminderRecipients bounds the named list. Naming everybody is what the empty list is for, so a long list is not the way anybody uses this - which is what makes a bound safe here (T-17).

View Source
const MaxRemindersPerItem = 25

MaxRemindersPerItem bounds what one entry may carry.

A bound rather than a page, and the reason the list is unpaged: reminders on one entry are a handful by nature, and a limit is a better answer than a cursor through something that should never be long. It is published in /meta/capabilities, so a client knows the number rather than discovering it (api-guidelines.md §1).

View Source
const MaxStructureNameLength = 120

MaxStructureNameLength counts code points rather than bytes, for the reason MaxContainerNameLength does: a limit in bytes draws a line a user cannot see. 120 is what the column allows (db/schema.sql, bucket and label).

View Source
const PathSeparator = "/"

PathSeparator separates the identifiers of a materialised path.

Variables

This section is empty.

Functions

func ChannelList

func ChannelList(channels []ReminderChannel) string

ChannelList spells the channels the way a change and the audit entry carry them.

func CustomFieldPath

func CustomFieldPath(key string) string

CustomFieldPath is how one key is named outside the entry: in a change log entry, in an audit entry and in a query filter. One spelling, so that a device merging a change and a client writing a filter are talking about the same thing (api-guidelines.md §3, offline-sync.md §4.2).

func EnsureReminderCapacity

func EnsureReminderCapacity(existing int) error

EnsureReminderCapacity refuses the reminder that would take an entry past the bound.

func ErrEmergencyNotNeeded

func ErrEmergencyNotNeeded() error

ErrEmergencyNotNeeded refuses opening a private hub the owner already holds a role in.

func ErrEmergencyNotPrivate

func ErrEmergencyNotPrivate() error

ErrEmergencyNotPrivate refuses opening a shared hub: the owner reaches it already.

func ErrEmergencyRunning

func ErrEmergencyRunning(endsAt time.Time) error

ErrEmergencyRunning refuses a second access while the first runs, naming when it ends: the hour is not renewed.

func ErrPrivateHubsOff

func ErrPrivateHubsOff() error

ErrPrivateHubsOff refuses a new private hub where the workspace, or a level above it, does not allow private hubs (ADR-0073 §6). Every private hub already there stays private.

func ErrPrivateOnlyHubs

func ErrPrivateOnlyHubs() error

ErrPrivateOnlyHubs refuses privacy on anything but a hub: there are no private collections inside a shared hub (UC-ID-16, *Where it ends*).

func ParseTemplateOffset

func ParseTemplateOffset(spec string) (time.Duration, error)

ParseTemplateOffset reads a node's relative date: an ISO-8601 duration, signed, of weeks down to seconds.

The same grammar a reminder's REL offset takes, and refused for the same reasons - years and months are calendar arithmetic rather than a length of time, and "+1 month" would mean two different things in two different months.

func PathIDs

func PathIDs(path string) []shared.ID

PathIDs reads a materialised path back into the chain it encodes: the entries from the top of the collection down to and including this one.

The inverse of how a path is built, and it exists for the one question a stored path cannot answer otherwise: which entries sit above this one. A legal hold placed on a task has to reach the activities under it (data-retention.md §4.1), and walking the parent chain to find out would be a query per level for something the row already carries.

A malformed path yields nothing rather than an error. Every path in the database was written by checkPlacement, so an unreadable one is a defect elsewhere - and the caller of this is a deletion, which must not be stopped by a chain it can only fail to widen.

func PresentElements

func PresentElements(elements []SetElement) []shared.ID

PresentElements is the membership a merged set describes: the elements that are in it.

func RecipientList

func RecipientList(recipients []shared.ID) string

RecipientList spells the recipients the way a change carries them, and the empty string for the empty list - which is the list that means the assignee and the members.

func RootPath

func RootPath(id shared.ID) string

RootPath is the path of an item directly under the collection: the top of a subtree.

func SpellTemplateOffset

func SpellTemplateOffset(offset time.Duration) string

SpellTemplateOffset writes a duration back as the ISO-8601 form a template stores.

Canonical rather than as-given: unlike a reminder's offset, which is a string somebody typed and is kept as typed, a node's offset lives inside a document this code writes - so there is no original spelling to preserve, and one spelling means one string in the change log.

func ValidCalendarUID

func ValidCalendarUID(uid string) bool

ValidCalendarUID reports whether a string is one the tree could answer an entry at: one to MaxCalendarUIDLength bytes of the alphabet checkCalendarUID describes. Exported for the tree, which asks it of an address before spending a read on it.

Types

type AutoAssignCandidate

type AutoAssignCandidate struct {
	Kind CandidateKind
	ID   shared.ID
}

AutoAssignCandidate is one entry of the pool: an account, or - for RANDOM_GROUP_MEMBER - a group whose members are resolved when the draw happens, not when the policy is written.

type AutoAssignDefinition

type AutoAssignDefinition struct {
	Strategy   AutoAssignStrategy
	Candidates []AutoAssignCandidate
	Enabled    bool
}

AutoAssignDefinition is the policy as the policies document carries it: the strategy, its pool, and whether it applies itself. What the aggregate adds - the scope, the rotation state, the version - is the row's business, not the document's.

func ParseAutoAssignDefinition

func ParseAutoAssignDefinition(value any) (*AutoAssignDefinition, error)

ParseAutoAssignDefinition reads the auto_assign key of a submitted policies document, from any channel: the value arrives as decoded JSON, and nil is the key sent as null - which, exactly like an absent key, means no automatic assignment.

func (AutoAssignDefinition) Equal

Equal reports whether two definitions say the same thing. The candidate order is part of the meaning - it is the order ROUND_ROBIN walks - so a reordered list is a different policy.

func (AutoAssignDefinition) Validate

func (d AutoAssignDefinition) Validate() error

Validate is the definition's own share of that check.

type AutoAssignPolicy

type AutoAssignPolicy struct {
	ID       shared.ID
	TenantID shared.ID
	// ScopeType and ScopeID are where the policy applies. The schema also allows HUB; only
	// COLLECTION is written and resolved, because UpdateContainerPolicies - the one writer of
	// policy documents - refuses a hub.
	ScopeType  AutoAssignScope
	ScopeID    shared.ID
	Strategy   AutoAssignStrategy
	Candidates []AutoAssignCandidate
	State      AutoAssignState
	// Enabled is the difference between the two ways a policy is reached: an enabled policy applies
	// itself to everything created in the collection, a disabled one waits to be asked for with
	// `auto_assign` on the create path. Removing the policy is deleting the row, not disabling it.
	Enabled bool
	Version int
}

AutoAssignPolicy is a collection's rule for handing out what is created in it (domain-model.md §3.6).

Its own row rather than a value inside the container's `policies` document, because two of the strategies are stateful in a way a document is not: ROUND_ROBIN advances a cursor that has to be locked against a concurrent create, and LEAST_LOADED is a query. The `autoAssign` key of the policies document is this row's representation in the contract; the row is the storage, so there is exactly one place a strategy and its state can disagree with themselves - nowhere.

func (AutoAssignPolicy) Definition

func (p AutoAssignPolicy) Definition() AutoAssignDefinition

Definition is the document half of the policy.

func (AutoAssignPolicy) Validate

func (p AutoAssignPolicy) Validate() error

Validate checks what every strategy demands of its pool. Called where the policy document is written, so that a policy that cannot choose anybody is refused before it is stored rather than discovered by the create that consults it.

type AutoAssignScope

type AutoAssignScope string

AutoAssignScope is where a policy hangs.

const (
	AutoAssignScopeCollection AutoAssignScope = "COLLECTION"
	AutoAssignScopeHub        AutoAssignScope = "HUB"
)

type AutoAssignState

type AutoAssignState struct {
	Cursor int
}

AutoAssignState is what ROUND_ROBIN remembers between assignments: the index into the configured candidate list whose turn is next. Explicitly visible (domain-model.md §3.6) - the state is data in the policy row, so a test and an operator read the same thing.

The cursor indexes the configured list rather than the currently eligible subset, so that a candidate who is skipped while ineligible has lost nothing but that one turn: the rotation's order does not depend on who happened to be eligible when.

type AutoAssignStrategy

type AutoAssignStrategy string

AutoAssignStrategy is how a candidate is picked (domain-model.md §3.6).

const (
	// AssignFixed always picks the one configured account. The fixed thing is a person; "always
	// the same group" is RANDOM_GROUP_MEMBER with a single group, because an assignee is an
	// account and a group can only ever reach the field through one of its members.
	AssignFixed AutoAssignStrategy = "FIXED"
	// AssignRandomMember draws uniformly from the candidate accounts.
	AssignRandomMember AutoAssignStrategy = "RANDOM_MEMBER"
	// AssignRandomGroupMember draws a group, then one of its members - so a small team and a
	// large one carry the same share of the work, not the same share per person.
	AssignRandomGroupMember AutoAssignStrategy = "RANDOM_GROUP_MEMBER"
	// AssignRoundRobin walks the candidate list in order, remembering where it stands in State.
	AssignRoundRobin AutoAssignStrategy = "ROUND_ROBIN"
	// AssignLeastLoaded picks the candidate with the fewest open entries.
	AssignLeastLoaded AutoAssignStrategy = "LEAST_LOADED"
)

func AutoAssignStrategies

func AutoAssignStrategies() []AutoAssignStrategy

AutoAssignStrategies returns every defined strategy, for the same reason CompletionPolicies does: a client's form is configured from the installation, not from a copy of this list.

func ParseAutoAssignStrategy

func ParseAutoAssignStrategy(value string) (AutoAssignStrategy, error)

ParseAutoAssignStrategy reads a stored or submitted strategy. No default: a policy without a strategy is not a policy, so the empty value is as unknown as a misspelled one.

func (AutoAssignStrategy) Valid

func (s AutoAssignStrategy) Valid() bool

Valid reports whether the strategy is one of the defined ones.

type Bucket

type Bucket struct {
	ID       shared.ID
	TenantID shared.ID
	// CollectionID is the collection whose board this bucket is a column of. Only a collection
	// has one; the application layer is what refuses a hub.
	CollectionID shared.ID
	Name         string
	// OrderKey ranks the bucket among the collection's other buckets: a fractional index, so that
	// two offline devices can insert a column without either one's order being discarded
	// (offline-sync.md §4.2).
	OrderKey string
	// WipLimit is how many items the people using this board agreed to have in the column at once.
	// Nil is "no limit", which is a different thing from zero - and zero is not a limit anybody
	// could satisfy, which is why the column refuses it.
	//
	// Nothing enforces it on a write. It is advisory by design: a limit that refused the drop
	// would make a board a form, and the requirement is that the column turns red rather than
	// that the work becomes impossible.
	WipLimit *int
	// IsDoneBucket marks the column that means "finished". Stored and reported; what reacts to it
	// is the client that renders the board and, later, an automation rule - the server completes
	// nothing on its own account here (domain-model.md §3.5).
	IsDoneBucket bool
	// ColorToken is optional, and the empty string is "not set" rather than a value - which is
	// what lets the adapter store NULL without a second flag per field.
	ColorToken string
	// DeletedAt is the soft delete. A bucket is deleted rather than trashed - it holds no content
	// of its own, so there is nothing for a retention period to protect - but the row stays, so
	// that a change log entry can still name it and a restore stays possible (offline-sync.md §7).
	DeletedAt *time.Time
	// Version is the optimistic lock. It starts at 1, which is what the column default says.
	Version int
}

Bucket is a column of a collection's board: the "list" of the requirements (domain-model.md §3.5).

It belongs to a collection and to nothing else, which is why it carries no parent beyond CollectionID and no type: a bucket under a hub would have no items to hold, and a bucket under an item would be a second hierarchy next to the one I-W2 already describes.

What is deliberately absent: created_at, updated_at and created_by. The column has none of them (db/schema.sql), and a struct field with no column behind it is a value that reads back as zero - which is worse than not answering the question at all. Who moved a bucket and when is the activity history's answer, not this row's.

func NewBucket

func NewBucket(in NewBucketInput) (Bucket, error)

NewBucket builds a bucket and checks its invariants (project-structure.md §3: constructors check, callers do not).

Uniqueness of the name within the collection is not checked here and cannot be: this type sees one bucket. It is the unique index that decides it, translated by the adapter into `buckets.name_taken` - a check followed by an insert is two statements with a gap between them, and two requests arriving in that gap both pass the check (multi-tenancy.md §2.1).

func (Bucket) Deleted

func (b Bucket) Deleted(at time.Time) (Bucket, []FieldChange, error)

Deleted takes the bucket off the board.

A soft delete, and idempotent: a bucket already deleted comes back untouched, which is what makes a retry after a lost response harmless. Where its items go is the use case's decision - this type sees one bucket and cannot answer for the collection's other columns.

func (Bucket) EnsureEditable

func (b Bucket) EnsureEditable() error

EnsureEditable refuses a bucket that is no longer on the board.

A conflict rather than a validation failure: the request is well formed and the state is what makes it impossible, which is the distinction that tells a client whether anything it could do would help (api-guidelines.md §6). Whether the collection above it is archived is a question about the collection, and the use case asks Container.EnsureEditable for it.

func (Bucket) IsDeleted

func (b Bucket) IsDeleted() bool

IsDeleted reports whether the bucket has been removed from its collection's board.

func (Bucket) Reordered

func (b Bucket) Reordered(orderKey string) (Bucket, []FieldChange, error)

Reordered returns the bucket at a new rank among its collection's buckets, and reports the move.

Idempotent: a bucket asked for the rank it already holds comes back untouched, so that nothing is written and nothing is announced.

func (Bucket) Updated

func (b Bucket) Updated(attributes BucketAttributes, form text.Normalizer) (Bucket, []FieldChange, error)

Updated applies a change and reports which fields moved.

Nothing that did not move is in the result, and a request that changes nothing returns the bucket untouched with no changes at all - the contract Container.Renamed and WorkItem.Updated both keep, and for the same reason: the caller writes nothing, spends no version and announces nothing, which is what makes a repeat harmless rather than merely accepted.

type BucketAttributes

type BucketAttributes struct {
	Name       *string
	ColorToken *string
	// WipLimit is a pointer to a number of which zero means "clear it", exactly as the empty
	// string does for the text fields. Zero is not a limit the column can hold, so nothing is lost
	// by reading it that way - and the alternative, a pointer to a pointer, is a type nobody reads
	// correctly twice.
	WipLimit     *int
	IsDoneBucket *bool
}

BucketAttributes is what an update may change.

A pointer per field, so that "set it to nothing" and "do not touch it" stay two different requests all the way down from the merge patch that expressed them (offline-sync.md §4.2). The rank is not here: where a bucket sits is ReorderBucket's decision, with its own endpoint and its own neighbour lookup, and a patch that quietly reordered a board would be a second answer to that question.

func (BucketAttributes) IsEmpty

func (a BucketAttributes) IsEmpty() bool

IsEmpty reports whether the update asks for nothing at all.

type CandidateKind

type CandidateKind string

CandidateKind says what a candidate identifier names.

const (
	CandidateAccount CandidateKind = "ACCOUNT"
	CandidateGroup   CandidateKind = "GROUP"
)

func ParseCandidateKind

func ParseCandidateKind(value string) (CandidateKind, error)

ParseCandidateKind reads a stored or submitted candidate kind.

type Capability

type Capability string

Capability is one thing an item type may do. The values are the rows of the capability matrix in domain-model.md §2.

const (
	CapabilityCompletion   Capability = "COMPLETION"
	CapabilityDueDate      Capability = "DUE_DATE"
	CapabilityReminder     Capability = "REMINDER"
	CapabilityAssignment   Capability = "ASSIGNMENT"
	CapabilityMembers      Capability = "MEMBERS"
	CapabilityBucket       Capability = "BUCKET"
	CapabilityNotes        Capability = "NOTES"
	CapabilityLabels       Capability = "LABELS"
	CapabilityComments     Capability = "COMMENTS"
	CapabilityCover        Capability = "COVER"
	CapabilityAttachments  Capability = "ATTACHMENTS"
	CapabilityHistory      Capability = "HISTORY"
	CapabilityRecurrence   Capability = "RECURRENCE"
	CapabilityCustomFields Capability = "CUSTOM_FIELDS"
)

type CapabilityProfile

type CapabilityProfile struct {
	Type              ItemType
	Capabilities      []Capability
	AllowedChildTypes []ItemType
	// MaxDepth is relative to the collection: how deep a subtree of this type may go.
	MaxDepth int
}

CapabilityProfile is what one item type may do, and what may sit underneath it.

It is a policy rather than a rule, which is why it is data: the system ships defaults and a tenant may narrow them. Widening past the system boundary is not permitted, and the place that enforces that is whatever writes an override - not this type, which describes the profile in force rather than deciding it (domain-model.md §2).

func (CapabilityProfile) Allows

func (p CapabilityProfile) Allows(capability Capability) bool

Allows reports whether the capability is active for this type. Setting a field whose capability is not active produces ErrCapabilityNotSupported rather than being ignored - silent ignoring is how a client comes to believe it stored something (domain-model.md §2).

func (CapabilityProfile) AllowsChild

func (p CapabilityProfile) AllowsChild(child ItemType) bool

AllowsChild reports whether an item of the given type may sit directly underneath one of this type.

func (CapabilityProfile) HistoryIsCompact

func (p CapabilityProfile) HistoryIsCompact() bool

HistoryIsCompact reports whether this type's history keeps the verb alone rather than the fields that moved with it.

The capability matrix gives every type HISTORY and notes "compact history for activities" (domain-model.md §2), which leaves the form to be derived rather than declared - and derived from the matrix itself, so that a tenant narrowing a profile gets the form that goes with what it narrowed to. A type carrying none of the fields a diff would be about - notes, labels, comments, custom fields - has, apart from its title, only state a client reads off the entry: done or open, a date, an assignee. Recording a per-field diff of those would repeat what the reader already has in front of them.

func (CapabilityProfile) Require

func (p CapabilityProfile) Require(capability Capability, field string) error

Require refuses a field whose capability this type does not carry.

It is the gate ADR-0006 asks for, in the one place that can produce it consistently. The refusal names the type and the capability rather than the field alone, because that is the information a client needs in order to act: `notes` is not wrong, `notes on an ACTIVITY` is, and the fix is to write to the work package above it instead.

The field is a JSON pointer into the request, so a form can mark the input the caller filled in rather than the request as a whole (api-guidelines.md §6).

type ChildCompletion

type ChildCompletion struct {
	Total     int
	Completed int
}

ChildCompletion summarises the children of one item: how many there are, and how many are done.

A summary rather than the children themselves, and that is a deliberate shape. The roll-up asks one question - "is anything still open down there" - and a repository can answer it with a count, where handing up every child would mean reading a subtree to decide one boolean. It also keeps the decision a pure function of two numbers, which is what makes every policy table-testable without a database.

Trashed children are not counted at all: they are deletions waiting out their retention period, and a work package whose last activity was deleted should not become done because of it. Archived children are counted as they stand - archiving is a decision to keep something quietly, not to disown it.

func (ChildCompletion) AllCompleted

func (c ChildCompletion) AllCompleted() bool

AllCompleted reports whether nothing is left open. False for no children at all: "every child is done" of an empty set is vacuously true, and completing a parent on that basis would complete it the moment its last child was trashed.

func (ChildCompletion) AnyOpen

func (c ChildCompletion) AnyOpen() bool

AnyOpen reports whether at least one child is still open.

type Comment

type Comment struct {
	ID       shared.ID
	TenantID shared.ID
	ItemID   shared.ID
	AuthorID shared.ID
	// ParentCommentID is the comment this one replies to, empty for a top-level comment. One
	// level only (§3.5): a reply cannot itself be replied to, which keeps a thread a list rather
	// than a tree a client has to render recursively.
	ParentCommentID shared.ID
	// Body is the text, and empty exactly when the comment is deleted: a soft deletion keeps the
	// row so the thread stays readable, and clears the text because a deletion that only hid the
	// words would be retained personal content with no purpose (data-protection.md). A legal hold
	// is such a purpose: the store then keeps the text, but this value never carries it
	// (data-retention.md §4).
	Body      string
	CreatedAt time.Time
	// EditedAt is when the body was last rewritten, nil for never. Its own stamp rather than a
	// generic updated_at, because it is a fact the thread shows: "edited" beside a comment is
	// this field, and a deletion must not set it.
	EditedAt  *time.Time
	DeletedAt *time.Time
	Version   int
	// Kind tells what somebody wrote from what the server filed on their behalf (offline-sync.md
	// §5). SystemCode and SystemParams are a system comment's heading - a message code and its
	// parameters, never a sentence the server composed (rule 8) - and empty on a comment somebody
	// wrote.
	Kind         CommentKind
	SystemCode   string
	SystemParams map[string]string
}

Comment is one contribution to the discussion beside an entry (domain-model.md §3.5).

Its own entity rather than a field of the item, because the two obey different merge rules: comments append and never merge (offline-sync.md §4.2, "appending lists"), so each is its own row with its own change log entries, and only an edit needs a rule at all - last writer wins over the body, with the displaced text not preserved (offline-sync.md §4.2).

func NewComment

func NewComment(input NewCommentInput) (Comment, error)

NewComment validates and builds a comment.

func (Comment) Edited

func (c Comment) Edited(body string, form text.Normalizer, at time.Time) (Comment, error)

Edited returns the comment with its body rewritten.

The displaced text is not preserved: an edit is last writer wins over the body (offline-sync.md §4.2). Editing a deleted comment is refused - the text is gone, and an edit that resurrected it would be an undelete nobody declared.

The normaliser is handed in for the reason NewComment takes one (i18n-l10n.md §5).

func (Comment) Removed

func (c Comment) Removed(at time.Time) Comment

Removed returns the comment as its tombstone: the text gone, the identity, the author and the timestamps kept, so that a reply does not dangle. EditedAt survives - that the words had been rewritten is part of the thread's history, what they were is not.

Idempotent: a comment already deleted comes back untouched, so that nothing is written, no version is spent and nothing is announced.

type CommentKind

type CommentKind string

CommentKind is who a comment is from: the person, or the server on the person's behalf.

const (
	CommentByUser   CommentKind = "USER"
	CommentBySystem CommentKind = "SYSTEM"
)

type Completion

type Completion struct {
	IsCompleted bool
	CompletedAt *time.Time
	CompletedBy shared.ID
}

Completion is the done/open state, together with who closed it and when (domain-model.md §3.4).

One value rather than three fields, because the three are only ever meaningful together: a completed item without a timestamp, or a timestamp on an open one, are states nothing should be able to express. CompleteWorkItem is what moves it.

type CompletionPolicy

type CompletionPolicy string

CompletionPolicy is a collection's answer to one question: when a child's completion changes, does anything happen to the item above it (domain-model.md §3.3, invariant I-W5).

A policy of the collection rather than of the item, because it is a way of working rather than a property of one task: a team that tracks a work package as done when its activities are done wants that for the whole collection, and a team that closes a work package deliberately wants that everywhere too.

Two values. `ROLLUP` is the one domain-model.md names; `MANUAL` is the absence of it, and it is the default because it is the behaviour that surprises nobody - an item changes when somebody changes it. No third value for "roll up completing but not reopening": nothing asks for it, and a policy that completed a parent automatically and then required a person to reopen it by hand would leave the parent wrong in exactly the case the roll-up exists to handle.

const (
	// CompletionManual leaves the parent alone. Completing the last activity of a work package
	// completes that activity, and nothing else.
	CompletionManual CompletionPolicy = "MANUAL"
	// CompletionRollup completes the parent when its last open child is completed, and reopens it
	// when any child is reopened (I-W5).
	CompletionRollup CompletionPolicy = "ROLLUP"
)

func CompletionPolicies

func CompletionPolicies() []CompletionPolicy

CompletionPolicies returns every defined policy. /meta/capabilities answers from it, so a client configures its own form from the installation rather than from a copy of this list.

func ParseCompletionPolicy

func ParseCompletionPolicy(value string) (CompletionPolicy, error)

ParseCompletionPolicy reads a stored or submitted policy.

The empty value is the default rather than an error: the `policies` column starts as `{}`, so every collection that predates this policy has no value for it, and a read that failed on those would make the column impossible to introduce. Anything else that is not a known policy *is* an error - a value nobody recognises is not a reason to silently pick a behaviour.

func (CompletionPolicy) OrDefault

func (p CompletionPolicy) OrDefault() CompletionPolicy

OrDefault reads the absent policy as the default: a collection that has never been configured behaves as MANUAL, and so does a hub, which has no items for a policy to decide about.

One rule in one place. The column starts as `{}` and a Container built anywhere but by the adapter carries the zero value, so every reader would otherwise have to know that "" and MANUAL are the same thing - and one of them would eventually not.

func (CompletionPolicy) RollsUp

func (p CompletionPolicy) RollsUp() bool

RollsUp reports whether this policy propagates a child's change upwards. The one question the completion service asks of it, kept here so that adding a policy that also rolls up is a change to this line rather than to every comparison against ROLLUP.

func (CompletionPolicy) Valid

func (p CompletionPolicy) Valid() bool

Valid reports whether the policy is one of the defined ones.

type Container

type Container struct {
	ID       shared.ID
	TenantID shared.ID
	Type     ContainerType
	// ParentID is the hub a collection sits in, and empty for a hub (I-C1).
	ParentID shared.ID
	Name     string
	// Description, Icon and ColorToken are optional; the empty string is "not set" rather than a
	// value, which is what lets the adapter store NULL without a second flag per field.
	Description string
	Icon        string
	ColorToken  string
	// OrderKey ranks the container among its siblings. A lexicographic key rather than a number,
	// so an insertion between two neighbours renumbers nothing (offline-sync.md §4.2).
	OrderKey string
	// ArchivedAt and DeletedAt are the lifecycle, as timestamps rather than as a status column:
	// restoring then moves no data (domain-model.md §6).
	ArchivedAt *time.Time
	DeletedAt  *time.Time
	// DeletedBy is who put it in the trash, meaningful only while DeletedAt is set (0070).
	DeletedBy DeletedBy
	// ParentArchivedAt is the hub's own stamp, read alongside the row rather than stored on it. That
	// is invariant I-C3's second half: a collection in an archived hub is read-only without being
	// archived itself, and the two facts stay separate so that unarchiving the hub restores exactly
	// what it covered - a collection archived in its own right stays archived.
	//
	// Denormalising it onto the collection would mean archiving a hub wrote every collection under it,
	// and unarchiving could no longer tell which of them had been archived before.
	ParentArchivedAt *time.Time
	// TrashBatchID ties every container trashed in one operation together, so that restoring a
	// subtree is one decision rather than a walk (I-C2). Set by TrashContainer, which arrives
	// with its own use case.
	TrashBatchID shared.ID
	CreatedBy    shared.ID
	CreatedAt    time.Time
	UpdatedAt    time.Time
	// CompletionPolicy decides whether a child's completion propagates upwards (I-W5). Only a
	// collection carries a meaningful one - a hub holds no items - and it is read off both, because
	// the column is on both and a reader that skipped hubs would be a second rule to keep.
	CompletionPolicy CompletionPolicy
	// AutoAssign is how what is created in this collection is handed out, nil for not at all
	// (domain-model.md §3.6). Stored in its own row and loaded by the adapter; nil on a hub,
	// which has no policies to carry it.
	AutoAssign *AutoAssignDefinition
	// Version is the optimistic lock. It starts at 1, which is what the column default says, so
	// that a freshly created container has an ETag before it has been read back.
	Version int
	// Private is a hub reached only through a membership on it or below it (ADR-0073 §1). Only a
	// hub carries it: privacy is per hub, never per collection.
	Private bool
}

Container is a hub or a collection (domain-model.md §3.3).

Of the `policies` column's four documented keys, two are here: the completion policy and the automatic assignment, because the completion and the assignment read them. The other two - the default bucket, capability overrides - stay absent: a field nothing reads and nothing writes is a promise nothing keeps.

UpdateContainerPolicies writes both keys, and a collection that has never been configured reads as the defaults - which is why the column starting as `{}` is a special case nowhere above the adapter. The auto_assign key is stored in its own row rather than in the column (see AutoAssignPolicy); on this type it is the document's read model, loaded by the adapter.

func NewContainer

func NewContainer(in NewContainerInput) (Container, error)

NewContainer builds a container and checks its invariants (project-structure.md §3: constructors check, callers do not).

The name is trimmed here rather than in the adapter, because the trimmed form is the one the uniqueness check must see: " Team" and "Team" are the same name to a person, and a check that disagrees with a person is a bug report waiting.

func (Container) Archived

func (c Container) Archived(at time.Time) (Container, []FieldChange, error)

Archived stamps the container read-only, and reports the change as the other verbs do.

Idempotent: archiving an archived container succeeds with no changes, which is what makes a retry after a lost response harmless. The inherited state is a gate rather than a no-op - a collection whose hub is archived is inside an archived subtree, and archiving it would be a write into one (I-C3). Unarchive the hub first; the answer names it.

func (Container) ArchivedRootID

func (c Container) ArchivedRootID() shared.ID

ArchivedRootID names the container a client would have to unarchive: this one, or the hub above it. Carried in the refusal, because "it is archived" is unhelpful when the archived thing is not the object the client named.

func (Container) EffectiveArchivedAt

func (c Container) EffectiveArchivedAt() *time.Time

EffectiveArchivedAt is when the read-only state began: this container's own stamp, or the hub's. The container's own wins when both carry one - it is the more specific decision, and the one unarchiving this container would lift.

func (Container) EnsureAcceptsChildren

func (c Container) EnsureAcceptsChildren(child ContainerType) error

EnsureAcceptsChildren refuses what would be created underneath a container that cannot take it.

Both refusals are conflicts rather than validation errors: the request is well formed and the state is what makes it impossible, which is the distinction a client needs in order to know whether retrying after a change of its own would help (api-guidelines.md §6).

func (Container) EnsureAcceptsItems

func (c Container) EnsureAcceptsItems() error

EnsureAcceptsItems refuses a work item under a container that cannot hold one.

Only a collection holds items: a hub holds collections, and that is the whole of the container hierarchy (domain-model.md §1). The codes are the item's rather than the container's, because the field the caller has to change is `collection_id` on the item it was creating - the container itself is not at fault and needs no fixing.

func (Container) EnsureEditable

func (c Container) EnsureEditable() error

EnsureEditable refuses a container whose state makes it read-only (I-C2, I-C3).

A conflict rather than a validation failure: the request is well formed and the state is what makes it impossible, which is the distinction that tells a client whether unarchiving something first would help (api-guidelines.md §6). `archived_id` says what that something is - this container, or the hub above it.

func (Container) IsArchived

func (c Container) IsArchived() bool

IsArchived reports whether this container carries the archive stamp itself. The question ArchiveContainer and UnarchiveContainer ask - not the one a write asks, which is the next method.

func (Container) IsEffectivelyArchived

func (c Container) IsEffectivelyArchived() bool

IsEffectivelyArchived reports invariant I-C3 as a write sees it: archived itself, or sitting in an archived hub. Every refusal of a write reads this rather than IsArchived, because "the hub above this is archived" and "this is archived" are the same answer to a client that wanted to write.

func (Container) IsTrashed

func (c Container) IsTrashed() bool

IsTrashed reports whether the container is in the trash. Distinct from archived: the trash is a deletion waiting out its retention period, archiving is a decision to keep something quietly.

func (Container) MovedInto

func (c Container) MovedInto(parent Container, orderKey string, at time.Time) (Container, []FieldChange, error)

MovedInto returns the container as it sits under a new parent, and reports what moved with it.

Everything a create refuses a move refuses too: the type under the new parent, a trashed or archived destination (Container.EnsureAcceptsChildren). Re-checking rather than assuming is what makes a narrowing visible - the destination may have been archived since the client read it.

A hub is refused outright. It sits in the tenant and in nothing else (I-C1), so there is no destination to name and a move would have to invent one.

func (Container) Ranked

func (c Container) Ranked(orderKey string, at time.Time) (Container, []FieldChange, error)

Ranked returns the container at a different rank within the level it already sits in, and reports the change.

It applies to a hub as well as to a collection, which is the whole reason it exists beside MovedInto: a hub sits in the tenant and in nothing else (I-C1), so it has no destination to name and MovedInto refuses it - while `order_key` is a field every container carries and domain-model.md §3.3 defines as "ordering within the parent context". For a hub that context is the tenant, and without this the rank of a hub is a field that is read and never written.

Nothing about the parent changes here. That is what separates the two: a move may also reorder, and a reorder never moves.

func (Container) Renamed

func (c Container) Renamed(
	attributes ContainerAttributes, form text.Normalizer, at time.Time,
) (Container, []FieldChange, error)

Renamed applies a change to the container's own fields and reports which of them moved.

Nothing that did not move is in the result, and a request that changes nothing returns the container untouched with no changes at all. That is what makes a repeat harmless rather than merely accepted: the caller writes nothing, spends no version and announces nothing - the same contract WorkItem.Updated keeps, for the same reason.

The normaliser is handed in for the reason NewContainer takes one (i18n-l10n.md §5).

func (Container) Restored

func (c Container) Restored(at time.Time) (Container, []FieldChange, error)

Restored takes the container out of the trash, clearing the batch with the stamp.

func (Container) Trashed

func (c Container) Trashed(
	at time.Time, batch shared.ID, by DeletedBy,
) (Container, []FieldChange, error)

Trashed puts the container into the trash as part of one deletion, named by batch.

The same batch as every row beneath it: the collections of a hub and the items of a collection go in with it, and one identifier over all of them is what makes the way back a single decision rather than a walk that has to guess what belonged together (I-C2).

The gate is the one archiving uses, and it refuses the same thing: a container inside an archived hub. That subtree is read-only, and deleting out of it is a write into it - unarchive the hub first, and the answer names it. A hub archived in its own right is not refused: Archived --> Trashed is a documented edge, and the archive stamp survives so that restoring gives back what was deleted rather than something subtly different.

func (Container) Unarchived

func (c Container) Unarchived(at time.Time) (Container, []FieldChange, error)

Unarchived lifts this container's own stamp.

Only its own. A collection archived in its own right stays archived when its hub is unarchived, because unarchiving restores what was archived and not what was merely covered by it - which is the whole reason the inherited stamp is never written onto the row.

func (Container) WithPolicies

func (c Container) WithPolicies(policies ContainerPolicies, at time.Time) (Container, []FieldChange, error)

WithPolicies replaces the container's policies and reports what moved.

A hub is refused rather than accepted and ignored. A hub holds collections and no items, so a completion policy on one decides nothing; storing it would be a setting a person could change and never see take effect (domain-model.md §3.3).

func (Container) WithPrivacy

func (c Container) WithPrivacy(private bool, at time.Time) (Container, []FieldChange, error)

WithPrivacy makes a hub private or shared again (ADR-0073 §1) and reports whether it moved. Who may is the application's question; that only a hub can be, and only one that may be written, is this one's.

type ContainerAttributes

type ContainerAttributes struct {
	Name        *string
	Description *string
	Icon        *string
	ColorToken  *string
}

ContainerAttributes is what a rename may change: the container's own descriptive fields.

A pointer per field, so that "set it to nothing" and "do not touch it" stay two different requests all the way down from the merge patch that expressed them. A struct of plain strings could not tell a caller that sent no icon from one that sent an empty one, and would clear the icon of every client that only meant to rename something.

The policies are not here. They are a different decision with a different endpoint and a different audit entry - what a collection is called and how it works are not one field set.

func (ContainerAttributes) IsEmpty

func (a ContainerAttributes) IsEmpty() bool

IsEmpty reports whether the update asks for nothing at all.

type ContainerPolicies

type ContainerPolicies struct {
	CompletionPolicy CompletionPolicy
	// AutoAssign is the auto_assign key, nil for the default: no automatic assignment.
	AutoAssign *AutoAssignDefinition
}

ContainerPolicies is how a collection works, as the policies document carries it.

A value type rather than pointers: this is a PUT, and a key that is not sent falls back to its default rather than keeping what was stored. A configuration document that partly remembers what it replaced is one nobody can reason about - and with one key today, it would be one key nobody noticed getting that wrong.

type ContainerType

type ContainerType string

ContainerType is the level of a container: a hub holds collections, a collection holds work items. The two levels are one type rather than two entities for the same reason the item levels are (domain-model.md §1) - one table, one repository, one set of use cases.

const (
	ContainerHub        ContainerType = "HUB"
	ContainerCollection ContainerType = "COLLECTION"
)

func ContainerTypes

func ContainerTypes() []ContainerType

ContainerTypes returns every defined type. An adapter uses it to prove it handles all of them.

func (ContainerType) AllowsChild

func (t ContainerType) AllowsChild(child ContainerType) bool

AllowsChild is invariant I-C1 read downwards: a hub takes collections, a collection takes work items and no further containers (domain-model.md §3.3).

func (ContainerType) Valid

func (t ContainerType) Valid() bool

Valid reports whether the type is one of the defined ones.

type Cover

type Cover struct {
	Kind CoverKind
	// ColorToken is set exactly for a COLOR cover.
	ColorToken string
	// MediaID is set exactly for an IMAGE cover.
	MediaID shared.ID
}

Cover is how a card presents itself: a colour token, or an image (domain-model.md §3.4).

Exactly one of the two, matching the kind - the same shape the database CHECK pins (migration 0013). The colour is a token rather than a value, for the reason a label's is: theming belongs to the client, and a hex value stored here would be a decision the design system cannot revisit (ADR-0029). Which media object may stand behind an image cover - READY, of usage COVER, of this tenant - is the application's question; this type only holds the reference.

func NewCover

func NewCover(kind CoverKind, token string, mediaID shared.ID) (Cover, error)

NewCover validates the exactly-one rule.

func (Cover) Equal

func (c Cover) Equal(other Cover) bool

Equal reports whether two covers say the same thing.

type CoverKind

type CoverKind string

CoverKind is which of the two a cover is.

const (
	CoverColor CoverKind = "COLOR"
	CoverImage CoverKind = "IMAGE"
)

type CustomFieldAttributes

type CustomFieldAttributes struct {
	Options    *[]string
	IsRequired *bool
	AppliesTo  *[]ItemType
}

CustomFieldAttributes is what an update may change. A pointer per field, so that "set it to nothing" and "do not touch it" stay two different requests.

No key and no kind: a key that moved would orphan every value stored under it, and a kind that changed would reinterpret them. Both are a new definition rather than an edit.

func (CustomFieldAttributes) IsEmpty

func (a CustomFieldAttributes) IsEmpty() bool

IsEmpty reports whether the update asks for nothing at all.

type CustomFieldDefinition

type CustomFieldDefinition struct {
	ID       shared.ID
	TenantID shared.ID
	// CollectionID is the collection the definition belongs to, zero for the whole workspace. Two
	// scopes and no more: a field every collection defines separately is a field nobody can filter
	// across, and a field only ever defined workspace-wide is one team's vocabulary imposed on
	// every other (domain-model.md §3.5).
	CollectionID shared.ID
	// Key is what the value is stored under. An identifier rather than a label, and fixed once
	// defined: it appears in a `custom_fields.<key>` filter, and a key that could be renamed would
	// orphan every value stored under it.
	Key  string
	Kind CustomFieldKind
	// Options are the permitted values of a SELECT or a MULTI_SELECT, and empty for every other
	// kind. Values rather than labels: what a person sees is the client's to render (rule 8).
	Options []string
	// IsRequired is enforced when the field is written and never retroactively: making a field
	// required does not make the entries that predate it invalid, and a rule that did would make
	// every one of them unsaveable at once.
	IsRequired bool
	// AppliesTo are the item types that carry the field. Never empty - a definition no type
	// carries is one nothing could ever hold.
	AppliesTo []ItemType
	CreatedAt time.Time
	UpdatedAt time.Time
	// DeletedAt is the soft delete. The values stay in the entries and stop being visible:
	// rewriting `custom_fields` across every entry in a collection would be an unbounded write
	// from one request.
	DeletedAt *time.Time
	Version   int
}

CustomFieldDefinition is one field a workspace or a collection adds to its entries (domain-model.md §3.5, §6).

The values live on the entry, in `work_item.custom_fields`; this says which keys exist, what they may hold and which types carry them. Validation is domain code and lives here rather than in the database: a `SELECT` value outside its options and a `NUMBER` arriving as a string are refusals a client has to be able to act on, and a CHECK constraint cannot say which field was wrong.

func NewCustomFieldDefinition

func NewCustomFieldDefinition(in NewCustomFieldInput) (CustomFieldDefinition, error)

NewCustomFieldDefinition builds a definition and checks its invariants.

Uniqueness of the key within its scope is the unique index's decision rather than this type's, for the reason NewLabel gives: a check followed by an insert is two statements with a gap between them, and two requests arriving in that gap both pass the check.

func (CustomFieldDefinition) Carries

func (d CustomFieldDefinition) Carries(itemType ItemType) bool

Carries reports whether an entry of this type holds the field.

func (CustomFieldDefinition) Deleted

Deleted takes the definition out of use. A soft delete, and idempotent.

func (CustomFieldDefinition) EnsureEditable

func (d CustomFieldDefinition) EnsureEditable() error

EnsureEditable refuses a definition that is out of use. A conflict rather than a validation failure, for the reason Label.EnsureEditable gives.

func (CustomFieldDefinition) IsDeleted

func (d CustomFieldDefinition) IsDeleted() bool

IsDeleted reports whether the definition is out of use.

func (CustomFieldDefinition) IsTenantWide

func (d CustomFieldDefinition) IsTenantWide() bool

IsTenantWide reports whether the definition applies to the whole workspace.

func (CustomFieldDefinition) Updated

Updated applies a change and reports which fields moved. Nothing that did not move is in the result, and a request that changes nothing returns the definition untouched.

Narrowing the options does not rewrite the entries holding a value that is no longer offered. That would be the unbounded write the soft delete exists to avoid, and the value is not wrong - it was permitted when it was written. It stops being offered, and the next write of that field is refused unless it picks from the new list.

The normaliser is handed in for the reason NewCustomFieldDefinition takes one (i18n-l10n.md §5).

func (CustomFieldDefinition) ValidateValue

func (d CustomFieldDefinition) ValidateValue(value any, form text.Normalizer) (any, error)

ValidateValue judges one value against the definition and returns it normalised.

The normalisation is the point of returning a value at all: what is stored is what came back from here, so a number that arrived as a float and a date that arrived with a time are one shape in the column rather than as many shapes as there are clients. A nil value means the key is being cleared, which a required field refuses.

The types it accepts are the ones a JSON document produces once decoded - `float64` for every number, `string`, `bool`, `[]any` - because that is what reaches this from all three channels. A NUMBER arriving as a string is refused rather than parsed: a client that sent "3" meant a string, and guessing turns a typo into stored data (domain-model.md §6).

The normaliser is for the kinds that hold text a person typed (i18n-l10n.md §5): a TEXT value is stored in normal form C like a note, and a choice is brought to the form the options were stored in before it is looked for among them.

type CustomFieldKind

type CustomFieldKind string

CustomFieldKind is what a value for a field looks like. The eight the schema has carried since `0001_init` (domain-model.md §3.5).

const (
	CustomFieldText        CustomFieldKind = "TEXT"
	CustomFieldNumber      CustomFieldKind = "NUMBER"
	CustomFieldDate        CustomFieldKind = "DATE"
	CustomFieldSelect      CustomFieldKind = "SELECT"
	CustomFieldMultiSelect CustomFieldKind = "MULTI_SELECT"
	CustomFieldBool        CustomFieldKind = "BOOL"
	CustomFieldUser        CustomFieldKind = "USER"
	CustomFieldURL         CustomFieldKind = "URL"
)

func CustomFieldKinds

func CustomFieldKinds() []CustomFieldKind

CustomFieldKinds returns every kind, in the order the contract's enum lists them.

func ParseCustomFieldKind

func ParseCustomFieldKind(value string) (CustomFieldKind, error)

ParseCustomFieldKind reads a submitted kind.

func (CustomFieldKind) TakesOptions

func (k CustomFieldKind) TakesOptions() bool

TakesOptions reports whether the kind's values are drawn from a list.

type DeletedBy

type DeletedBy struct {
	Kind shared.ActorKind
	ID   shared.ID
}

DeletedBy is who put something in the trash.

The pair `activity.Actor` carries, spelled out here rather than imported: an aggregate under `work` has no business depending on the activity aggregate for a value type, and the vocabulary both use - `shared.ActorKind` - is already shared.

The identifier is empty where the kind is not: an automation and the system act without an account, so a kind alone says what acted and an identifier alone could not. A zero value means nobody recorded it, which is the permanent state of everything deleted before the columns existed (migration 0070).

func (DeletedBy) IsKnown

func (d DeletedBy) IsKnown() bool

IsKnown reports whether anything was recorded. A row trashed before 0070 answers false, and a reader shows the deletion without a name rather than a name it made up.

type DroppedReference

type DroppedReference struct {
	// ItemID is the entry that lost it. A move carries a whole subtree, so one operation can drop
	// references from several entries at once.
	ItemID shared.ID
	Kind   ReferenceKind
	// ID is the reference that could not be carried over, as the contract spells it: an identifier
	// for a label, a column, an account, and the key itself for a custom field - which has no
	// identifier in a collection that does not define it. Text rather than an identifier for
	// exactly that reason, and the contract types it as a plain string (api/openapi.yaml, schema
	// DroppedReference).
	ID string
	// Code is the stable message code saying why, which is what a client renders. It is the
	// domain's rather than the adapter's, so that the same loss reads the same way through every
	// channel.
	Code string
}

DroppedReference is one thing a move took away from an entry, and why (invariant I-W6).

Reported rather than silently dropped. Moving a task to another collection takes it away from the vocabulary it was tagged from and the board it sat on; a client that saw the move succeed and the chips disappear would have no way to tell that from a rendering fault - and a person who had spent an afternoon labelling would have no way to know what to redo.

func DroppedAssignee

func DroppedAssignee(itemID, accountID shared.ID) DroppedReference

func DroppedBucket

func DroppedBucket(itemID, bucketID shared.ID) DroppedReference

DroppedBucket is a column of the board the entry left.

func DroppedCapability

func DroppedCapability(itemID shared.ID, kind ReferenceKind, id string) DroppedReference

DroppedCapability is a reference the copy's own type may no longer carry: the profile in force has been narrowed since the entry was written, and a copy takes only what the profile allows (domain-model.md §2).

func DroppedCustomField

func DroppedCustomField(itemID shared.ID, key, code string) DroppedReference

DroppedCustomField is a value whose definition the destination collection does not define, or whose definition there will not accept it - a key that is text in one collection and a number in the other is not the same field.

func DroppedLabel

func DroppedLabel(itemID, labelID shared.ID) DroppedReference

DroppedLabel is a label the destination collection does not define.

func DroppedMember

func DroppedMember(itemID, accountID shared.ID) DroppedReference

DroppedMember and DroppedAssignee are accounts the destination collection does not reach: an entry may only be handed to somebody who can see it, and a copy into another collection is the moment that can stop being true (domain-model.md §3.4, I-W6).

type DueDate

type DueDate struct {
	At time.Time
	// DateOnly marks an all-day due date: At is read as a date in TimeZone, never as an instant.
	DateOnly bool
	// TimeZone is the IANA name the date is local to, and empty for a due date that is a plain
	// instant. Stored separately because local time matters to the business logic here - a
	// reminder offset and a recurrence both count from it (i18n-l10n.md §4).
	TimeZone string
}

DueDate is when an entry is due: the instant, the all-day flag, and the IANA zone the date is local to (domain-model.md §3.4, i18n-l10n.md §4).

One value rather than three fields, like Completion and for the same reason: the three are only ever meaningful together. The instant is stored in UTC and the zone beside it, never folded in - an all-day due date is a date in that zone, not a midnight that shifts with the viewer, and the flag is what tells every reader to render a date rather than a moment.

func NewDueDate

func NewDueDate(at *time.Time, dateOnly bool, zone string) (*DueDate, error)

NewDueDate builds the trio or explains why it cannot be one.

Two absences are refused here: a zone without a date and a flag without a date each qualify something that is not there, and storing either would be a row whose meaning depends on a field it does not have. The nil answer with no error is a due date deliberately absent - which is what lets a merge patch express "clear it" through the same door that sets it.

func (*DueDate) Anchor

func (d *DueDate) Anchor() (time.Time, error)

Anchor is the instant everything counting from a due date counts from (reminders, and the recurrence that follows it).

For a due date that is an instant, that is the instant. For an all-day due date it is the start of that date in its own zone, which is the only reading that survives a time change: "the day it is due" is a date in a place, and midnight UTC is the wrong moment in every place but one. A date without a zone is read in UTC - the same fallback NewDueDate leaves open by allowing the zone to be empty.

The zone is loaded rather than trusted: it was validated when it was written, so a name that no longer loads is this installation's tzdata disagreeing with its own rows, which is an internal fault and not something a caller can fix.

func (*DueDate) Equal

func (d *DueDate) Equal(other *DueDate) bool

Equal reports whether two due dates say the same thing. Instants compare as instants, so the same moment written from two zones is one due date.

type DuePatch

type DuePatch struct {
	// At is the instant the patch names, and nil for `due_at: null` - but only when AtPresent
	// says the member was sent at all.
	At        *time.Time
	AtPresent bool
	DateOnly  *bool
	TimeZone  *string
}

DuePatch is a merge patch's touch on the trio: which members were sent, and what each said. Nil pointers mean "not touched", exactly as ItemAttributes spells it - the target trio is only decidable against the stored one, so the patch travels raw and is applied where that is known.

func (DuePatch) Applied

func (p DuePatch) Applied(current *DueDate) (*DueDate, error)

Applied merges the patch onto the stored trio and answers the target, validated.

`due_at: null` clears the three together (api/openapi.yaml, WorkItemUpdate): the qualifiers fall with their date rather than surviving to contradict it, and only a qualifier the same patch re-asserts reaches NewDueDate to be refused. Every other combination starts from what is stored and overrides what was sent - which is how a device that moves the zone alone leaves the date alone, the case the per-field merge rule exists for (offline-sync.md §4.2).

func (DuePatch) IsEmpty

func (p DuePatch) IsEmpty() bool

IsEmpty reports whether the patch touches the trio at all.

type EmergencyAccess

type EmergencyAccess struct {
	ID       shared.ID
	TenantID shared.ID
	HubID    shared.ID
	// OpenedBy is zero once the opener's account is gone; the members' record stays.
	OpenedBy shared.ID
	// Reason is written to the hub's members. User content: never in a log, a metric or the trail
	// in clear (rule 10).
	Reason   string
	OpenedAt time.Time
	EndsAt   time.Time
}

EmergencyAccess is one opening of a private hub by the workspace's owner: the hub, who, why, and the hour. It grants nothing by itself; the authoriser reads a running one for its opener alone (identity.md §22).

func NewEmergencyAccess

func NewEmergencyAccess(in NewEmergencyAccessInput) (EmergencyAccess, error)

NewEmergencyAccess opens an access for one hour from now, with the reason trimmed and bounded.

func (EmergencyAccess) RunningAt

func (a EmergencyAccess) RunningAt(now time.Time) bool

RunningAt reports whether the access is still open at the moment.

type FieldChange

type FieldChange struct {
	Field    string
	From, To string
}

FieldChange is one field that moved, with the value on each side.

Reported out of the domain rather than recomputed by each recipient. Three of them describe the same change - the event's change set, one change log entry per field for the offline merge, and the audit entry - and three derivations would be three chances to disagree about what changed.

type ItemAttributes

type ItemAttributes struct {
	Title *string
	Notes *string
	// BucketID is the column the item moves to, and a pointer to the zero identifier takes it off
	// the board altogether - which is the same "empty is not set" the text fields keep.
	BucketID *shared.ID
	// ContentLanguage re-indexes the entry under another language, and a pointer to the empty
	// string clears the statement altogether.
	ContentLanguage *string
	// StartAt moves the start, and a pointer to the zero time clears it - the same "empty is not
	// set" the board keeps, because the zero time is not an instant anything can begin at.
	StartAt *time.Time
}

ItemAttributes is what an update may change.

A nil pointer means "leave it alone", which is what an absent member of a JSON Merge Patch says. The distinction is the whole of the type's job: `notes: null` clears the notes and an omitted `notes` keeps them, and a struct of plain strings could not tell a caller that meant one from a caller that meant the other - it would clear the notes of every client that only wanted to rename something.

Icon and colour are deliberately absent: a WorkItem has neither - domain-model.md §3.4 gives the item no such field and §3.3 puts both on Container. Adding them here would be a model change rather than an implementation of one.

func (ItemAttributes) IsEmpty

func (a ItemAttributes) IsEmpty() bool

IsEmpty reports whether the update asks for nothing at all.

type ItemType

type ItemType string

ItemType is the level of an item. Extensible by design: a new type is a new profile row and an adjustment to the permitted children, not a schema change and not an API change (domain-model.md §1).

const (
	ItemTask        ItemType = "TASK"
	ItemWorkPackage ItemType = "WORK_PACKAGE"
	ItemActivity    ItemType = "ACTIVITY"
)

func ItemTypes

func ItemTypes() []ItemType

ItemTypes returns every defined type. An adapter uses it to prove it handles all of them.

func (ItemType) Valid

func (t ItemType) Valid() bool

Valid reports whether the type is one of the defined ones.

Deliberately a closed set even though the profiles are data: the profile decides what a type may *do*, the enum decides which types the schema knows. A profile row naming a type the column cannot store would fail at the insert, and this says so earlier.

type Label

type Label struct {
	ID           shared.ID
	TenantID     shared.ID
	CollectionID shared.ID
	Name         string
	// ColorToken is a theme token rather than a colour value, so that clients render it in their
	// own palette (domain-model.md §3.5).
	ColorToken string
	// Description is optional; the empty string is "not set" rather than a value.
	Description string
	// DeletedAt is the soft delete. The row stays so that a change log entry can still name it and
	// the items that carry the label keep a reference that resolves (offline-sync.md §7).
	DeletedAt *time.Time
	Version   int
}

Label is a tag a collection defines and its items carry (domain-model.md §3.5).

Defined on the collection rather than on the tenant, deliberately: a label is a vocabulary the people working in one collection agree on, and a tenant-wide list would make every collection pay for every other's vocabulary. What that costs is that a label does not survive a move to another collection - which is invariant I-W6, reported back rather than silently dropped.

The colour is required, unlike a bucket's. A label is rendered as a chip and nothing else: with no colour there is nothing to render it as, and a client would have to invent one - which is how two clients come to render the same label differently.

func NewLabel

func NewLabel(in NewLabelInput) (Label, error)

NewLabel builds a label and checks its invariants.

Uniqueness within the collection is the unique index's decision rather than this type's, for the reason given at NewBucket: a check followed by an insert is two statements with a gap between them, and two requests arriving in that gap both pass the check.

func (Label) Deleted

func (l Label) Deleted(at time.Time) (Label, []FieldChange, error)

Deleted takes the label out of the collection's vocabulary.

A soft delete, and idempotent. What happens to the items carrying it is the use case's decision: this type sees one label and knows nothing about who wears it.

func (Label) EnsureEditable

func (l Label) EnsureEditable() error

EnsureEditable refuses a label that is no longer in the collection's vocabulary. A conflict rather than a validation failure, for the reason Bucket.EnsureEditable gives.

func (Label) IsDeleted

func (l Label) IsDeleted() bool

IsDeleted reports whether the label has been removed from its collection's vocabulary.

func (Label) Updated

func (l Label) Updated(attributes LabelAttributes, form text.Normalizer) (Label, []FieldChange, error)

Updated applies a change and reports which fields moved. Nothing that did not move is in the result, and a request that changes nothing returns the label untouched.

type LabelAttributes

type LabelAttributes struct {
	Name        *string
	ColorToken  *string
	Description *string
}

LabelAttributes is what an update may change. A pointer per field, so that "set it to nothing" and "do not touch it" stay two different requests.

The colour has no "set it to nothing": a label without one cannot be rendered, so an empty colour is refused rather than stored.

func (LabelAttributes) IsEmpty

func (a LabelAttributes) IsEmpty() bool

IsEmpty reports whether the update asks for nothing at all.

type NewBucketInput

type NewBucketInput struct {
	ID           shared.ID
	TenantID     shared.ID
	CollectionID shared.ID
	Name         string
	OrderKey     string

	WipLimit     *int
	IsDoneBucket bool
	ColorToken   string

	// Text brings the name to normal form C before it is bounded and stored (i18n-l10n.md §5);
	// NewWorkItemInput says why it is handed in.
	Text text.Normalizer
}

NewBucketInput is what a bucket is made of. The rank is computed by the ordering service rather than passed by a client, and arrives here already decided.

type NewCommentInput

type NewCommentInput struct {
	ID       shared.ID
	TenantID shared.ID
	ItemID   shared.ID
	AuthorID shared.ID
	// Parent is the comment being replied to, already read by the caller - nil for a top-level
	// comment. The whole comment rather than its identifier, because the rules are about what it
	// is: on the same entry, not deleted, and not itself a reply.
	Parent *Comment
	Body   string
	Now    time.Time
	// System marks a comment the server files on the author's behalf, with the code and the
	// parameters of its heading. Empty is what somebody wrote.
	SystemCode   string
	SystemParams map[string]string

	// Text brings the body to normal form C before it is bounded and stored (i18n-l10n.md §5);
	// NewWorkItemInput says why it is handed in.
	Text text.Normalizer
}

NewCommentInput is what a creation needs decided.

type NewContainerInput

type NewContainerInput struct {
	ID       shared.ID
	TenantID shared.ID
	Type     ContainerType
	ParentID shared.ID
	Name     string

	Description string
	Icon        string
	ColorToken  string

	OrderKey  string
	CreatedBy shared.ID
	Now       time.Time

	// Private makes the new hub private (ADR-0073 §1); refused on a collection.
	Private bool

	// Text brings the name and the description to normal form C before they are bounded and stored
	// (i18n-l10n.md §5); NewWorkItemInput says why it is handed in.
	Text text.Normalizer
}

NewContainerInput is what a container is made of. A struct rather than eleven parameters, because eleven parameters of which four are identifiers is an invitation to swap two of them.

type NewCustomFieldInput

type NewCustomFieldInput struct {
	ID           shared.ID
	TenantID     shared.ID
	CollectionID shared.ID
	Key          string
	Kind         CustomFieldKind
	Options      []string
	IsRequired   bool
	AppliesTo    []ItemType
	Now          time.Time

	// Text brings the options to normal form C before they are bounded and stored (i18n-l10n.md
	// §5): an option is a value a person typed once and every entry then picks from, so a choice
	// has to meet it in one form. NewWorkItemInput says why it is handed in.
	Text text.Normalizer
}

NewCustomFieldInput is what a definition is made of.

type NewEmergencyAccessInput

type NewEmergencyAccessInput struct {
	ID       shared.ID
	TenantID shared.ID
	HubID    shared.ID
	OpenedBy shared.ID
	Reason   string
	At       time.Time
}

NewEmergencyAccessInput is what opening a hub needs decided.

type NewLabelInput

type NewLabelInput struct {
	ID           shared.ID
	TenantID     shared.ID
	CollectionID shared.ID
	Name         string
	ColorToken   string
	Description  string

	// Text brings the name and the description to normal form C before they are bounded and stored
	// (i18n-l10n.md §5); NewWorkItemInput says why it is handed in.
	Text text.Normalizer
}

NewLabelInput is what a label is made of.

type NewRecurrenceRuleInput

type NewRecurrenceRuleInput struct {
	ID       shared.ID
	TenantID shared.ID
	ItemID   shared.ID
	Spec     RecurrenceSpec
	// Due is the entry's due date, already read. A series counts from it, so an entry without one
	// cannot carry a rule.
	Due *DueDate
	Now time.Time
}

NewRecurrenceRuleInput is what a series needs decided.

type NewReminderInput

type NewReminderInput struct {
	ID       shared.ID
	TenantID shared.ID
	ItemID   shared.ID
	// OffsetSpec is the text as the caller wrote it.
	OffsetSpec string
	// Channels as the caller named them, and empty for the default: an omitted channel list means
	// email, which is what the column's own default says (0001_init).
	Channels   []string
	Recipients []shared.ID
	// Due is the entry's due date, already read - nil for an entry that has none.
	Due *DueDate
	Now time.Time
}

NewReminderInput is what a creation needs decided.

type NewTemplateInput

type NewTemplateInput struct {
	ID       shared.ID
	TenantID shared.ID
	Spec     TemplateSpec
	Now      time.Time

	// Text brings the name, the description and every node's title and notes to normal form C
	// before they are bounded and stored (i18n-l10n.md §5); NewWorkItemInput says why it is handed
	// in.
	Text text.Normalizer
}

NewTemplateInput is what a definition needs decided.

type NewWorkItemInput

type NewWorkItemInput struct {
	ID           shared.ID
	TenantID     shared.ID
	CollectionID shared.ID
	Type         ItemType
	ParentID     shared.ID

	Title string
	Notes string
	// BucketID is the column the item starts in. Checked against the profile like every other
	// optional field, so that a client cannot put a work package on a board.
	BucketID shared.ID

	// ContentLanguage is the language the title and the notes are written in. The use case
	// defaults it to the creator's locale, which is what i18n-l10n.md §5 says an unstated language
	// falls back to - so an empty value here is a deliberate "not stated" rather than a default
	// waiting to be applied.
	ContentLanguage string

	// StartAt is when the work begins, nil for no start. The due date is deliberately not beside
	// it: it arrives through the writer that owns the trio, in the same transaction, the way an
	// assignee does.
	StartAt *time.Time

	// CalendarUID is the UID a calendar client chose for the entry it is making, and empty for
	// every other creation. Bounded and checked here, because it becomes a path segment of the
	// tree: what a client may write into a UID is anything, and what an address can carry is not.
	CalendarUID string

	// Text brings the title and the notes to normal form C before they are bounded and stored
	// (i18n-l10n.md §5). Handed in like the profile is, because the domain may not import the
	// library that knows the form (rule 1, ADR-0056); without it, a title that is not ASCII is
	// refused rather than stored in whatever form it arrived (shared.NFC).
	Text text.Normalizer

	// Profile is the capability profile in force for this type, which is data rather than code
	// (ADR-0006) and therefore has to be handed in. It decides which of the optional fields above
	// this item may carry at all.
	Profile CapabilityProfile

	Path      string
	Depth     int
	OrderKey  string
	CreatedBy shared.ID
	Now       time.Time
}

NewWorkItemInput is what an item is made of. Placement - the path and the depth - is computed by the hierarchy service rather than passed by a client, and arrives here already decided.

type RecurrenceMode

type RecurrenceMode string

RecurrenceMode is how the next occurrence arrives (arc42 §6.3).

const (
	// RecurrenceOnSchedule puts the next occurrence in place at its time, whether or not the last
	// one was done. A rent payment does not wait for the previous one to be ticked off.
	RecurrenceOnSchedule RecurrenceMode = "ON_SCHEDULE"
	// RecurrenceOnCompletion creates the next occurrence only once its predecessor is completed:
	// "again, two weeks after I last did it" rather than "again on the first of every month".
	RecurrenceOnCompletion RecurrenceMode = "ON_COMPLETION"
)

func RecurrenceModes

func RecurrenceModes() []RecurrenceMode

RecurrenceModes is the closed set, in the order the schema's check constraint lists them.

func (RecurrenceMode) String

func (m RecurrenceMode) String() string

func (RecurrenceMode) Valid

func (m RecurrenceMode) Valid() bool

Valid reports whether a mode is one this system knows.

type RecurrenceRule

type RecurrenceRule struct {
	ID       shared.ID
	TenantID shared.ID
	// ItemID is the entry the series sits on: the source the occurrences are copied from.
	ItemID shared.ID
	// RRULE is the rule text as it was given, without a DTSTART and without an end.
	RRULE string
	// TimeZone is the IANA name the rule is read in, required. DST is resolved through it and
	// never through an offset (i18n-l10n.md §4).
	TimeZone string
	Mode     RecurrenceMode
	// HorizonDays is how far ahead occurrences are materialised. A rolling window rather than the
	// whole series: a rule with no end would otherwise be an infinite list of entries.
	HorizonDays int
	// EndsAt and MaxCount are the two spellings of the end, and at most one of them is set.
	EndsAt   *time.Time
	MaxCount int
	// LastMaterializedAt is how far the materialisation has come. The server's bookkeeping: nothing
	// a client sends ever reaches it.
	LastMaterializedAt *time.Time
	CreatedAt          time.Time
	UpdatedAt          *time.Time
	Version            int
}

RecurrenceRule is a series: what repeats, in which zone it is read, how the next occurrence arrives, and how far ahead the occurrences are kept (domain-model.md §3.5, arc42 §6.3).

Its own entity beside the entry rather than columns on it, because it is one thing an entry either carries or does not, and because the occurrences it produces point back at it. The entry it belongs to is the one the occurrences are copied from - the series' own template.

What is not decided here is what the rule *means*: expanding it is a library's job behind a port (ADR-0008, core/port/recurrence), because "RRULE plus time zones plus DST is error-prone" is a risk this project answers with a proven implementation rather than with its own (R-07). What is decided here is everything that can be decided without one.

func NewRecurrenceRule

func NewRecurrenceRule(input NewRecurrenceRuleInput) (RecurrenceRule, error)

NewRecurrenceRule validates and builds a series, or says why it is not one.

Everything here is decidable without expanding anything. Whether the text is a rule at all, and whether it is a *dense* one, needs the library and is asked by the application through the port - which is also the only order that works: a text that is not a rule cannot be measured.

func (RecurrenceRule) Changed

func (r RecurrenceRule) Changed(
	spec RecurrenceSpec, due *DueDate, at time.Time,
) (RecurrenceRule, []FieldChange, error)

Changed applies a new document to an existing series and reports which fields moved.

A PUT rather than a patch, because a series is one statement: "every Monday in Berlin, on completion, ninety days ahead" is not five independent settings, and a caller sending half of it means the half it sent. The changes are still reported per field, because that is what the merge needs (offline-sync.md §4.2).

Idempotent like every other writer here: a document that says what is already stored reports no changes, so the caller writes nothing, spends no version and announces nothing.

func (RecurrenceRule) Horizon

func (r RecurrenceRule) Horizon(from time.Time) time.Time

Horizon is how far ahead this rule owes occurrences, counted from a moment.

type RecurrenceSpec

type RecurrenceSpec struct {
	RRULE    string
	TimeZone string
	Mode     string
	// HorizonDays is zero for "the default", which is what an omitted member means.
	HorizonDays int
	EndsAt      *time.Time
	MaxCount    int
}

RecurrenceSpec is what a caller says about a series - the whole document, because the route is a PUT and a series is one thing.

type ReferenceKind

type ReferenceKind string

ReferenceKind is what an entry lost when it moved. The values are the contract's (api/openapi.yaml, schema DroppedReference).

const (
	// ReferenceLabel is a label of the collection the entry left. A label is a vocabulary one
	// collection agreed on, so it does not travel.
	ReferenceLabel ReferenceKind = "LABEL"
	// ReferenceBucket is a column of the board the entry left.
	ReferenceBucket ReferenceKind = "BUCKET"
	// ReferenceMember is an account the destination does not reach, and ReferenceCustomField a
	// value whose definition it does not define. Only a copy reports either, because a copy writes
	// a whole new entry into the other collection (domain-model.md §3.4, I-W6).
	ReferenceMember      ReferenceKind = "MEMBER"
	ReferenceCustomField ReferenceKind = "CUSTOM_FIELD"
	// ReferenceAssignee is the one person an entry was on. Its own kind rather than a member,
	// because the two are different fields with different rules - an activity carries an assignee
	// and no member list at all (domain-model.md §2) - and a client that put the assignee back
	// would call a different operation from the one that puts a member back.
	ReferenceAssignee ReferenceKind = "ASSIGNEE"
	// ReferenceAttachment is a file an entry pointed at and the copy may not: only the capability
	// takes one away, since a media object belongs to the tenant rather than to a collection and
	// therefore resolves wherever an entry lands.
	ReferenceAttachment ReferenceKind = "ATTACHMENT"
)

type Reminder

type Reminder struct {
	ID       shared.ID
	TenantID shared.ID
	ItemID   shared.ID
	// Offset is when to fire, expressed against the entry's due date or against the clock.
	Offset ReminderOffset
	// Channels is what carries it. A list because the preference is per channel and somebody may
	// want both; never empty, because a reminder nothing carries is a row that reminds nobody.
	Channels []ReminderChannel
	// Recipients is who is reminded, and empty means the assignee and the entry's members.
	//
	// Empty is stored as empty and resolved when the reminder fires, never expanded here: a list
	// written down today would remind exactly the people who were on the entry today, and somebody
	// added tomorrow would never hear about it.
	Recipients []shared.ID
	State      ReminderState
	// FireAt is the computed moment, and nil when there is none: a relative reminder whose entry
	// has no due date has nothing to count from. Derived rather than given - a client sends the
	// offset, the server owns the instant - which is why it never merges (offline-sync.md §4.2).
	FireAt    *time.Time
	CreatedAt time.Time
	UpdatedAt *time.Time
	Version   int
}

Reminder is one promise to say something about an entry at a particular moment (domain-model.md §3.5).

Its own entity rather than a field of the item, because an entry may carry several and each has its own moment, its own channels and its own recipients. What it does not carry is a message: nothing here holds content, and what an email eventually says is read from the entry when it is rendered - which is what keeps the deletion path of a title a single path (data-protection.md §5) and what makes a reminder right about an entry renamed while it waited.

"Predefined plus custom" (§2) is the two forms of the offset and not a preset table: a preset is a client's vocabulary for a common relative offset, and what is stored is the offset itself.

func NewReminder

func NewReminder(input NewReminderInput) (Reminder, error)

NewReminder validates and builds a reminder, with the moment it will fire at computed.

func (Reminder) Patched

func (r Reminder) Patched(
	patch ReminderPatch, due *DueDate, at time.Time,
) (Reminder, []FieldChange, error)

Patched applies the patch and reports which fields moved, each as its own change.

One change per field, for the reason a scalar update takes one per field (offline-sync.md §4.2): a single entry naming the offset and the channels would give the pair one clock, and a merge would then decide them together - which is exactly the loss two devices editing different members must not suffer. `fire_at`, `version` and `updated_at` are not in it: they are derived, and a client that merged them would be merging the server's arithmetic.

Only a pending reminder may be changed. One that has fired is a record of something that happened, and one that was cancelled will not fire whatever it says - so both are refused rather than silently rewritten into a future they no longer have.

func (Reminder) Rescheduled

func (r Reminder) Rescheduled(due *DueDate) (Reminder, bool, error)

Rescheduled recomputes the moment a reminder fires at against a due date that has moved, and reports whether it moved with it.

Only relative reminders and only pending ones: an absolute reminder is a moment somebody named and no deadline moves it, and a reminder that has already fired cannot be given a new future. A due date that is gone leaves the reminder with no moment at all rather than removing it (see FireAt).

type ReminderChannel

type ReminderChannel string

ReminderChannel is what carries a reminder.

The same set the notification context sends on, spelled where a reminder validates it: the domain models are independent of one another (ADR-0001), so the two lists cannot be one constant - and a test in the application layer, which may see both, holds them to each other.

const ReminderChannelEmail ReminderChannel = "EMAIL"

ReminderChannelEmail is the one channel this installation sends on. Webhook and push are named in arc42 §5.2 and arrive with the tasks that build them.

func ReminderChannels

func ReminderChannels() []ReminderChannel

ReminderChannels is the closed set.

func (ReminderChannel) String

func (c ReminderChannel) String() string

func (ReminderChannel) Valid

func (c ReminderChannel) Valid() bool

Valid reports whether a channel is one this system knows.

type ReminderOffset

type ReminderOffset struct {
	// Spec is the stored text: `REL:-PT1H` or `ABS:2026-09-01T08:00:00Z`.
	Spec string
	// Relative marks the first form. A relative offset counts from the entry's due date and is
	// negative for before it; an absolute one is a moment no due date moves.
	Relative bool
	Duration time.Duration
	Instant  time.Time
}

ReminderOffset is when a reminder fires, in one of exactly two forms.

The text travels with the parsed value deliberately. What is stored is what was given - two spellings of the same hour are two strings, and rewriting one into the other would be the server answering a client with words it did not choose - while the members beside it are what any arithmetic uses, so nothing has to re-parse a string to know what it means.

func ParseReminderOffset

func ParseReminderOffset(spec string) (ReminderOffset, error)

ParseReminderOffset reads an offset spec, or says why it is not one.

The grammar is deliberately narrow: `REL:` and an ISO-8601 duration of weeks, days, hours, minutes and seconds, or `ABS:` and an RFC 3339 instant. Years and months are refused - they are calendar arithmetic rather than a length of time, and "one month before" would mean two different durations in two different months, which is not something an offset can promise.

func (ReminderOffset) FireAt

func (o ReminderOffset) FireAt(due *DueDate) (*time.Time, error)

FireAt answers the moment this offset means for a given due date, or nil when it means none.

Nil is not an error: a relative reminder whose entry has lost its due date has nothing to count from, and the reminder stays - the date may come back, and nobody asked for the reminder to go. Refusing a relative reminder on an entry that never had one is a different question, decided where the reminder is written.

type ReminderPatch

type ReminderPatch struct {
	OffsetSpec *string
	Channels   *[]string
	Recipients *[]shared.ID
}

ReminderPatch is a merge patch's touch on a reminder: nil means "not sent", exactly as ItemAttributes spells it, because only the stored reminder can say what an absent member means.

func (ReminderPatch) IsEmpty

func (p ReminderPatch) IsEmpty() bool

IsEmpty reports whether the patch asks for nothing at all.

type ReminderState

type ReminderState string

ReminderState is how far a reminder got.

The states are the schema's, and only the first is a client's business: a reminder is written PENDING, the scheduler moves it to SENT when it has fired, and CANCELLED is the answer for one that never will. Deleting a reminder removes the row rather than cancelling it - what somebody deleted is gone, and a tombstone with a state would be a reminder they could still see.

const (
	ReminderPending   ReminderState = "PENDING"
	ReminderSent      ReminderState = "SENT"
	ReminderCancelled ReminderState = "CANCELLED"
	// ReminderLapsed is a reminder whose moment passed while the data was in an archive
	// (backup-restore.md §8.4). It is set by a restore and by nothing else.
	//
	// Not CANCELLED, which would be the cheap answer and the wrong one: somebody cancelled that,
	// and an auditor reading a workspace after a restore would find hundreds of cancellations
	// nobody made. Not PENDING either - a restore that left them pending would have the
	// scheduler send every one of them at once, which is exactly what §8.4 forbids.
	ReminderLapsed ReminderState = "LAPSED"
)

func ReminderStates

func ReminderStates() []ReminderState

ReminderStates is the closed set, in the order the schema's check constraint lists them.

func (ReminderState) String

func (s ReminderState) String() string

func (ReminderState) Valid

func (s ReminderState) Valid() bool

Valid reports whether a state is one this system knows.

type RetentionState

type RetentionState struct {
	// Action is what will happen.
	Action string
	// EffectiveAt is when. It is the entry's own date rather than the rule's, because the period
	// runs from the entry's anchor.
	EffectiveAt time.Time
	// PolicyID is the rule that announced it.
	PolicyID shared.ID
	// BlockedBy is why the act is not happening, and empty when nothing is stopping it
	// (data-retention.md §4, §6). An entry that is blocked has no EffectiveAt: it has no due
	// moment, which is what keeps the second phase off it.
	BlockedBy string
}

RetentionState is what an entry in a running retention period carries (data-retention.md §6).

A value on the entry rather than a reference to the rule: a client showing "this goes on the fourteenth" should not have to fetch a rule to say so, and the rule may be changed or removed while the announcement it made still stands.

func (RetentionState) RetentionBlocked

func (r RetentionState) RetentionBlocked() bool

RetentionBlocked reports an announcement something is holding back.

type SetElement

type SetElement struct {
	ElementID shared.ID
	// AddedAt is the clock reading of the last addition, zero if the element has never been added
	// on this replica.
	AddedAt shared.HLC
	// RemovedAt is the clock reading of the last removal, zero if it has never been removed.
	RemovedAt shared.HLC
}

SetElement is one element of a set an item carries, with the tags that decide whether it is in it (offline-sync.md §4.2, §10).

Labels and members are sets, and a set is the one kind of field last writer wins gets wrong. Two devices offline: one adds "urgent", the other removes "blocked". LWW over the whole array makes the later of the two arrays the truth, and one of the two changes disappears - not as a conflict a person could see, but as a label that quietly is not there any more. So each element carries its own pair of tags instead, and the merge decides per element.

Both tags are kept rather than only the winner. A removal that erased the addition would make a concurrent re-add on another device indistinguishable from an element that had never been added, and the two have to merge differently.

func MergeSetElements

func MergeSetElements(mine, theirs []SetElement) []SetElement

MergeSetElements merges two replicas of one set and returns the result, ordered by element.

This is the merge rule offline-sync.md §4.2 states for sets, written once: the union of the elements either side knows about, each element's tags being the later of the two readings. What falls out is "the union minus the removals that are genuinely later than the additions they undo" - so a label added on one device survives a different label being removed on another, which is the failure this rule exists to prevent.

It is commutative and idempotent by construction: the result depends only on the tags, and HLC.Compare orders any two of them the same way on every replica - including the tie, which is broken by the device identifier rather than by whichever side happened to be scanned first.

Nothing calls it from a request path yet. The transport that will - `POST /sync:push`, which merges a device's tags into the server's - arrives with the sync task; this is the rule it applies, defined and tested where it belongs rather than inside the endpoint that will need it.

func (SetElement) IsPresent

func (e SetElement) IsPresent() bool

IsPresent reports whether the element is in the set: added, and not removed since.

An element that was never added is not in the set whatever its removal tag says - a device may well remove something it saw and this replica did not, and the removal is then a fact about an element that is already absent.

type SetName

type SetName string

SetName is which set of an item an element belongs to. The values are the ones the column's CHECK constraint allows (db/schema.sql, set_element).

const (
	SetLabels   SetName = "labels"
	SetMembers  SetName = "members"
	SetWatchers SetName = "watchers"
	// SetAttachments is what an entry carries in files. A set like the others, and for the same
	// reason it needs tags: two devices, one attaching a photo and the other detaching a receipt,
	// must not resolve to whichever array was written later.
	SetAttachments SetName = "attachments"
)

type Template

type Template struct {
	ID       shared.ID
	TenantID shared.ID
	// Scope is where the template is defined and therefore who can use it. ScopeID names the
	// container for a hub or a collection scope, and is empty for a workspace-wide one.
	Scope       TemplateScope
	ScopeID     shared.ID
	Name        string
	Description string
	RootType    ItemType
	// Root is the tree. One node with its children under it, which is what the contract's array
	// of one carries.
	Root      TemplateNode
	CreatedAt time.Time
	UpdatedAt *time.Time
	// DeletedAt marks a soft deletion: the trees it has already stamped out are ordinary entries
	// and outlive it, and a template defined later under the same name is a new one rather than
	// this one coming back.
	DeletedAt *time.Time
	Version   int
}

Template is a tree somebody wrote down once so that it can be stamped out again (domain-model.md §3.5).

One tree rather than several: `root_type` names what it produces and an instantiation answers one `root_item_id`, so a document with two roots would be two templates wearing one name. Somebody who wants three tasks writes three templates, or one task with three work packages - which is also the shape the hierarchy is about.

What it stores is a shape and never a schedule: a node's due date is a duration from the anchor of the instantiation, because a template outlives the week it was written in and an absolute date in it would be wrong by the second time it is used.

func NewTemplate

func NewTemplate(input NewTemplateInput) (Template, error)

NewTemplate validates and builds a template.

What it does not check is whether the tree is *possible*: which type may sit under which is the hierarchy's answer and depends on this installation's profiles, so the application asks it (the same division the recurrence rule makes with its library). Everything that is decidable from the document alone is decided here.

func (Template) Changed

func (t Template) Changed(
	patch TemplatePatch, form text.Normalizer, at time.Time,
) (Template, []FieldChange, error)

Changed applies a patch and reports which fields moved.

The scope and the root type are not patchable, and that is the decision rather than an omission: a template that changed scope would move out from under the people who could use it, and one whose root type changed would produce a different kind of thing under the same name. Both are a new template - which costs nothing, since defining one is a single call.

The normaliser is handed in for the reason NewTemplate takes one (i18n-l10n.md §5).

func (Template) NodeCount

func (t Template) NodeCount() int

NodeCount is how many entries this template would produce.

func (Template) Nodes

func (t Template) Nodes() []TemplateNode

Nodes walks the tree in the order an instantiation writes it: parents before children, so a child always finds the parent it hangs from.

func (Template) Removed

func (t Template) Removed(at time.Time) Template

Removed returns the template as its tombstone. Idempotent: one already deleted comes back untouched, so nothing is written and nothing is announced.

type TemplateNode

type TemplateNode struct {
	Type  ItemType
	Title string
	Notes string
	// DueOffset is how far after the anchor this entry is due, and nil for a node with no due
	// date. A duration rather than a date - "three days in" is what a template can know.
	DueOffset *time.Duration
	// DueDateOnly marks a node whose due date is a day rather than a moment.
	DueDateOnly bool
	// AssigneeID is who this step always belongs to, and empty for a node the target collection
	// decides about.
	//
	// The assignment rule a template carries, and the whole of it: a fixed person. Naming the
	// collection's own policy instead would name what already happens - a create runs it - and
	// would give a template a second way of saying nothing.
	AssigneeID shared.ID
	Children   []TemplateNode
}

TemplateNode is one entry the template will produce.

func (TemplateNode) DueAt

func (n TemplateNode) DueAt(anchor time.Time, zone string) (*DueDate, error)

DueAt resolves a node's relative date against an anchor, or answers nil for a node with none.

The anchor is a moment in the reader's own zone and the offset is added to it, so a template used in Berlin and in São Paulo produces the same *day* in each - which is what "+3 days" means to the person who wrote it (i18n-l10n.md §4).

type TemplatePatch

type TemplatePatch struct {
	Name        *string
	Description *string
	Root        *TemplateNode
}

TemplatePatch is a merge patch's touch on a template: nil means "not sent". The tree travels whole, because a tree is a shape rather than a list of settings and half of one is a different shape.

func (TemplatePatch) IsEmpty

func (p TemplatePatch) IsEmpty() bool

IsEmpty reports whether the patch asks for nothing at all.

type TemplateScope

type TemplateScope string

TemplateScope is where a template is defined.

const (
	TemplateScopeTenant     TemplateScope = "TENANT"
	TemplateScopeHub        TemplateScope = "HUB"
	TemplateScopeCollection TemplateScope = "COLLECTION"
)

func TemplateScopes

func TemplateScopes() []TemplateScope

TemplateScopes is the closed set, in the order the schema's check constraint lists them.

func (TemplateScope) NeedsContainer

func (s TemplateScope) NeedsContainer() bool

NeedsContainer reports whether the scope names a container. A workspace-wide template names none, and the two that do are refused without one.

func (TemplateScope) String

func (s TemplateScope) String() string

func (TemplateScope) Valid

func (s TemplateScope) Valid() bool

Valid reports whether a scope is one this system knows.

type TemplateSpec

type TemplateSpec struct {
	Scope       string
	ScopeID     shared.ID
	Name        string
	Description string
	RootType    string
	Root        TemplateNode
}

TemplateSpec is what a caller says about a template.

type TrashEntry

type TrashEntry struct {
	Kind TrashKind
	ID   shared.ID
	// BatchID names every row this deletion took, the entry's own included. It is what a restore
	// is keyed on, so it travels to the client: restoring is an act on the deletion, not on the row.
	BatchID shared.ID
	// DeletedAt is when it went in, and the anchor the retention period runs from
	// (data-retention.md §3).
	DeletedAt time.Time
	// DeletedBy is who put it there. What the view shows, which is what this projection carries -
	// the audit trail has the same fact behind a permission most members do not hold, and a trash
	// screen that had to ask for AUDIT_READ to name a person would name nobody.
	//
	// Unknown for everything deleted before migration 0070, permanently: those rows were never
	// asked who, and reading it out of the audit trail afterwards would be taking a fact from a
	// source with a different permission.
	DeletedBy DeletedBy
	// Title is the container's name or the item's title. User content, so it goes to the client and
	// never into a log, a metric or an audit entry (rule 10).
	Title string
	// Subtype is the kind within the kind: HUB or COLLECTION for a container, TASK, WORK_PACKAGE or
	// ACTIVITY for an item. A client draws a different icon for each, and a string rather than
	// either enumeration keeps this projection from having to be two types.
	Subtype string
	// HubID is the level the permission question is asked at. A membership held at a hub applies
	// downwards, so an entry that named only its collection could not be shown to somebody whose
	// right sits on the hub above it - and the trash is the one view that spans hubs, so there is no
	// path parameter to read it from. Empty for a hub, which is its own level.
	HubID shared.ID
	// CollectionID and ParentID say where the entry would go back to, which is what lets a client
	// show "in Shopping" beside a deleted entry rather than only its title. Empty where they do not
	// apply - a hub sits in nothing, and an item is under a collection rather than a container.
	CollectionID shared.ID
	ParentID     shared.ID
	// Version is the row's, so that a restore can be sent with the version it was read at.
	Version int
}

TrashEntry is one deletion, as the trash lists it.

One entry per deletion and not per deleted row. A hub with two hundred entries under it went into the trash as one act and comes back as one act (I-C2), so a list of its subtree would be two hundred lines describing one decision - and a person looking for what they deleted would have to find it among the things that merely came along.

It is a projection rather than an aggregate: it carries what the trash view shows and what restoring one needs, and nothing else. Reading the entry back as a Container or a WorkItem is a second read, which is what Restore does - the entry says what is there, and the aggregate is what the rules are applied to.

type TrashKind

type TrashKind string

TrashKind says which of the two aggregates a trash entry describes.

The trash is the one view in this system that mixes them. Everywhere else a hub and a task are answered by different endpoints because a caller knows which it asked for; here a person asks "what did I delete" and the answer is whatever they deleted, in the order they deleted it.

const (
	TrashContainerKind TrashKind = "CONTAINER"
	TrashItemKind      TrashKind = "ITEM"
)

type WorkItem

type WorkItem struct {
	ID       shared.ID
	TenantID shared.ID
	// CollectionID is the collection the item belongs to, denormalised onto every level of the
	// subtree rather than resolved through the parent chain: it is what a board query filters on
	// and what row level security compares, and a join per row would be paid on every read
	// (domain-model.md §3.4).
	CollectionID shared.ID
	Type         ItemType
	// ParentID is the item above, and empty for a task - whose parent is the collection rather
	// than another item (I-W1).
	ParentID shared.ID
	// Path is the materialised path, `/<taskId>/<wpId>/…/`, including the item itself and closed
	// by a separator. Subtree queries are then a prefix comparison against an index rather than a
	// recursive walk, and the trailing separator is what stops one identifier's prefix from
	// matching another's (I-W2).
	Path string
	// Depth is the level below the collection, counting from 1. Derived from Path and stored, so
	// that a depth limit can be checked without parsing a string per row.
	Depth int
	Title string
	// Notes is Markdown, unrendered on the server. Capability NOTES, so an activity has none.
	Notes      string
	Completion Completion
	// StartAt is when the work begins, for the timeline view, and nil for an entry with no start
	// (domain-model.md §3.4). A plain instant with no zone beside it: nothing counts from it the
	// way a reminder counts from a due date, so there is no local time for the business logic to
	// care about - which is the distinction i18n-l10n.md §4 draws.
	//
	// A scalar, so it merges as last writer wins per field (offline-sync.md §4.2).
	StartAt *time.Time
	// Due is when the entry is due, nil for none. Capability DUE_DATE. One value with three
	// columns behind it, because the instant, the all-day flag and the zone are only ever
	// meaningful together - see DueDate.
	//
	// The three merge as scalars, each with its own change log entry: two devices moving the date
	// and the zone independently converge to both (offline-sync.md §4.2).
	Due *DueDate
	// BucketID is the column of the collection's board this item sits in, and empty for one that
	// sits in none. Capability BUCKET, which only a task carries: a board is the collection's, so
	// only the items directly in it have a place on it (domain-model.md §2).
	//
	// A scalar rather than a set, so it merges as last writer wins per field - two devices dragging
	// the same card to two columns is a genuine conflict with one answer, unlike two devices adding
	// two labels.
	BucketID shared.ID
	// OrderKey ranks the item among its siblings: a fractional index, so that two offline devices
	// can insert into the same list without either one's order being discarded (offline-sync.md §4.2).
	OrderKey string
	// AssigneeID is the one person the entry is on, and empty for one nobody is on. Capability
	// ASSIGNMENT, which every type carries - an activity carries this and no member list, which is
	// the one row of the matrix where the two set fields and this scalar part company
	// (domain-model.md §2).
	//
	// A scalar rather than a set, so it merges as last writer wins per field: two devices naming two
	// different people is a genuine conflict with one answer, unlike two devices adding two members.
	AssigneeID shared.ID
	// Cover is how the card presents itself, nil for none. Capability COVER, which by default
	// only a task carries (domain-model.md §2, §3.4).
	//
	// One value with two shapes rather than two fields, and it merges as one scalar: last writer
	// wins per field via the HLC - two devices choosing a colour and an image is a genuine
	// conflict with one answer (offline-sync.md §4.2).
	Cover *Cover
	// CustomFields is what this workspace added to its entries, keyed by the definition's key
	// (domain-model.md §6). A map rather than columns, because the keys are data: a tenant defines
	// them and a migration cannot.
	//
	// It merges per key rather than as one value: two devices setting two different keys converge
	// to both, and merging the whole document as one scalar is precisely the loss the per-field
	// rule exists to prevent (offline-sync.md §4.2). Nil and empty are the same thing - an entry
	// carrying nothing.
	CustomFields map[string]any
	// ContentLanguage is the BCP 47 tag of the language the title and the notes are written in,
	// and empty for an entry whose language nobody stated. What it decides is the text search
	// configuration the entry is indexed under, which is why it is a field of the entry and not of
	// the account that wrote it: a German note in an English workspace is German (i18n-l10n.md §5,
	// ADR-0034).
	//
	// A scalar, so it merges as last writer wins per field: two devices declaring two languages is
	// a genuine conflict with one answer (offline-sync.md §4.2).
	ContentLanguage string

	// CalendarUID is the UID a calendar client knows this entry by, and empty for an entry no
	// calendar client made. A todo made in Reminders or Thunderbird arrives as a PUT to an address
	// the client chose, and the client keys the todo by that UID from then on - so the tree answers
	// the entry at that address and renders that UID back, and the identifier the server minted
	// stays the server's. Not an identifier: the rule about client-minted ones (offline-sync.md §9)
	// is untouched, and every other channel addresses the entry by ID.
	//
	// Set exactly once, at creation, and never edited: a UID that moved would be a todo the
	// client cannot find again. Server-side rather than merged, like the provenance below - it
	// reaches a device inside the item's UPSERT, and a patch naming it is refused.
	CalendarUID string

	// OriginJumbleID is the jumble entry this item came from, and zero for an item that was not
	// converted out of one. Provenance, set exactly once at the conversion and never cleared: where
	// an item came from does not stop being true.
	OriginJumbleID shared.ID

	// RecurrenceRuleID is the series this entry belongs to, and empty for an entry that repeats
	// never. The link is on the entry rather than the rule holding a list, because a series has
	// exactly one template - and it is what tells a reader, without a second query, that this entry
	// has something to do with a series at all.
	//
	// It does not say *which end*: the materialisation writes it onto every occurrence as well as
	// onto the template, which is what RecurrenceSourceID is for.
	//
	// The server decides it: it is set by the writer that stores a rule and cleared by the one
	// that removes it, and a client that sent one would be claiming a series exists.
	RecurrenceRuleID shared.ID

	// RecurrenceSourceID is the entry this one was copied from as an occurrence, and empty on the
	// template and on everything that is not an occurrence at all. With RecurrenceRuleID it is what
	// tells the two ends of a series apart, and it is the only thing on an occurrence that names
	// the entry it repeats from - `GET /items/{id}/recurrence` resolves a rule by its source entry,
	// so an occurrence asking for its own series answers 404.
	//
	// Provenance, like OriginJumbleID: written once by the materialisation, beside the rule
	// identifier, and never cleared. Where an entry came from does not stop being true when the
	// entry it came from is deleted.
	RecurrenceSourceID shared.ID

	// Retention is what a retention rule has announced about this entry, and nil while none has
	// (data-retention.md §6). It is on the entry rather than fetched beside it because §6's whole
	// point is that the object itself says what is coming: "retention nobody can see will
	// eventually surprise somebody".
	Retention *RetentionState

	ArchivedAt *time.Time
	DeletedAt  *time.Time
	// DeletedBy is who put it in the trash, meaningful only while DeletedAt is set. Zero for
	// everything deleted before migration 0070, permanently.
	DeletedBy    DeletedBy
	TrashBatchID shared.ID
	CreatedBy    shared.ID
	CreatedAt    time.Time
	UpdatedAt    time.Time
	Version      int
}

WorkItem is the aggregate root: a task, a work package, or an activity (domain-model.md §3.4).

One type for all three levels is the decision of ADR-0006. What a given item may do is not in this struct but in its CapabilityProfile, which is why a field being present here does not mean every item may carry it - the profile decides, and Require refuses what it does not allow.

What is deliberately absent follows the reasoning that kept `policies` off Container: a field nothing writes is a promise nothing keeps.

The labels and the members are absent too, and for a different reason: they are sets rather than fields. They live in their own tables with their own merge tags, because two devices adding two different labels at once is the case last writer wins over an array gets wrong (offline-sync.md §4.2) - so an item does not carry them, and AddLabel and AddMember are what move them. The assignee is here rather than beside them because it is not a set: exactly one person carries an entry, and two devices naming two different people is a genuine conflict with one answer.

func NewWorkItem

func NewWorkItem(in NewWorkItemInput) (WorkItem, error)

NewWorkItem builds an item and checks its invariants.

The capability gate runs here rather than in the use case, so that every path into an item - this one today, an import or a template instantiation later - passes the same check. A use case that wrote its own version of it would be the second answer to a question that must have one.

func (WorkItem) Archived

func (i WorkItem) Archived(at time.Time) (WorkItem, []FieldChange, error)

Archived stamps the item read-only.

Idempotent: an item already archived comes back untouched, keeping its original timestamp, which is what makes a retry after a lost response harmless rather than a silent re-dating.

func (WorkItem) Assigned

func (i WorkItem) Assigned(accountID shared.ID, at time.Time) WorkItem

Assigned returns the item with the account on it.

Idempotent, for the reason Completed is: an entry already on that person comes back untouched, so that nothing is written, no version is spent and nothing is announced. Which is what makes a repeat harmless rather than merely accepted - and it is the whole of what makes assigning the same person twice from two devices converge.

Whether that account may be assigned at all is not decided here. It is a question about a membership somewhere above the entry, and the domain holds no memberships; the use case asks it before it gets this far (ADR-0005).

func (WorkItem) ChildPath

func (i WorkItem) ChildPath(child shared.ID) string

ChildPath is where an item directly underneath this one would sit. Kept next to Path so that the two cannot drift: whoever builds a path and whoever reads it share this line.

func (WorkItem) Completed

func (i WorkItem) Completed(by shared.ID, at time.Time) WorkItem

Completed returns the item marked done, by whom and when.

Idempotent: an item that is already done comes back untouched, keeping the original timestamp and the original person. That is not politeness towards a client retrying - it is what invariant I-W5 asks of the roll-up, which may reach the same parent twice from two children, and it is what keeps `completed_by` the truth about who finished the work rather than about who last pressed the button.

The caller decides what to do with an unchanged item: nothing is written, no version is spent and no event is announced, which is what makes a repeat harmless rather than merely accepted.

func (WorkItem) Contains

func (i WorkItem) Contains(other WorkItem) bool

Contains reports whether other sits inside this item's subtree, this item itself included.

A prefix test on the materialised path, which is what the trailing separator on every path exists for: a path is `/a/b/` rather than `/a/b`, so `/ab/` cannot be read as being inside `/a/`. Without the separator this test would be quietly wrong for exactly the identifiers that share a prefix (I-W2).

It is the whole of the cycle check. An item may not move under something inside its own subtree, and "inside its own subtree" is a string comparison rather than a walk - which is what makes the check cheap enough to run on every move rather than a thing that gets skipped for performance.

func (WorkItem) Covered

func (i WorkItem) Covered(cover Cover, at time.Time) WorkItem

Covered returns the item wearing the cover. Idempotent for the reason Assigned is: the same cover again comes back untouched, so nothing is written, no version is spent and nothing is announced.

func (WorkItem) EnsureAssignable

func (i WorkItem) EnsureAssignable(profile CapabilityProfile) error

EnsureAssignable refuses what cannot have its assignee changed at all.

The same two answers EnsureCompletable gives, in the same order and for the same reason. A type whose profile does not carry ASSIGNMENT has no assignee for anybody to set, which is the capability matrix and comes back naming the type and the capability; a trashed or archived entry cannot be edited, which is I-W4 and a conflict. Asking the capability first is what stops a client being sent off to unarchive an entry whose assignee would still be refused afterwards.

func (WorkItem) EnsureAttachable

func (i WorkItem) EnsureAttachable(profile CapabilityProfile) error

EnsureAttachable refuses what cannot carry attachments: a type whose profile does not carry ATTACHMENTS - an activity - and a trashed or archived entry.

func (WorkItem) EnsureCommentable

func (i WorkItem) EnsureCommentable(profile CapabilityProfile) error

EnsureCommentable refuses what cannot carry a discussion at all: a type whose profile does not carry COMMENTS - an activity, per the matrix in §2 - and a trashed or archived entry, in that order for the reason EnsureAssignable asks the capability first.

func (WorkItem) EnsureCompletable

func (i WorkItem) EnsureCompletable(profile CapabilityProfile) error

EnsureCompletable refuses what cannot have its completion changed at all.

Two reasons, and they are different kinds of answer. A type whose profile does not carry COMPLETION cannot be completed by anybody - that is the capability matrix, and it comes back as ErrCapabilityNotSupported naming the type and the capability (ADR-0006). A trashed or archived item cannot be *edited*, which is invariant I-W4 and a conflict: the request is well formed and the state is what makes it impossible, which is the distinction that tells a client whether restoring the item first would help (api-guidelines.md §6).

The capability is checked before the lifecycle deliberately. "An activity has no completion" is true of the type whatever state one particular activity is in, and reporting the state first would send a client off to restore an item that still could not be completed afterwards.

func (WorkItem) EnsureCoverable

func (i WorkItem) EnsureCoverable(profile CapabilityProfile) error

EnsureCoverable refuses what cannot carry a cover at all: a type whose profile does not carry COVER, and a trashed or archived entry - the capability first, for the reason EnsureAssignable asks it first.

func (WorkItem) EnsureCustomisable

func (i WorkItem) EnsureCustomisable(profile CapabilityProfile) error

EnsureCustomisable refuses what cannot hold a custom field at all: a type whose profile does not carry CUSTOM_FIELDS - an activity - and a trashed or archived entry. The capability first, for the reason EnsureAssignable asks it first.

func (WorkItem) EnsureEditable

func (i WorkItem) EnsureEditable() error

EnsureEditable refuses an item whose state makes it read-only (I-W4).

A conflict rather than a validation failure: the request is well formed and the state is what makes it impossible, which is the distinction that tells a client whether restoring the item first would help (api-guidelines.md §6).

func (WorkItem) EnsureRecurring

func (i WorkItem) EnsureRecurring(profile CapabilityProfile) error

EnsureRecurring refuses what cannot carry a series: a type whose profile does not carry RECURRENCE - which is everything but a task, because "a series applies to the whole subtree" (domain-model.md §2) - and a trashed or archived entry, in that order for the reason EnsureCommentable asks the capability first.

func (WorkItem) EnsureRemindable

func (i WorkItem) EnsureRemindable(profile CapabilityProfile) error

EnsureRemindable refuses what cannot be reminded about: a type whose profile does not carry REMINDER, and a trashed or archived entry (I-W4) - the capability first, for the reason EnsureCommentable asks it first.

func (WorkItem) InRetention

func (i WorkItem) InRetention() bool

InRetention reports an entry a rule has announced something about.

func (WorkItem) IsArchived

func (i WorkItem) IsArchived() bool

IsArchived reports whether the item is archived: kept, and read-only (I-W4).

func (WorkItem) IsTrashed

func (i WorkItem) IsTrashed() bool

IsTrashed reports whether the item is in the trash - a deletion waiting out its retention period, which is a different thing from being archived.

func (WorkItem) Reopened

func (i WorkItem) Reopened(at time.Time) WorkItem

Reopened returns the item marked open again, and clears who completed it and when.

Cleared rather than kept: the two fields answer "when was this finished, and by whom", and an open item has no answer. Keeping the old values would make `completed_at` a record of the last time it happened to be closed, which is what the activity history is for.

Idempotent for the reason Completed is: reopening propagates upwards, and an item already open comes back untouched so that nothing is written.

func (WorkItem) Restored

func (i WorkItem) Restored(at time.Time) (WorkItem, []FieldChange, error)

Restored takes the item out of the trash.

It clears the batch along with the stamp, so that an item deleted again later belongs to that deletion and not to the one it came back from. The archive stamp is left exactly as it was, for the reason the file header gives.

No lifecycle gate: the state this refuses to act on is the only state it is ever called in.

func (WorkItem) SubtreePathUnder

func (i WorkItem) SubtreePathUnder(parentPath string) string

SubtreePathUnder is where this item's path lands when it moves under a parent whose path is parentPath. The empty parentPath is the top level of a collection.

Kept beside Path and ChildPath so that the three cannot drift: whoever moves a subtree and whoever built its paths in the first place read the same lines.

func (WorkItem) Trashed

func (i WorkItem) Trashed(
	at time.Time, batch shared.ID, by DeletedBy,
) (WorkItem, []FieldChange, error)

Trashed puts the item into the trash as part of one deletion, named by batch.

An item already in the trash comes back untouched - not merely as politeness towards a retry, but because that is the invariant a subtree deletion rests on. Trashing a subtree walks rows that may already be in the trash from an earlier, separate deletion, and restamping those would fold them into the new batch: restoring it would then bring back something nobody deleted this time, and its own retention period would have been quietly restarted. Its deletion stays its own.

Archived is not an obstacle. Deleting something that was taken out of use is the ordinary case, and the archive stamp survives the trip so that Restored can put it back the way it was.

func (WorkItem) Unarchived

func (i WorkItem) Unarchived(at time.Time) (WorkItem, []FieldChange, error)

Unarchived makes the item writable again. Idempotent in the same way.

func (WorkItem) Unassigned

func (i WorkItem) Unassigned(at time.Time) WorkItem

Unassigned returns the item with nobody on it. Idempotent for the reason Assigned is.

func (WorkItem) Uncovered

func (i WorkItem) Uncovered(at time.Time) WorkItem

Uncovered returns the item with no cover. Idempotent for the same reason.

func (WorkItem) Updated

func (i WorkItem) Updated(
	attributes ItemAttributes, profile CapabilityProfile, form text.Normalizer, at time.Time,
) (WorkItem, []FieldChange, error)

Updated applies an update and reports which fields moved.

Nothing that did not move is in the result, and a request that changes nothing returns the item untouched with no changes at all. That is what makes a repeat harmless rather than merely accepted: the caller writes nothing, spends no version and announces nothing, which is the same contract Completed keeps for the same reason.

The capability is asked before the lifecycle, exactly as EnsureCompletable does and for the same reason: "an activity has no notes" is true of the type whatever state one particular activity is in, and answering with the state first would send a client off to unarchive an item whose notes would still be refused afterwards.

The normaliser is handed in for the reason NewWorkItem takes one: the title and the notes are stored in normal form C, and the domain cannot produce that form itself (i18n-l10n.md §5).

func (WorkItem) WithCustomField

func (i WorkItem) WithCustomField(key string, value any, at time.Time) (WorkItem, bool)

WithCustomField returns the item with one custom field written, and reports whether anything moved. A nil value clears the key.

One key at a time, which is the merge rule made unavoidable: a caller that could write the whole document would be writing keys it never read, and the later of two devices would erase the other's. Idempotent for the reason Covered is - the same value again comes back untouched, so nothing is written, no version is spent and nothing is announced.

The map is copied rather than mutated. A caller holding the item it passed in must not see it change under them, and an aggregate that hands out a mutable interior is one whose invariants are advisory.

func (WorkItem) WithDueDate

func (i WorkItem) WithDueDate(
	due *DueDate, profile CapabilityProfile, at time.Time,
) (WorkItem, []FieldChange, error)

WithDueDate returns the item carrying the given due date - or none, for nil - and reports which of the three fields moved, each as its own change (offline-sync.md §4.2: one change log entry per field, so two devices moving the date and the zone independently converge to both).

Only setting asks for the capability, which is the rule Updated applies to the notes and the board: clearing a due date from a type that cannot carry one asks for the state it is already in. Idempotent like Completed and for the same reason: an unchanged item reports no changes, so the caller writes nothing, spends no version and announces nothing.

Jump to

Keyboard shortcuts

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