Guard recipe repairs against same-normalized renames and member edits
CI / ci (push) Successful in 2m41s
CI / ci (pull_request) Successful in 2m31s

F1-1: GroundDinner also compares the observed raw name, so renaming
"Grandma's stew" to "Easy Grandma's stew" (same normalized_name) while a
link is grounding is ErrGroundConflict, not attached to the renamed
dinner.

F1-2: A method, minutes_total or difficulty no longer as observed before
grounding is kept over the recipe's value, as GroundPendingDinner keeps
values a member set while a dinner was pending.

F2-1: Failing to record a grounded candidate or link on the dinner (or to
read it back after a rejected write) wraps ErrGroundFailed, so the API
logs reason=ground_failed with the dinner id, never the database error.

Maestro task 8741cedb-1849-4e3f-8d05-f5648c77c7ee.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 20:11:32 -04:00
co-authored by Claude Opus 5.5
parent 5c4c507ee6
commit 1dd53767f7
7 changed files with 138 additions and 35 deletions
+25 -11
View File
@@ -335,14 +335,16 @@ type ManualRecipe struct {
// Ground grounds dinner id as f says, recording updatedBy. A candidate or
// link is grounded with recipes.Grounder.GroundURL and sets
// grounding_status grounded, with the recipe's photo, method, minutes and
// difficulty. A manual recipe is stored as typed — no model runs — and
// sets grounding_status manual with its method and minutes; it has no
// photo, and the dinner keeps its difficulty. An invalid f is a
// *ValidationError and an archived dinner ErrArchived. A failure grounding
// a candidate or link wraps ErrGroundFailed; a page that cannot ground the
// dinner is also a *recipes.ErrUngroundable. A dinner renamed, archived or
// grounded some other way while a candidate or link was grounding is
// ErrGroundConflict or ErrArchived. None of these changes the dinner.
// difficulty; one a member changed while it was grounding is kept. A
// manual recipe is stored as typed — no model runs — and sets
// grounding_status manual with its method and minutes; it has no photo,
// and the dinner keeps its difficulty. An invalid f is a *ValidationError
// and an archived dinner ErrArchived. A failure grounding a candidate or
// link, or recording it on the dinner, wraps ErrGroundFailed; a page that
// cannot ground the dinner is also a *recipes.ErrUngroundable. A dinner
// renamed, archived or grounded some other way while a candidate or link
// was grounding is ErrGroundConflict or ErrArchived. None of these changes
// the dinner.
func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string) (Dinner, error) {
if updatedBy == "" {
return Dinner{}, errors.New("dinners: ground: no member to record as updated_by")
@@ -380,27 +382,35 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: store.Now().String(),
ID: cur.ID,
ObservedName: cur.Name,
ObservedNormalizedName: cur.NormalizedName,
ObservedGroundingStatus: cur.GroundingStatus,
ObservedGroundingRevision: cur.GroundingRevision,
ObservedRecipeID: cur.RecipeID,
ObservedMethod: cur.Method,
ObservedMinutesTotal: cur.MinutesTotal,
ObservedDifficulty: cur.Difficulty,
})
if errors.Is(err, sql.ErrNoRows) {
return Dinner{}, s.groundRejected(ctx, id)
}
if err != nil {
return Dinner{}, fmt.Errorf("dinners: ground %s: %w", id, err)
return Dinner{}, fmt.Errorf("%w: dinner %s: record: %w", ErrGroundFailed, id, err)
}
return fromRow(row), nil
}
// groundRejected is the error for dinner id when it was not as observed
// before grounding: ErrNotFound, ErrArchived or ErrGroundConflict.
// before grounding: ErrNotFound, ErrArchived or ErrGroundConflict. Failing
// to read it wraps ErrGroundFailed.
func (s *Service) groundRejected(ctx context.Context, id string) error {
cur, err := s.get(ctx, id)
if err != nil {
if errors.Is(err, ErrNotFound) {
return err
}
if err != nil {
return fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
}
if cur.Status == StatusArchived {
return ErrArchived
}
@@ -486,10 +496,14 @@ func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, u
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: now,
ID: cur.ID,
ObservedName: cur.Name,
ObservedNormalizedName: cur.NormalizedName,
ObservedGroundingStatus: cur.GroundingStatus,
ObservedGroundingRevision: cur.GroundingRevision,
ObservedRecipeID: cur.RecipeID,
ObservedMethod: cur.Method,
ObservedMinutesTotal: cur.MinutesTotal,
ObservedDifficulty: cur.Difficulty,
})
if err != nil {
return fmt.Errorf("dinners: ground %s: %w", id, err)