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
- func ChannelList(channels []ReminderChannel) string
- func CustomFieldPath(key string) string
- func EnsureReminderCapacity(existing int) error
- func ErrEmergencyNotNeeded() error
- func ErrEmergencyNotPrivate() error
- func ErrEmergencyRunning(endsAt time.Time) error
- func ErrPrivateHubsOff() error
- func ErrPrivateOnlyHubs() error
- func ParseTemplateOffset(spec string) (time.Duration, error)
- func PathIDs(path string) []shared.ID
- func PresentElements(elements []SetElement) []shared.ID
- func RecipientList(recipients []shared.ID) string
- func RootPath(id shared.ID) string
- func SpellTemplateOffset(offset time.Duration) string
- func ValidCalendarUID(uid string) bool
- type AutoAssignCandidate
- type AutoAssignDefinition
- type AutoAssignPolicy
- type AutoAssignScope
- type AutoAssignState
- type AutoAssignStrategy
- type Bucket
- func (b Bucket) Deleted(at time.Time) (Bucket, []FieldChange, error)
- func (b Bucket) EnsureEditable() error
- func (b Bucket) IsDeleted() bool
- func (b Bucket) Reordered(orderKey string) (Bucket, []FieldChange, error)
- func (b Bucket) Updated(attributes BucketAttributes, form text.Normalizer) (Bucket, []FieldChange, error)
- type BucketAttributes
- type CandidateKind
- type Capability
- type CapabilityProfile
- type ChildCompletion
- type Comment
- type CommentKind
- type Completion
- type CompletionPolicy
- type Container
- func (c Container) Archived(at time.Time) (Container, []FieldChange, error)
- func (c Container) ArchivedRootID() shared.ID
- func (c Container) EffectiveArchivedAt() *time.Time
- func (c Container) EnsureAcceptsChildren(child ContainerType) error
- func (c Container) EnsureAcceptsItems() error
- func (c Container) EnsureEditable() error
- func (c Container) IsArchived() bool
- func (c Container) IsEffectivelyArchived() bool
- func (c Container) IsTrashed() bool
- func (c Container) MovedInto(parent Container, orderKey string, at time.Time) (Container, []FieldChange, error)
- func (c Container) Ranked(orderKey string, at time.Time) (Container, []FieldChange, error)
- func (c Container) Renamed(attributes ContainerAttributes, form text.Normalizer, at time.Time) (Container, []FieldChange, error)
- func (c Container) Restored(at time.Time) (Container, []FieldChange, error)
- func (c Container) Trashed(at time.Time, batch shared.ID, by DeletedBy) (Container, []FieldChange, error)
- func (c Container) Unarchived(at time.Time) (Container, []FieldChange, error)
- func (c Container) WithPolicies(policies ContainerPolicies, at time.Time) (Container, []FieldChange, error)
- func (c Container) WithPrivacy(private bool, at time.Time) (Container, []FieldChange, error)
- type ContainerAttributes
- type ContainerPolicies
- type ContainerType
- type Cover
- type CoverKind
- type CustomFieldAttributes
- type CustomFieldDefinition
- func (d CustomFieldDefinition) Carries(itemType ItemType) bool
- func (d CustomFieldDefinition) Deleted(at time.Time) (CustomFieldDefinition, []FieldChange, error)
- func (d CustomFieldDefinition) EnsureEditable() error
- func (d CustomFieldDefinition) IsDeleted() bool
- func (d CustomFieldDefinition) IsTenantWide() bool
- func (d CustomFieldDefinition) Updated(attributes CustomFieldAttributes, form text.Normalizer, at time.Time) (CustomFieldDefinition, []FieldChange, error)
- func (d CustomFieldDefinition) ValidateValue(value any, form text.Normalizer) (any, error)
- type CustomFieldKind
- type DeletedBy
- type DroppedReference
- func DroppedAssignee(itemID, accountID shared.ID) DroppedReference
- func DroppedBucket(itemID, bucketID shared.ID) DroppedReference
- func DroppedCapability(itemID shared.ID, kind ReferenceKind, id string) DroppedReference
- func DroppedCustomField(itemID shared.ID, key, code string) DroppedReference
- func DroppedLabel(itemID, labelID shared.ID) DroppedReference
- func DroppedMember(itemID, accountID shared.ID) DroppedReference
- type DueDate
- type DuePatch
- type EmergencyAccess
- type FieldChange
- type ItemAttributes
- type ItemType
- type Label
- type LabelAttributes
- type NewBucketInput
- type NewCommentInput
- type NewContainerInput
- type NewCustomFieldInput
- type NewEmergencyAccessInput
- type NewLabelInput
- type NewRecurrenceRuleInput
- type NewReminderInput
- type NewTemplateInput
- type NewWorkItemInput
- type RecurrenceMode
- type RecurrenceRule
- type RecurrenceSpec
- type ReferenceKind
- type Reminder
- type ReminderChannel
- type ReminderOffset
- type ReminderPatch
- type ReminderState
- type RetentionState
- type SetElement
- type SetName
- type Template
- type TemplateNode
- type TemplatePatch
- type TemplateScope
- type TemplateSpec
- type TrashEntry
- type TrashKind
- type WorkItem
- func (i WorkItem) Archived(at time.Time) (WorkItem, []FieldChange, error)
- func (i WorkItem) Assigned(accountID shared.ID, at time.Time) WorkItem
- func (i WorkItem) ChildPath(child shared.ID) string
- func (i WorkItem) Completed(by shared.ID, at time.Time) WorkItem
- func (i WorkItem) Contains(other WorkItem) bool
- func (i WorkItem) Covered(cover Cover, at time.Time) WorkItem
- func (i WorkItem) EnsureAssignable(profile CapabilityProfile) error
- func (i WorkItem) EnsureAttachable(profile CapabilityProfile) error
- func (i WorkItem) EnsureCommentable(profile CapabilityProfile) error
- func (i WorkItem) EnsureCompletable(profile CapabilityProfile) error
- func (i WorkItem) EnsureCoverable(profile CapabilityProfile) error
- func (i WorkItem) EnsureCustomisable(profile CapabilityProfile) error
- func (i WorkItem) EnsureEditable() error
- func (i WorkItem) EnsureRecurring(profile CapabilityProfile) error
- func (i WorkItem) EnsureRemindable(profile CapabilityProfile) error
- func (i WorkItem) InRetention() bool
- func (i WorkItem) IsArchived() bool
- func (i WorkItem) IsTrashed() bool
- func (i WorkItem) Reopened(at time.Time) WorkItem
- func (i WorkItem) Restored(at time.Time) (WorkItem, []FieldChange, error)
- func (i WorkItem) SubtreePathUnder(parentPath string) string
- func (i WorkItem) Trashed(at time.Time, batch shared.ID, by DeletedBy) (WorkItem, []FieldChange, error)
- func (i WorkItem) Unarchived(at time.Time) (WorkItem, []FieldChange, error)
- func (i WorkItem) Unassigned(at time.Time) WorkItem
- func (i WorkItem) Uncovered(at time.Time) WorkItem
- func (i WorkItem) Updated(attributes ItemAttributes, profile CapabilityProfile, form text.Normalizer, ...) (WorkItem, []FieldChange, error)
- func (i WorkItem) WithCustomField(key string, value any, at time.Time) (WorkItem, bool)
- func (i WorkItem) WithDueDate(due *DueDate, profile CapabilityProfile, at time.Time) (WorkItem, []FieldChange, error)
Constants ¶
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.
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.
const ( FieldOptions = "options" FieldIsRequired = "is_required" FieldAppliesTo = "applies_to" )
The field names an update reports its changes under.
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.
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).
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 )
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).
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.
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.
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 )
const ( FieldTemplateName = "name" FieldTemplateDescription = "description" FieldTemplateNodes = "nodes" )
The template's own field names, as the contract spells them.
const DefaultCompletionPolicy = CompletionManual
DefaultCompletionPolicy is what a collection that has never been configured behaves as.
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).
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.
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.
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.
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.
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).
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.
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).
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.
const MaxReminderOffsetSpecLength = 64
MaxReminderOffsetSpecLength is the contract's maxLength in code points, so that a refusal comes from the same number the specification declares.
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).
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).
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).
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
RootPath is the path of an item directly under the collection: the top of a subtree.
func SpellTemplateOffset ¶
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 ¶
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 ¶
func (d AutoAssignDefinition) Equal(other AutoAssignDefinition) bool
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.
type CustomFieldAttributes ¶
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 ¶
func (d CustomFieldDefinition) Deleted(at time.Time) (CustomFieldDefinition, []FieldChange, error)
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 ¶
func (d CustomFieldDefinition) Updated( attributes CustomFieldAttributes, form text.Normalizer, at time.Time, ) (CustomFieldDefinition, []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 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 ¶
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).
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 ¶
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 ¶
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.
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 ¶
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).
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.
type FieldChange ¶
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).
func ItemTypes ¶
func ItemTypes() []ItemType
ItemTypes returns every defined type. An adapter uses it to prove it handles all of them.
func (ItemType) Valid ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.
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 ¶
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 ¶
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) 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.
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 ¶
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.
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
InRetention reports an entry a rule has announced something about.
func (WorkItem) IsArchived ¶
IsArchived reports whether the item is archived: kept, and read-only (I-W4).
func (WorkItem) IsTrashed ¶
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 ¶
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 ¶
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 ¶
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 ¶
Unarchived makes the item writable again. Idempotent in the same way.
func (WorkItem) Unassigned ¶
Unassigned returns the item with nobody on it. Idempotent for the reason Assigned is.
func (WorkItem) Uncovered ¶
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 ¶
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.