mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 23:15:40 +00:00
fix(items,server): referential system metadata travels only within its context (BUG-2674)
Lead ruling on the copy-semantics fork Codex round 1 raised. It does not add an
exception to the carry rule — it applies the qualifier the rule already had.
The contract was "system-minted NON-REFERENTIAL data carries". github_pr is
referential: it names a repository that is a property of the SOURCE workspace's
project, and it hydrates into code_context and renders as a live PR link. Carried
into another workspace that link is a false statement about the destination's
project, not preserved information. implementation_notes and decision_log
describe the item's own history and are true wherever the item is.
So the rule stays one sentence: non-referential system data carries everywhere;
referential system data carries only where its referent's context still holds.
## Scope is a required argument
MigrateFields takes items.MigrateScope. Required rather than defaulted because
BOTH wrong answers lose something: SameWorkspace on a cross-workspace copy
carries a PR link into a workspace it does not describe, and CrossWorkspace on
an ordinary move DROPS metadata from an item whose repo context never changed. A
caller that must name its scope cannot pick one by omission.
The two move handlers pass SameWorkspace as a property of the endpoint, not a
guess — a move changes an item's COLLECTION and cannot change its workspace.
The copy and its preflight COMPUTE it by comparing workspace ids rather than
assuming cross-workspace, because that endpoint accepts a target_workspace equal
to the source; hardcoding would drop a github_pr from a same-workspace duplicate.
Both sides use the same helper, or the preview promises a carry the copy drops —
the DR-6 divergence the shared endpoint exists to prevent.
## The drop is reported, with a reason that explains itself
PLAN-2357 DR-17: "None of this may be silent." It would be perverse to
reintroduce a silent drop inside this fix's own new branch.
The preflight reports it as `referent_not_portable` rather than the generic
`no_target_field`. That generic reason would be actively misleading here: no
schema declares these keys ANYWHERE, so "the destination has no such field" is
equally true of the source and explains nothing about why the value is being
left behind.
## Verified
Mutants run: scope ignored (always carry) -> the cross-workspace leg fails;
generic reason on the preflight drop -> the reason assertion fails. The
same-workspace leg and the non-referential-sibling leg are what stop an
implementation that ignores scope in EITHER direction from passing — each half
alone is satisfiable by a constant.
Gates re-run for THIS commit: lint 0 · go test ./... 0 · make test-pg 0 (3282).
Web gates NOT re-run and not claimed: this commit touches no web file (the web
half of BUG-2674 shipped in 82577a74 and is unchanged here).
## Noted, not fixed
handlers_items_copy_preflight.go already documents the same defect class for
RELATION fields — a same-named relation carries a SOURCE-workspace item id
across workspaces and is reported as a clean carry — and says the fix "belongs
in MigrateFields, for both callers at once". MigrateScope is now the mechanism
that comment asks for, but wiring relation fields through it is a separate
change with its own semantics to settle.
Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
This commit is contained in:
@@ -17,14 +17,39 @@ type MigrateResult struct {
|
||||
Errors []string
|
||||
}
|
||||
|
||||
// MigrateScope says how far the item is travelling. It is a required argument
|
||||
// rather than an option with a default because BOTH wrong answers lose
|
||||
// something (BUG-2674): using SameWorkspace for a cross-workspace copy carries
|
||||
// a github_pr into a workspace whose repository it does not describe, and using
|
||||
// CrossWorkspace for an ordinary move DROPS that metadata from an item whose
|
||||
// repo context never changed. A caller that has to name its scope cannot pick
|
||||
// one by omission.
|
||||
type MigrateScope int
|
||||
|
||||
const (
|
||||
// SameWorkspace — a collection change within one workspace. The
|
||||
// surrounding context (repository, members, conventions) is unchanged,
|
||||
// so referential system metadata still describes something true.
|
||||
SameWorkspace MigrateScope = iota
|
||||
// CrossWorkspace — the item is landing in a different workspace, where
|
||||
// a referent belonging to the source's context no longer holds.
|
||||
CrossWorkspace
|
||||
)
|
||||
|
||||
// MigrateFields maps field values from a source schema to a target schema.
|
||||
// Fields with matching keys and compatible types are transferred.
|
||||
// Incompatible or missing fields are dropped. Required target fields without
|
||||
// values after migration are reported as errors.
|
||||
//
|
||||
// Reserved system metadata (models.IsReservedItemField) bypasses schema
|
||||
// matching entirely — it is declared by no schema, so matching it against one
|
||||
// is what destroyed it before BUG-2674. Referential reserved keys additionally
|
||||
// depend on scope; see MigrateScope.
|
||||
func MigrateFields(
|
||||
currentFields map[string]any,
|
||||
sourceSchema []models.FieldDef,
|
||||
targetSchema []models.FieldDef,
|
||||
scope MigrateScope,
|
||||
) MigrateResult {
|
||||
result := MigrateResult{
|
||||
Fields: make(map[string]any),
|
||||
@@ -60,6 +85,15 @@ func MigrateFields(
|
||||
// them. There is no migration to attempt: they have no source or
|
||||
// target FieldDef to migrate BETWEEN.
|
||||
if models.IsReservedItemField(key) {
|
||||
// Referential metadata travels only as far as its referent's
|
||||
// context. Reported through the ordinary Dropped channel with
|
||||
// no special casing — a user losing a PR link should learn it
|
||||
// the same way they learn about any other dropped value
|
||||
// (PLAN-2357 DR-17: "None of this may be silent").
|
||||
if scope == CrossWorkspace && models.IsReferentialItemField(key) {
|
||||
result.Dropped = append(result.Dropped, key)
|
||||
continue
|
||||
}
|
||||
result.Fields[key] = value
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestMigrateFields_MatchingTypes(t *testing.T) {
|
||||
}
|
||||
fields := map[string]any{"status": "open", "priority": "high"}
|
||||
|
||||
result := MigrateFields(fields, source, target)
|
||||
result := MigrateFields(fields, source, target, SameWorkspace)
|
||||
|
||||
if result.Fields["status"] != "open" {
|
||||
t.Errorf("status: got %v, want 'open'", result.Fields["status"])
|
||||
@@ -40,7 +40,7 @@ func TestMigrateFields_SelectValueNotInTarget(t *testing.T) {
|
||||
}
|
||||
fields := map[string]any{"status": "in-progress"}
|
||||
|
||||
result := MigrateFields(fields, source, target)
|
||||
result := MigrateFields(fields, source, target, SameWorkspace)
|
||||
|
||||
// "in-progress" is not in target options, should be dropped and default applied
|
||||
if result.Fields["status"] != "todo" {
|
||||
@@ -58,7 +58,7 @@ func TestMigrateFields_DropsExtraFields(t *testing.T) {
|
||||
}
|
||||
fields := map[string]any{"severity": "high", "browser": "Chrome"}
|
||||
|
||||
result := MigrateFields(fields, source, target)
|
||||
result := MigrateFields(fields, source, target, SameWorkspace)
|
||||
|
||||
if len(result.Dropped) != 2 {
|
||||
t.Errorf("dropped: got %d, want 2", len(result.Dropped))
|
||||
@@ -76,7 +76,7 @@ func TestMigrateFields_TypeConversion(t *testing.T) {
|
||||
}
|
||||
fields := map[string]any{"count": 42, "status": "open"}
|
||||
|
||||
result := MigrateFields(fields, source, target)
|
||||
result := MigrateFields(fields, source, target, SameWorkspace)
|
||||
|
||||
if result.Fields["count"] != "42" {
|
||||
t.Errorf("count: got %v, want '42'", result.Fields["count"])
|
||||
@@ -93,7 +93,7 @@ func TestMigrateFields_RequiredFieldMissing(t *testing.T) {
|
||||
}
|
||||
fields := map[string]any{}
|
||||
|
||||
result := MigrateFields(fields, source, target)
|
||||
result := MigrateFields(fields, source, target, SameWorkspace)
|
||||
|
||||
if len(result.Errors) != 1 {
|
||||
t.Errorf("errors: got %d, want 1", len(result.Errors))
|
||||
@@ -107,7 +107,7 @@ func TestMigrateFields_DefaultApplied(t *testing.T) {
|
||||
}
|
||||
fields := map[string]any{}
|
||||
|
||||
result := MigrateFields(fields, source, target)
|
||||
result := MigrateFields(fields, source, target, SameWorkspace)
|
||||
|
||||
if result.Fields["status"] != "open" {
|
||||
t.Errorf("status: got %v, want 'open'", result.Fields["status"])
|
||||
@@ -154,7 +154,7 @@ func TestMigrateFields_ReservedKeysCarryThroughUntouched(t *testing.T) {
|
||||
"severity": "high", // an ordinary key with no target home
|
||||
}
|
||||
|
||||
result := MigrateFields(fields, source, target)
|
||||
result := MigrateFields(fields, source, target, SameWorkspace)
|
||||
|
||||
for key, want := range map[string]any{
|
||||
"implementation_notes": wantNotes,
|
||||
@@ -199,7 +199,7 @@ func TestMigrateFields_ReservedKeysBypassSchemaMatching(t *testing.T) {
|
||||
|
||||
notes := []any{map[string]any{"id": "note-1", "summary": "carried"}}
|
||||
want := []any{map[string]any{"id": "note-1", "summary": "carried"}} // independent copy — see above
|
||||
result := MigrateFields(map[string]any{"implementation_notes": notes}, source, target)
|
||||
result := MigrateFields(map[string]any{"implementation_notes": notes}, source, target, SameWorkspace)
|
||||
|
||||
got, ok := result.Fields["implementation_notes"]
|
||||
if !ok {
|
||||
@@ -236,3 +236,67 @@ func TestReservedItemFieldKeysAreStableAndComplete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The carry rule's own qualifier (BUG-2674, lead ruling): system-minted
|
||||
// NON-REFERENTIAL data carries everywhere; REFERENTIAL system data carries only
|
||||
// where its referent's context still holds. github_pr names a repository that
|
||||
// belongs to the source workspace's context — carried into a different
|
||||
// workspace it renders as a live PR link on an item whose project may have no
|
||||
// relationship to that repo, which is a false statement rather than a preserved
|
||||
// one. Notes and decisions describe the item's own history and are true wherever
|
||||
// the item is.
|
||||
//
|
||||
// Both scopes are asserted in one test on purpose. The interesting property is
|
||||
// the DIFFERENCE: a implementation that ignored scope entirely, in either
|
||||
// direction, passes whichever half you write alone.
|
||||
func TestMigrateFields_ReferentialKeysTravelOnlyWithinTheirContext(t *testing.T) {
|
||||
source := []models.FieldDef{}
|
||||
target := []models.FieldDef{}
|
||||
|
||||
pr := map[string]any{"number": float64(42), "url": "https://example.invalid/42"}
|
||||
notes := []any{map[string]any{"id": "note-1", "summary": "history is true anywhere"}}
|
||||
build := func() map[string]any {
|
||||
return map[string]any{"github_pr": pr, "implementation_notes": notes}
|
||||
}
|
||||
|
||||
t.Run("same workspace carries it", func(t *testing.T) {
|
||||
result := MigrateFields(build(), source, target, SameWorkspace)
|
||||
if _, ok := result.Fields["github_pr"]; !ok {
|
||||
t.Error("github_pr must carry within one workspace — the repo context is unchanged")
|
||||
}
|
||||
for _, d := range result.Dropped {
|
||||
if d == "github_pr" {
|
||||
t.Error("github_pr must not be reported dropped within one workspace")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cross workspace drops it AND reports it", func(t *testing.T) {
|
||||
result := MigrateFields(build(), source, target, CrossWorkspace)
|
||||
if _, ok := result.Fields["github_pr"]; ok {
|
||||
t.Error("github_pr must not carry into another workspace — its referent's context is gone")
|
||||
}
|
||||
// Reported, not silently discarded. PLAN-2357 DR-17: "None of this
|
||||
// may be silent." A drop with no report is the defect this whole
|
||||
// unit exists to remove, and it would be perverse to reintroduce it
|
||||
// in the fix's own new branch.
|
||||
var reported bool
|
||||
for _, d := range result.Dropped {
|
||||
if d == "github_pr" {
|
||||
reported = true
|
||||
}
|
||||
}
|
||||
if !reported {
|
||||
t.Errorf("github_pr dropped without a report; Dropped = %#v", result.Dropped)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-referential metadata is unaffected by scope", func(t *testing.T) {
|
||||
for _, scope := range []MigrateScope{SameWorkspace, CrossWorkspace} {
|
||||
result := MigrateFields(build(), source, target, scope)
|
||||
if _, ok := result.Fields["implementation_notes"]; !ok {
|
||||
t.Errorf("scope %v: implementation_notes must carry — it describes the item, not its surroundings", scope)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -49,6 +49,34 @@ func IsReservedItemField(key string) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
// referentialItemFieldKeys are the reserved keys whose VALUE points at
|
||||
// something outside the item — a resource whose meaning depends on the
|
||||
// surrounding workspace's context rather than on the item itself.
|
||||
//
|
||||
// The distinction decides how far they travel (BUG-2674, lead ruling). The
|
||||
// carry rule is one sentence: system-minted NON-REFERENTIAL data carries;
|
||||
// referential system data carries only where its referent's context still
|
||||
// holds. implementation_notes and decision_log describe the item's own history
|
||||
// and are true wherever the item is. github_pr names a repository that is a
|
||||
// property of the SOURCE workspace's context — carried into a different
|
||||
// workspace it renders as a live PR link on an item whose project may have no
|
||||
// relationship to that repo, which is a false statement rather than a preserved
|
||||
// one.
|
||||
//
|
||||
// So this is not an exception to the rule; it is the rule's own qualifier doing
|
||||
// its job. A same-workspace move leaves the referent's context unchanged, so
|
||||
// these carry there.
|
||||
var referentialItemFieldKeys = map[string]struct{}{
|
||||
ItemFieldGitHubPR: {},
|
||||
}
|
||||
|
||||
// IsReferentialItemField reports whether a reserved key's value depends on the
|
||||
// workspace context around it. See referentialItemFieldKeys.
|
||||
func IsReferentialItemField(key string) bool {
|
||||
_, ok := referentialItemFieldKeys[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ReservedItemFieldKeys returns the reserved keys in a stable order, for
|
||||
// callers that need to enumerate rather than test membership (schema-key
|
||||
// validation, error messages). Sorted so the output is deterministic — an
|
||||
|
||||
@@ -1908,7 +1908,12 @@ func (s *Server) handleMoveItem(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Migrate fields
|
||||
result := items.MigrateFields(currentFields, sourceSchema.Fields, targetSchema.Fields)
|
||||
// SameWorkspace is a property of the endpoint, not a guess: a move
|
||||
// changes the item's COLLECTION and cannot change its workspace — the
|
||||
// cross-workspace path is the copy endpoint. So the repo/member
|
||||
// context around the item is unchanged and referential system
|
||||
// metadata still describes something true (BUG-2674).
|
||||
result := items.MigrateFields(currentFields, sourceSchema.Fields, targetSchema.Fields, items.SameWorkspace)
|
||||
|
||||
// Apply overrides
|
||||
for k, v := range input.FieldOverrides {
|
||||
|
||||
@@ -602,7 +602,12 @@ func (s *Server) bulkMoveCollection(r *http.Request, workspaceID string, item *m
|
||||
currentFields = make(map[string]any)
|
||||
}
|
||||
|
||||
result := items.MigrateFields(currentFields, sourceSchema.Fields, targetSchema.Fields)
|
||||
// SameWorkspace is a property of the endpoint, not a guess: a move
|
||||
// changes the item's COLLECTION and cannot change its workspace — the
|
||||
// cross-workspace path is the copy endpoint. So the repo/member
|
||||
// context around the item is unchanged and referential system
|
||||
// metadata still describes something true (BUG-2674).
|
||||
result := items.MigrateFields(currentFields, sourceSchema.Fields, targetSchema.Fields, items.SameWorkspace)
|
||||
if req.Status != "" {
|
||||
result.Fields["status"] = req.Status
|
||||
}
|
||||
|
||||
@@ -271,6 +271,22 @@ type ItemCopyPreflightDropped struct {
|
||||
// destination workspace (DR-8)
|
||||
// "agent_role_not_portable" — role slugs are workspace-local and
|
||||
// never carry (DR-8)
|
||||
// "referent_not_portable" — system metadata whose VALUE points at
|
||||
// something belonging to the SOURCE
|
||||
// workspace's context, so it describes
|
||||
// nothing true in the destination
|
||||
// (BUG-2674). Today that is github_pr:
|
||||
// the repository is a property of the
|
||||
// source's project, and carrying it
|
||||
// would render a live PR link on an
|
||||
// item whose project may have no
|
||||
// relationship to that repo. Distinct
|
||||
// from no_target_field, which would
|
||||
// otherwise be reported here and is
|
||||
// simply wrong: no schema declares this
|
||||
// key ANYWHERE, so "the destination has
|
||||
// no such field" is true of the source
|
||||
// too and explains nothing
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
@@ -589,7 +605,13 @@ func (s *Server) handleCopyItemPreflight(w http.ResponseWriter, r *http.Request)
|
||||
// deliberately never read here; the authoritative answer comes from
|
||||
// items.ValidateFieldsDetailed run over the MERGED map, which also
|
||||
// applies destination defaults and type/option/pattern checks.
|
||||
migrated := items.MigrateFields(currentFields, sourceSchema.Fields, targetSchema.Fields)
|
||||
// Scope is COMPUTED, not assumed cross-workspace: this endpoint accepts a
|
||||
// target_workspace equal to the source, and hardcoding CrossWorkspace
|
||||
// would drop a github_pr from a duplicate whose repo context never
|
||||
// changed (BUG-2674). Same computation in the mutating copy, or the two
|
||||
// disagree — the divergence DR-6 exists to prevent.
|
||||
migrated := items.MigrateFields(currentFields, sourceSchema.Fields, targetSchema.Fields,
|
||||
migrateScopeFor(item.WorkspaceID, dst.WorkspaceID()))
|
||||
|
||||
final := make(map[string]any, len(migrated.Fields)+len(input.FieldOverrides))
|
||||
origin := make(map[string]string, len(final))
|
||||
@@ -745,6 +767,17 @@ func (s *Server) handleCopyItemPreflight(w http.ResponseWriter, r *http.Request)
|
||||
for _, key := range sortedDroppedKeys(migrated.Dropped, sourceSchema.Fields) {
|
||||
reason := "no_target_field"
|
||||
label := key
|
||||
// A reserved key in Dropped can only have got there one way: it is
|
||||
// referential and this is a cross-workspace copy. The generic
|
||||
// no_target_field would be actively misleading — no schema declares
|
||||
// these keys anywhere, so it is equally true of the source and
|
||||
// explains nothing about why the value is being left behind.
|
||||
if models.IsReservedItemField(key) {
|
||||
resp.Fields.Dropped = append(resp.Fields.Dropped, ItemCopyPreflightDropped{
|
||||
Key: key, Label: reservedFieldLabel(key), Kind: "field", Reason: "referent_not_portable",
|
||||
})
|
||||
continue
|
||||
}
|
||||
srcDef, declaredBySource := fieldDefByKey(sourceSchema.Fields, key)
|
||||
if def, exists := targetDefs[key]; exists {
|
||||
// The key exists downstream, so migration rejected the VALUE.
|
||||
@@ -1135,3 +1168,13 @@ func reservedFieldLabel(key string) string {
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// migrateScopeFor answers the one question items.MigrateScope asks: is the
|
||||
// item landing in a different workspace? Shared by the preflight and the
|
||||
// mutating copy so the preview cannot promise a carry the copy then drops.
|
||||
func migrateScopeFor(sourceWorkspaceID, targetWorkspaceID string) items.MigrateScope {
|
||||
if sourceWorkspaceID == targetWorkspaceID {
|
||||
return items.SameWorkspace
|
||||
}
|
||||
return items.CrossWorkspace
|
||||
}
|
||||
|
||||
@@ -2034,3 +2034,60 @@ func TestCopyPreflight_ReportsReservedMetadataAsCarried(t *testing.T) {
|
||||
t.Fatalf("implementation_notes was reported carried but is absent on the copy: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// BUG-2674, lead ruling. github_pr is REFERENTIAL system metadata: it names a
|
||||
// repository belonging to the source workspace's context. It carries on an
|
||||
// intra-workspace move (context unchanged) and drops on a cross-workspace copy,
|
||||
// where carrying it would render a live PR link on an item whose project may
|
||||
// have no relationship to that repo.
|
||||
//
|
||||
// The drop must be REPORTED — PLAN-2357 DR-17, "None of this may be silent" —
|
||||
// and with a reason that explains itself. The generic no_target_field would be
|
||||
// misleading here: no schema declares this key anywhere, so it is equally true
|
||||
// of the source and says nothing about why the value is being left behind.
|
||||
func TestCopyPreflight_ReportsReferentialMetadataAsNotPortable(t *testing.T) {
|
||||
f := newCopyPreflightFixture(t)
|
||||
|
||||
seeded := `{"status":"open","priority":"low","impact":"large","count":7,"code":"abc",` +
|
||||
`"github_pr":{"number":42,"url":"https://example.invalid/42","state":"OPEN"},` +
|
||||
`"implementation_notes":[{"id":"note-1","summary":"travels anywhere"}]}`
|
||||
if _, err := f.srv.store.UpdateItem(f.source.ID, models.ItemUpdate{Fields: &seeded}); err != nil {
|
||||
t.Fatalf("seed referential metadata: %v", err)
|
||||
}
|
||||
|
||||
body := f.resolvableBody()
|
||||
body["field_overrides"] = map[string]any{"ticket": "T-1", "code": "123"}
|
||||
pre := f.ok(body)
|
||||
|
||||
var dropped *ItemCopyPreflightDropped
|
||||
for i := range pre.Fields.Dropped {
|
||||
if pre.Fields.Dropped[i].Key == models.ItemFieldGitHubPR {
|
||||
dropped = &pre.Fields.Dropped[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if dropped == nil {
|
||||
t.Fatalf("github_pr must be reported dropped on a cross-workspace copy; dropped = %+v", pre.Fields.Dropped)
|
||||
}
|
||||
if dropped.Reason != "referent_not_portable" {
|
||||
t.Errorf("reason = %q, want %q — no_target_field is true of the source too and explains nothing",
|
||||
dropped.Reason, "referent_not_portable")
|
||||
}
|
||||
|
||||
// It must not also be reported carried, and the non-referential sibling
|
||||
// must NOT be swept up with it — that pair is the whole distinction.
|
||||
for _, c := range pre.Fields.Carried {
|
||||
if c.Key == models.ItemFieldGitHubPR {
|
||||
t.Error("github_pr reported both dropped and carried")
|
||||
}
|
||||
}
|
||||
var notesCarried bool
|
||||
for _, c := range pre.Fields.Carried {
|
||||
if c.Key == models.ItemFieldImplementationNotes {
|
||||
notesCarried = true
|
||||
}
|
||||
}
|
||||
if !notesCarried {
|
||||
t.Error("implementation_notes must still carry across workspaces — it describes the item, not its surroundings")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,7 +309,7 @@ func TestPlanAttachmentCopy_DroppedFieldNotCloned(t *testing.T) {
|
||||
"screenshot": "pad-attachment:" + dropped.ID,
|
||||
}
|
||||
|
||||
migrated := items.MigrateFields(rawFields, sourceSchema, targetSchema)
|
||||
migrated := items.MigrateFields(rawFields, sourceSchema, targetSchema, items.SameWorkspace)
|
||||
if len(migrated.Dropped) != 1 || migrated.Dropped[0] != "screenshot" {
|
||||
t.Fatalf("precondition: MigrateFields dropped %v, want [screenshot]", migrated.Dropped)
|
||||
}
|
||||
|
||||
@@ -602,7 +602,17 @@ func (s *Store) copyItemAcrossWorkspacesTx(req CrossWorkspaceCopyRequest, source
|
||||
// malformed_override from its own preview. A bad request is a bad request
|
||||
// whether or not the destination happens to be full, and a client told
|
||||
// "you are out of room" cannot fix an override it was never told about.
|
||||
finalFields, dropped, err := migrateCopyFields(source.Fields, sourceColl.Schema, targetColl.Schema, req.FieldOverrides)
|
||||
// Scope is COMPUTED from the two workspace ids rather than assumed
|
||||
// cross-workspace: this path also serves a copy whose target IS the source
|
||||
// workspace, and hardcoding CrossWorkspace would drop a github_pr from a
|
||||
// duplicate whose repo context never changed (BUG-2674). The preflight
|
||||
// computes it the same way — a divergence here would have the preview
|
||||
// promising a carry the copy drops, which DR-6 exists to prevent.
|
||||
scope := items.SameWorkspace
|
||||
if sourceWorkspaceID != req.TargetWorkspaceID {
|
||||
scope = items.CrossWorkspace
|
||||
}
|
||||
finalFields, dropped, err := migrateCopyFields(source.Fields, sourceColl.Schema, targetColl.Schema, req.FieldOverrides, scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1006,7 +1016,7 @@ func (s *Store) getCollectionInWorkspaceTx(tx *sql.Tx, collectionID, workspaceID
|
||||
//
|
||||
// Returns the final field map (the planner's input, pre-rewrite) and the keys
|
||||
// migration dropped.
|
||||
func migrateCopyFields(sourceFieldsJSON, sourceSchemaJSON, targetSchemaJSON string, overrides map[string]any) (map[string]any, []string, error) {
|
||||
func migrateCopyFields(sourceFieldsJSON, sourceSchemaJSON, targetSchemaJSON string, overrides map[string]any, scope items.MigrateScope) (map[string]any, []string, error) {
|
||||
var sourceSchema, targetSchema models.CollectionSchema
|
||||
if err := json.Unmarshal([]byte(sourceSchemaJSON), &sourceSchema); err != nil {
|
||||
return nil, nil, fmt.Errorf("copy item across workspaces: parse source schema: %w", err)
|
||||
@@ -1032,7 +1042,7 @@ func migrateCopyFields(sourceFieldsJSON, sourceSchemaJSON, targetSchemaJSON stri
|
||||
}
|
||||
}
|
||||
|
||||
migrated := items.MigrateFields(currentFields, sourceSchema.Fields, targetSchema.Fields)
|
||||
migrated := items.MigrateFields(currentFields, sourceSchema.Fields, targetSchema.Fields, scope)
|
||||
for k, v := range overrides {
|
||||
if v == nil {
|
||||
// An explicit null means "leave this unset". DELETE rather than
|
||||
|
||||
Reference in New Issue
Block a user