Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions internal/models/item.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,19 @@ type Item struct {
// omitted for a restricted caller (nil).
IsUnparented *bool `json:"is_unparented,omitempty"`

// MovedTo names the destination(s) an ARCHIVED item was moved to by a
// cross-workspace move (PLAN-2357 / TASK-2359). Populated on the single-item
// GET response only, and only for destinations the caller has independently
// been authorized to READ — see (*server.Server).movedToDestinations.
//
// `omitempty` is load-bearing, not cosmetic. A caller who may not read the
// destination must receive a response byte-identical to one for an archived
// item that was never moved: no key, no null, no empty array. A
// structurally distinguishable response is itself the disclosure the ACL
// gate exists to prevent. Never populate this from a list, search, activity
// or share-link path, and never emit a placeholder when the gate denies.
MovedTo []ItemMovedTo `json:"moved_to,omitempty"`

DerivedClosure *ItemDerivedClosure `json:"derived_closure,omitempty"`
CodeContext *ItemCodeContext `json:"code_context,omitempty"`
Convention *ItemConventionMetadata `json:"convention,omitempty"`
Expand All @@ -96,6 +109,40 @@ func (item *Item) ComputeRef() {
}
}

// ItemMovedTo is one destination an archived item was moved to, rendered in
// DISPLAYABLE terms. It deliberately carries no UUIDs: the consumer must be
// able to render (and link to) the destination without a second call, and
// exposing internal IDs of a resource in another workspace buys nothing the
// slug/ref pair does not already give.
//
// Every field describes a resource the caller has already been authorized to
// read, and authorization is all-or-nothing per entry: an entry is never
// partially REDACTED, it is dropped. (Individual fields may still be empty for
// ordinary reasons — a workspace with no resolvable owner username, an item
// whose collection has no prefix — which is what the omitempty tags are for.
// Absence here never means "withheld".)
type ItemMovedTo struct {
// WorkspaceSlug is the destination workspace's CANONICAL slug — the same
// value the token consent allow-list was tested against.
WorkspaceSlug string `json:"workspace_slug"`
WorkspaceName string `json:"workspace_name,omitempty"`
// WorkspaceOwnerUsername completes the /{username}/{workspace}/... web
// route. Empty when the join did not resolve one; the consumer degrades to
// a non-linked label rather than guessing.
WorkspaceOwnerUsername string `json:"workspace_owner_username,omitempty"`

CollectionSlug string `json:"collection_slug,omitempty"`
// Ref is the destination item's issue ID ("TASK-5"). Empty only for the
// rare item whose collection has no prefix or number.
Ref string `json:"ref,omitempty"`
ItemSlug string `json:"item_slug"`
Title string `json:"title"`

// MovedAt is when the move was recorded (RFC3339 UTC), matching the
// provenance row's created_at.
MovedAt string `json:"moved_at,omitempty"`
}

type ItemRelationRef struct {
ID string `json:"id"`
Slug string `json:"slug,omitempty"`
Expand Down
43 changes: 43 additions & 0 deletions internal/models/item_workspace_move.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package models

// ItemWorkspaceMove is one durable record of an item being copied — or moved,
// which is a copy plus an archive of the source — from one workspace to
// another. Written in the SAME transaction as the copy, so the provenance can
// never disagree with the data (PLAN-2357 DR-2).
//
// It backs exactly two lookups:
//
// - forward, by SourceItemID: "where did this item go?" One source can be
// copied into several workspaces, so the forward lookup is a SET.
// - back, by TargetItemID: "where did this item come from?" A destination
// item is created by exactly one copy, so this is at most one row.
//
// ArchivedSource distinguishes a move from a plain copy, and only a move
// produces a "moved to" pointer on the archived source (DR-2a). A source
// copied three times and then moved has four rows, and only the archived one
// says where it *went*.
type ItemWorkspaceMove struct {
ID string `json:"id"`
SourceWorkspaceID string `json:"source_workspace_id"`
SourceItemID string `json:"source_item_id"`
TargetWorkspaceID string `json:"target_workspace_id"`
TargetItemID string `json:"target_item_id"`

// ArchivedSource is true when the source was archived as part of the
// operation (a move) and false for a plain copy.
ArchivedSource bool `json:"archived_source"`

// SourceSeq is the workspace-scoped `seq` the source workspace assigned
// when it archived the source. It exists only to order one source's
// moves deterministically: CreatedAt is second-precision RFC3339, so two
// moves inside the same second tie and the "moved to" lookup could
// otherwise return an arbitrary destination.
//
// nil for plain copies, which never archive. Since the "moved to" lookup
// reads only ArchivedSource rows, nil values never participate in the
// ordering.
SourceSeq *int64 `json:"source_seq,omitempty"`

CreatedBy string `json:"created_by"`
CreatedAt string `json:"created_at"`
}
Loading