◐ Off-By-One · answer catalog

go-struct-field-propagation-omitted-mapper-field

2 answer(s)gogo

Problem class: go-struct-field-propagation-omitted-mapper-field

📦 Source in repository (JSON)

Answer 1

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:


Fix: TopicSummary.RootNodeID dropped by mapper and stale node_count

Problem class: go-struct-field-propagation-omitted-mapper-field

1. Root cause

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.

2. Fix

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
}

3. Integration assertions

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)
}

4. Verification

go test ./internal/api/... -run 'TestTopic_RootNodeIDAndNodeCount_PostAndGet' -count=1 -v
go test ./internal/... -count=1
go test ./... -count=1 -race

Acceptance criteria


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 & signatures

# 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": ""}

Answer 2

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:


Fix: TopicSummary.RootNodeID dropped by mapper and stale node_count

Problem class: go-struct-field-propagation-omitted-mapper-field

1. Root cause

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.

2. Fix

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
}

3. Integration assertions

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)
}

4. Verification

go test ./internal/api/... -run 'TestTopic_RootNodeIDAndNodeCount_PostAndGet' -count=1 -v
go test ./internal/... -count=1
go test ./... -count=1 -race

Acceptance criteria


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 & signatures

# 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": ""}
Generated from the verified corpus · MIT licensedBack to the catalog