fix(server): item context decodes schemas canonically and treats reference items as no work (PLAN-3598 slice (b))

CI's Go job at d290fb37c failed three guards in internal/models, a package
my local gate did not run.
- TestReservedFieldDecodeSitesAreClassified / TestGuardSeesEveryKnownDecodeSite:
  the trigger-option read and the done check decoded a collection schema
  with raw json.Unmarshal. Both read item fields, so both now use
  models.UnmarshalItemFieldSchema.
- TestPLAN3535_LifecycleHelperCallSitesAreClassified: the new IsTerminalItem
  call was unclassified. Classifying it exposed a real defect. A reference
  item (a doc, in a collection that does not track work) read as OPEN, so a
  doc blocking the item raised open_blocker. CollectionTracksWork now answers
  first, as childProgressState's isReferenceCollection does, so a reference
  item is neither an open blocker nor a closed parent. The site is
  classified "exclude".

The new reference-item test fails on d290fb37c.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
This commit is contained in:
xarmian
2026-10-12 04:25:25 +00:00
parent d290fb37ca
commit e45860db96
3 changed files with 65 additions and 11 deletions
@@ -44,6 +44,7 @@ var lifecycleSites = map[string]string{
"internal/server/handlers_dashboard.go::isItemDone::IsTerminalItem": "keep: the done predicate itself; each caller below is classified",
"internal/server/handlers_dashboard.go::childProgressState::IsAbandonedItem": "exclude: the progress predicate; isReferenceCollection answers first",
"internal/server/handlers_dashboard.go::childProgressState::IsTerminalItem": "exclude: the progress predicate; isReferenceCollection answers first",
"internal/server/handlers_item_context.go::lifecycle::IsTerminalItem": "exclude: item-context conflicts (PLAN-3598); CollectionTracksWork answers first, so a reference item is neither an open blocker nor a closed parent",
"internal/store/items.go::nonTerminalFilter::doneFiltersForWorkspace": "keep: the list filter for an item list's own open items (a doc's status filters its own list)",
"internal/store/items.go::nonTerminalFilter::buildChildrenDoneExpr": "keep: as above",
"internal/store/items.go::ListItems::nonTerminalFilter": "keep: `pad item list` / MCP list's default open filter, a list's own items",
+30 -11
View File
@@ -230,7 +230,7 @@ func (s *Server) handleGetItemContext(w http.ResponseWriter, r *http.Request) {
writeInternalError(w, err)
return
}
itemOpen := !sc.terminal(item)
itemOpen := sc.openWork(item)
for i, a := range ancestors {
p := itemContextParent{Ref: a.Ref, Title: a.Title, Collection: a.CollectionSlug, Seq: a.Seq, Status: contextFieldString(a.Fields, "status")}
if ac := acceptance.Parse(a.Content); ac.Found {
@@ -238,7 +238,7 @@ func (s *Server) handleGetItemContext(w http.ResponseWriter, r *http.Request) {
}
if i == 0 {
p.Content = a.Content
if itemOpen && sc.terminal(&a) {
if itemOpen && sc.closedWork(&a) {
resp.Conflicts = append(resp.Conflicts, itemContextConflict{Kind: "open_under_closed_parent", Refs: []string{item.Ref, a.Ref},
Detail: item.Ref + " is open but its parent " + a.Ref + " is closed"})
}
@@ -306,7 +306,7 @@ func (s *Server) handleGetItemContext(w http.ResponseWriter, r *http.Request) {
}
depKind = append(depKind, kind)
if !outgoing && itemOpen {
if other, gerr := s.store.GetItem(otherID); gerr == nil && other != nil && !sc.terminal(other) {
if other, gerr := s.store.GetItem(otherID); gerr == nil && other != nil && sc.openWork(other) {
resp.Conflicts = append(resp.Conflicts, itemContextConflict{Kind: "open_blocker", Refs: []string{item.Ref, ref},
Detail: item.Ref + " is blocked by " + ref + ", which is still open"})
}
@@ -415,7 +415,7 @@ func (s *Server) resolveContextIntent(conv *collections.TraitedCollection, inten
}
if c != nil {
var schema models.CollectionSchema
if json.Unmarshal([]byte(c.Schema), &schema) == nil {
if models.UnmarshalItemFieldSchema([]byte(c.Schema), &schema) == nil {
for _, f := range schema.Fields {
if f.Key == "trigger" {
options = f.Options
@@ -599,7 +599,8 @@ func contextFieldString(fieldsJSON, key string) string {
return v
}
// contextSchemaCache answers "is this item closed?" per collection, with the
// contextSchemaCache answers "is this item open work, closed work, or not
// work at all?" per collection: CollectionTracksWork first, then the
// done-field-aware check (models.IsTerminalItem).
type contextSchemaCache struct {
s *Server
@@ -616,24 +617,42 @@ func newContextSchemaCache(s *Server) *contextSchemaCache {
return &contextSchemaCache{s: s, by: map[string]*contextSchema{}}
}
func (c *contextSchemaCache) terminal(it *models.Item) bool {
func (c *contextSchemaCache) load(it *models.Item) *contextSchema {
cs, ok := c.by[it.CollectionID]
if !ok {
cs = &contextSchema{}
if coll, err := c.s.store.GetCollection(it.CollectionID); err == nil && coll != nil {
cs.ok = json.Unmarshal([]byte(coll.Schema), &cs.schema) == nil
cs.ok = models.UnmarshalItemFieldSchema([]byte(coll.Schema), &cs.schema) == nil
if coll.Settings != "" {
_ = json.Unmarshal([]byte(coll.Settings), &cs.settings)
}
}
c.by[it.CollectionID] = cs
}
if !cs.ok {
return false
return cs
}
// lifecycle reports whether the item is work at all (a reference item, in a
// collection that does not track work, is neither open nor closed: PLAN-3535)
// and, if so, whether it is done.
func (c *contextSchemaCache) lifecycle(it *models.Item) (tracksWork, done bool) {
cs := c.load(it)
if !cs.ok || !models.CollectionTracksWork(cs.settings) {
return false, false
}
var m map[string]any
if json.Unmarshal([]byte(it.Fields), &m) != nil {
return false
return true, false
}
return models.IsTerminalItem(m, cs.schema, cs.settings)
return true, models.IsTerminalItem(m, cs.schema, cs.settings)
}
func (c *contextSchemaCache) openWork(it *models.Item) bool {
work, done := c.lifecycle(it)
return work && !done
}
func (c *contextSchemaCache) closedWork(it *models.Item) bool {
work, done := c.lifecycle(it)
return work && done
}
@@ -308,3 +308,37 @@ func TestPLAN3598_ItemContextArchivedFollowsGet(t *testing.T) {
}
})
}
// A reference item (in a collection that does not track work, PLAN-3535) is
// neither open nor closed: a doc that blocks the item raises no open_blocker.
func TestPLAN3598_ItemContextReferenceItemIsNotOpenWork(t *testing.T) {
forEachDialect(t, func(t *testing.T, d store.DriverType) {
f := newCtxFixture(t, d)
doc := f.itemIn("docs", map[string]any{"title": "Ctx reference doc"})
if _, err := f.srv.store.CreateItemLink(f.wsID, models.ItemLinkCreate{TargetID: f.task.ID, LinkType: models.ItemLinkTypeBlocks}, doc.ID); err != nil {
t.Fatal(err)
}
resp, code, body := f.context(f.ownerTok, "?intent=implement")
if resp == nil {
t.Fatalf("status %d: %s", code, body)
}
var sawDoc, sawTask bool
for _, b := range resp.Deps.BlockedBy {
sawDoc = sawDoc || b.Ref == doc.Ref
}
for _, c := range resp.Conflicts {
if c.Kind != "open_blocker" {
continue
}
for _, r := range c.Refs {
if r == doc.Ref {
t.Errorf("a reference doc was reported as an open blocker: %+v", c)
}
sawTask = sawTask || r == f.blocker.Ref
}
}
if !sawDoc || !sawTask {
t.Errorf("precondition: the doc should be listed as a blocker (%v) and the open task blocker still conflict (%v)", sawDoc, sawTask)
}
})
}