Problem class: go-struct-field-propagation-omitted-mapper-field
I couldn't locate the Hermes Canopy repository in this environment (nothing is checked out), so I produced the solution as a self-contained, adapt-and-apply document. It's saved to ~/SOLUTION.md.
Here is the solution:
TopicSummary.RootNodeID dropped by mapper and stale node_countProblem class: go-struct-field-propagation-omitted-mapper-field
Two defects combined into the same bad payload:
1a. Mapper dropped a persisted identity field. TopicSummary exposes RootNodeID, but topicToSummary never assigned it, so it serialized as the zero UUID on every endpoint using the mapper (both POST and GET). The DB row was correct; only the DTO projection was lossy.
1b. Creation never refreshed the denormalized counter. The root TopicMember row is inserted, but db.Topic.NodeCount is a denormalized counter not updated on that path, so the column stayed at its 0 default — and the mapper faithfully copied 0.
Fixing only the mapper makes root_node_id correct but leaves node_count at 0. Both must be fixed.
2a. Copy every persisted identity field:
func topicToSummary(t *db.Topic) *TopicSummary {
if t == nil {
return nil
}
return &TopicSummary{
ID: t.ID,
Name: t.Name,
RootNodeID: t.RootNodeID, // FIX: was omitted
NodeCount: t.NodeCount,
}
}
2b. Recount after inserting the root membership (in the same transaction):
func refreshTopicNodeCount(tx *gorm.DB, topic *db.Topic) error {
var count int64
if err := tx.Model(&db.TopicMember{}).
Where("topic_id = ?", topic.ID).
Count(&count).Error; err != nil {
return err
}
if err := tx.Model(&db.Topic{}).
Where("id = ?", topic.ID).
Update("node_count", count).Error; err != nil {
return err
}
topic.NodeCount = count // keep in-memory struct consistent for the response
return nil
}
func (s *Service) CreateTopic(ctx context.Context, req CreateTopicRequest) (*TopicSummary, error) {
topic := &db.Topic{ID: uuid.New(), Name: req.Name, RootNodeID: req.RootNodeID}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(topic).Error; err != nil {
return err
}
if err := tx.Create(&db.TopicMember{
ID: uuid.New(), TopicID: topic.ID, NodeID: topic.RootNodeID,
}).Error; err != nil {
return err
}
return refreshTopicNodeCount(tx, topic) // FIX: refresh denormalized count
})
if err != nil {
return nil, err
}
return topicToSummary(topic), nil
}
The essential checks: the submitted UUID round-trips unchanged, and the count is at least 1 — on both POST and GET, plus the DB source of truth.
func TestTopic_RootNodeIDAndNodeCount_PostAndGet(t *testing.T) {
rootID := uuid.New()
// ---- POST ----
body := fmt.Sprintf(`{"name":"Canopy","root_node_id":%q}`, rootID.String())
req := httptest.NewRequest(http.MethodPost, "/v1/topics", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusCreated, rec.Code)
var created dto.TopicSummary
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created))
require.Equal(t, rootID, created.RootNodeID, "POST must echo submitted root_node_id")
require.GreaterOrEqual(t, created.NodeCount, 1, "POST must report node_count >= 1")
// ---- GET ----
getReq := httptest.NewRequest(http.MethodGet, "/v1/topics/"+created.ID.String(), nil)
getRec := httptest.NewRecorder()
router.ServeHTTP(getRec, getReq)
require.Equal(t, http.StatusOK, getRec.Code)
var fetched dto.TopicSummary
require.NoError(t, json.Unmarshal(getRec.Body.Bytes(), &fetched))
require.Equal(t, rootID, fetched.RootNodeID, "GET must return persisted root_node_id")
require.GreaterOrEqual(t, fetched.NodeCount, 1, "GET must return node_count >= 1")
// ---- DB source of truth ----
var persisted db.Topic
require.NoError(t, testDB.First(&persisted, "id = ?", created.ID).Error)
require.Equal(t, rootID, persisted.RootNodeID)
require.GreaterOrEqual(t, persisted.NodeCount, 1)
}
go test ./internal/api/... -run 'TestTopic_RootNodeIDAndNodeCount_PostAndGet' -count=1 -v
go test ./internal/... -count=1
go test ./... -count=1 -race
topicToSummary assigns RootNodeID: t.RootNodeID.node_count in the same transaction as the root-member insert.POST /v1/topics returns the submitted root_node_id and node_count >= 1.GET /v1/topics/{id} returns the persisted root_node_id and node_count >= 1.root_node_id and node_count >= 1.Note on verification: Since the actual repository isn't present in this environment, I could not execute go test against the real code or verify exact package/table names. The document notes where to adapt (TopicMember table name, parser assertion style). If you point me at the repo path or clone URL, I can apply the patch directly and run the tests to confirm.
# Evidence - Problem class: go-struct-field-propagation-omitted-mapper-field - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-11T14:20:00.386Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "When an API DTO is populated by a mapper, verify every persisted identity field is copied. Here TopicSummary exposed RootNodeID, but topicToSummary omitted db.Topic.RootNodeID. The creation path also added the root member without refreshing the denormalized node_count. Fix the mapper, refresh the count after membership insertion, and add integration assertions on both POST and GET for the submitted UUID plus node_count >= 1.", "environment": "Hermes Canopy topic service", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-struct-field-propagation-omitted-mapper-field", "provider": "openrouter", "solved_at": "2026-09-11T14:20:00.386Z", "version": ""}I couldn't locate the Hermes Canopy repository in this environment (nothing is checked out), so I produced the solution as a self-contained, adapt-and-apply document. It's saved to ~/SOLUTION.md.
Here is the solution:
TopicSummary.RootNodeID dropped by mapper and stale node_countProblem class: go-struct-field-propagation-omitted-mapper-field
Two defects combined into the same bad payload:
1a. Mapper dropped a persisted identity field. TopicSummary exposes RootNodeID, but topicToSummary never assigned it, so it serialized as the zero UUID on every endpoint using the mapper (both POST and GET). The DB row was correct; only the DTO projection was lossy.
1b. Creation never refreshed the denormalized counter. The root TopicMember row is inserted, but db.Topic.NodeCount is a denormalized counter not updated on that path, so the column stayed at its 0 default — and the mapper faithfully copied 0.
Fixing only the mapper makes root_node_id correct but leaves node_count at 0. Both must be fixed.
2a. Copy every persisted identity field:
func topicToSummary(t *db.Topic) *TopicSummary {
if t == nil {
return nil
}
return &TopicSummary{
ID: t.ID,
Name: t.Name,
RootNodeID: t.RootNodeID, // FIX: was omitted
NodeCount: t.NodeCount,
}
}
2b. Recount after inserting the root membership (in the same transaction):
func refreshTopicNodeCount(tx *gorm.DB, topic *db.Topic) error {
var count int64
if err := tx.Model(&db.TopicMember{}).
Where("topic_id = ?", topic.ID).
Count(&count).Error; err != nil {
return err
}
if err := tx.Model(&db.Topic{}).
Where("id = ?", topic.ID).
Update("node_count", count).Error; err != nil {
return err
}
topic.NodeCount = count // keep in-memory struct consistent for the response
return nil
}
func (s *Service) CreateTopic(ctx context.Context, req CreateTopicRequest) (*TopicSummary, error) {
topic := &db.Topic{ID: uuid.New(), Name: req.Name, RootNodeID: req.RootNodeID}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(topic).Error; err != nil {
return err
}
if err := tx.Create(&db.TopicMember{
ID: uuid.New(), TopicID: topic.ID, NodeID: topic.RootNodeID,
}).Error; err != nil {
return err
}
return refreshTopicNodeCount(tx, topic) // FIX: refresh denormalized count
})
if err != nil {
return nil, err
}
return topicToSummary(topic), nil
}
The essential checks: the submitted UUID round-trips unchanged, and the count is at least 1 — on both POST and GET, plus the DB source of truth.
func TestTopic_RootNodeIDAndNodeCount_PostAndGet(t *testing.T) {
rootID := uuid.New()
// ---- POST ----
body := fmt.Sprintf(`{"name":"Canopy","root_node_id":%q}`, rootID.String())
req := httptest.NewRequest(http.MethodPost, "/v1/topics", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusCreated, rec.Code)
var created dto.TopicSummary
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created))
require.Equal(t, rootID, created.RootNodeID, "POST must echo submitted root_node_id")
require.GreaterOrEqual(t, created.NodeCount, 1, "POST must report node_count >= 1")
// ---- GET ----
getReq := httptest.NewRequest(http.MethodGet, "/v1/topics/"+created.ID.String(), nil)
getRec := httptest.NewRecorder()
router.ServeHTTP(getRec, getReq)
require.Equal(t, http.StatusOK, getRec.Code)
var fetched dto.TopicSummary
require.NoError(t, json.Unmarshal(getRec.Body.Bytes(), &fetched))
require.Equal(t, rootID, fetched.RootNodeID, "GET must return persisted root_node_id")
require.GreaterOrEqual(t, fetched.NodeCount, 1, "GET must return node_count >= 1")
// ---- DB source of truth ----
var persisted db.Topic
require.NoError(t, testDB.First(&persisted, "id = ?", created.ID).Error)
require.Equal(t, rootID, persisted.RootNodeID)
require.GreaterOrEqual(t, persisted.NodeCount, 1)
}
go test ./internal/api/... -run 'TestTopic_RootNodeIDAndNodeCount_PostAndGet' -count=1 -v
go test ./internal/... -count=1
go test ./... -count=1 -race
topicToSummary assigns RootNodeID: t.RootNodeID.node_count in the same transaction as the root-member insert.POST /v1/topics returns the submitted root_node_id and node_count >= 1.GET /v1/topics/{id} returns the persisted root_node_id and node_count >= 1.root_node_id and node_count >= 1.Note on verification: Since the actual repository isn't present in this environment, I could not execute go test against the real code or verify exact package/table names. The document notes where to adapt (TopicMember table name, parser assertion style). If you point me at the repo path or clone URL, I can apply the patch directly and run the tests to confirm.
# Evidence - Problem class: go-struct-field-propagation-omitted-mapper-field - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-11T14:20:00.386Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "When an API DTO is populated by a mapper, verify every persisted identity field is copied. Here TopicSummary exposed RootNodeID, but topicToSummary omitted db.Topic.RootNodeID. The creation path also added the root member without refreshing the denormalized node_count. Fix the mapper, refresh the count after membership insertion, and add integration assertions on both POST and GET for the submitted UUID plus node_count >= 1.", "environment": "Hermes Canopy topic service", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-struct-field-propagation-omitted-mapper-field", "provider": "openrouter", "solved_at": "2026-09-11T14:20:00.386Z", "version": ""}