Files
pad/internal/items/relation_blanks.go
T
xarmian c1076ce15f fix: a relation's "no target" has one stored form, and required means it names one (BUG-3028) (#1427)
* fix: a relation's "no target" has one stored form, and required means it names one (BUG-3028)

A scalar relation had three stored spellings of "no target" (key absent, ""
and whitespace-only), and required was enforced only against the first, so a
required relation holding "" validated. The canonical form is now the one
multi_relation uses: the key absent.

Provenance decides the rest, per the lead's ruling. A blank a write SETS
(create, fields_patch, a changed value in full fields, a move or copy
override) is removed before validation, so a required one is refused as
required. A patch blanking a required relation now gets the answer deleting
it always got. A legacy blank a write only CARRIES is removed after validation
and never refused: an untouched key under fields_patch, an unchanged full
fields value, move, bulk update and bulk move, cross-workspace copy and its
preflight, artifact import. On the patch path the store does this under its
write lock (ItemUpdate.BlankRelationKeys), so a concurrent write's new value
is never mistaken for a blank.

An empty list filter on a scalar relation of a collection-scoped list
(owner=) now matches all three spellings instead of only "".

MCP ToolSurfaceVersion 0.40, a behavior bump. No migration: this instance
has no relation fields, and the prod census is Dave's.

Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE

* fix(items): a blank relation default is no default, so it cannot satisfy required (BUG-3028, codex round 1)

Codex round 1 P2: a required relation whose schema default is "" had the blank
injected by validation, which satisfied required, and was then normalised
away after it, storing a required relation with no target. A blank default
on a scalar relation now takes the no-default path: a required field is
refused (the message names the blank default) and an optional one stays
absent with nothing reported dropped.

Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE
2026-09-22 08:08:15 -04:00

70 lines
2.3 KiB
Go

package items
import (
"sort"
"strings"
"github.com/PerpetualSoftware/pad/internal/models"
)
// BUG-3028: a scalar `relation` field had three stored spellings of "no
// target" — the key absent, `""`, and whitespace-only — and `required` was
// enforced only against the first, so a required relation holding `""`
// validated. The canonical form is the one multi_relation already uses (U4):
// the key ABSENT. These helpers find and remove the other two spellings.
//
// Which blanks a door removes BEFORE validation and which AFTER is the whole
// of the provenance rule (the lead's ruling on BUG-3028): a blank the write
// itself SUPPLIES is removed first, so the ordinary required check refuses it;
// a legacy blank the write merely CARRIES is removed after, so it becomes
// absent without refusing a write that never touched the field.
// IsBlankRelationValue reports whether v is a string that trims to "" — one
// of the two non-canonical spellings of "no target".
func IsBlankRelationValue(v any) bool {
s, ok := v.(string)
return ok && strings.TrimSpace(s) == ""
}
// BlankRelationKeys returns, sorted, the keys of fields that the schema
// declares as a scalar `relation` and whose value is blank.
func BlankRelationKeys(fields map[string]any, schema models.CollectionSchema) []string {
var keys []string
for _, def := range schema.Fields {
if def.Type != "relation" {
continue
}
if v, ok := fields[def.Key]; ok && IsBlankRelationValue(v) {
keys = append(keys, def.Key)
}
}
sort.Strings(keys)
return keys
}
// DropBlankRelations deletes the blank scalar relation keys for which keep is
// false (every one when keep is nil) and returns the keys it deleted.
func DropBlankRelations(fields map[string]any, schema models.CollectionSchema, keep func(key string) bool) []string {
var dropped []string
for _, k := range BlankRelationKeys(fields, schema) {
if keep != nil && keep(k) {
continue
}
delete(fields, k)
dropped = append(dropped, k)
}
return dropped
}
// ScalarRelationKeys returns the keys the schema declares as scalar
// `relation`, in schema order.
func ScalarRelationKeys(schema models.CollectionSchema) []string {
var keys []string
for _, def := range schema.Fields {
if def.Type == "relation" {
keys = append(keys, def.Key)
}
}
return keys
}