"German Red Garlic" now links back to the Johnny's page it came from, and pansy knows how much is left.
Two modelling calls, both of which read as omissions unless stated
The plant catalog stays flat. No species→variety hierarchy. A row in your catalog just is the thing you plant; the built-in "Garlic" is a generic starting point you clone. A hierarchy buys taxonomy nobody asked for and complicates every join.
Inventory belongs to a purchase, not to a variety. You might buy the same garlic from two vendors in two years at two prices and two germination rates — quantity columns on plants would collapse all of that into one number and lose it. So lots get their own table, and owner_id sits on the lot rather than being inherited from the plant: you can record buying generic built-in Garlic from Johnny's and the record is still yours alone. Lots are never shared along with a garden.
remaining is derived, never stored
quantity − the summed effective count of the active plantings attributed to the lot. The alternative — decrementing a column when you plant — drifts the moment anything edits or removes a planting behind its back, and once it has drifted there's no way to tell what the true number was. Removing a plop gives the seed back with nothing having to remember to.
The usage query returns raw radius/count/spacing rather than a SUM, because the effective-count formula (explicit count, else π·r²/spacing²) lives in the service. Reproducing it in SQL would guarantee the two drift apart.
source_url is untrusted input, not a tidy string
Validated as http(s) with a host, on both plants and lots. It renders as a clickable link, so that check is load-bearing: without it a stored javascript: URL becomes script execution the moment someone clicks it. Schemeless (//evil.example.com) and relative URLs are refused too — they'd resolve against pansy's own origin, which is never what a vendor link means. There's a test with the whole rejection table.
Attribution is checked, not trusted
A planting's lot must be the actor's and a lot of the plant being planted. Attributing a garlic planting to a tomato lot would silently corrupt every remaining downstream, so it's refused rather than stored — and re-checked on update, since a plant swap can invalidate a previously-valid attribution. Another user's lot is ErrInvalidInput, not a leak of its existence.
Deleting a lot leaves its plantings alone with a null link (ON DELETE SET NULL): the plants really were in the ground, whatever happened to the record of the packet.
Plus sourceUrl/vendor on plants and seedLotId on planting create/update.
Verification
9 new tests, one per acceptance criterion: two lots of one plant tracking independently, remaining returning when a plop is soft-removed, a lot on a built-in plant staying private (ErrNotFound for everyone else, on every verb), the URL rejection table, provenance round-tripping while the 32 built-ins stay untouched, mismatched-lot rejection, planting history surviving lot deletion, and the version guard.
Closes #50. Opens Phase 1 of #58.
"German Red Garlic" now links back to the Johnny's page it came from, and pansy knows how much is left.
### Two modelling calls, both of which read as omissions unless stated
**The plant catalog stays flat.** No species→variety hierarchy. A row in your catalog just *is* the thing you plant; the built-in "Garlic" is a generic starting point you clone. A hierarchy buys taxonomy nobody asked for and complicates every join.
**Inventory belongs to a purchase, not to a variety.** You might buy the same garlic from two vendors in two years at two prices and two germination rates — quantity columns on `plants` would collapse all of that into one number and lose it. So lots get their own table, and **`owner_id` sits on the lot** rather than being inherited from the plant: you can record buying generic *built-in* Garlic from Johnny's and the record is still yours alone. Lots are never shared along with a garden.
### `remaining` is derived, never stored
`quantity` − the summed effective count of the **active** plantings attributed to the lot. The alternative — decrementing a column when you plant — drifts the moment anything edits or removes a planting behind its back, and once it has drifted there's no way to tell what the true number was. Removing a plop gives the seed back with nothing having to remember to.
The usage query returns raw radius/count/spacing rather than a `SUM`, because the effective-count formula (explicit count, else π·r²/spacing²) lives in the service. Reproducing it in SQL would guarantee the two drift apart.
### `source_url` is untrusted input, not a tidy string
Validated as http(s) **with a host**, on both plants and lots. It renders as a clickable link, so that check is load-bearing: without it a stored `javascript:` URL becomes script execution the moment someone clicks it. Schemeless (`//evil.example.com`) and relative URLs are refused too — they'd resolve against pansy's own origin, which is never what a vendor link means. There's a test with the whole rejection table.
### Attribution is checked, not trusted
A planting's lot must be the actor's **and** a lot of the plant being planted. Attributing a garlic planting to a tomato lot would silently corrupt every `remaining` downstream, so it's refused rather than stored — and re-checked on update, since a plant swap can invalidate a previously-valid attribution. Another user's lot is `ErrInvalidInput`, not a leak of its existence.
Deleting a lot leaves its plantings alone with a null link (`ON DELETE SET NULL`): the plants really were in the ground, whatever happened to the record of the packet.
### API
```
GET,POST /seed-lots GET,PATCH,DELETE /seed-lots/:id (own lots only)
```
Plus `sourceUrl`/`vendor` on plants and `seedLotId` on planting create/update.
### Verification
9 new tests, one per acceptance criterion: two lots of one plant tracking independently, `remaining` returning when a plop is soft-removed, a lot on a built-in plant staying private (`ErrNotFound` for everyone else, on every verb), the URL rejection table, provenance round-tripping while the 32 built-ins stay untouched, mismatched-lot rejection, planting history surviving lot deletion, and the version guard.
`go test ./...`, `go vet ./...`, `gofmt` green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
"German Red Garlic" now links back to the Johnny's page it came from, and pansy
knows how much is left.
Two modelling calls, both of which look like omissions unless stated.
The plant catalog stays FLAT — no species/variety hierarchy. A row in your
catalog just is the thing you plant; the built-in "Garlic" is a generic starting
point you clone. A hierarchy buys taxonomy nobody asked for and complicates
every join.
Inventory belongs to a PURCHASE, not to a variety. You might buy the same garlic
from two vendors in two years at two prices and two germination rates; quantity
columns on plants would collapse all of that into one number and lose it. So
lots get their own table, and owner_id sits on the lot rather than being
inherited from the plant — you can buy generic built-in Garlic from Johnny's and
the record is still yours alone. Lots are never shared along with a garden.
`remaining` is derived, never stored: quantity minus the summed effective count
of the active plantings attributed to the lot. The alternative — decrementing a
column when you plant — drifts the moment anything edits or removes a planting
behind its back, and once it has drifted there's no way to tell what the true
number was. Removing a plop gives the seed back with nothing having to remember
to. The usage query returns raw radius/count/spacing rather than a SUM, because
the effective-count formula lives in the service and reproducing it in SQL would
guarantee the two drift apart.
source_url is validated as http(s) with a host, on both plants and lots. It
renders as a clickable link, so that check is load-bearing rather than tidiness:
without it a stored javascript: URL becomes script execution the moment someone
clicks it. Schemeless and relative URLs are refused too — they'd resolve against
pansy's own origin, which is never what a vendor link means.
A planting's lot must be the actor's AND a lot of the plant being planted.
Attributing a garlic planting to a tomato lot would silently corrupt every
remaining count downstream, so it's refused rather than stored, and re-checked
on update in case a plant swap invalidated a previously-valid attribution.
Deleting a lot leaves its plantings alone with a null link: the plants really
were in the ground, whatever happened to the record of the packet.
Closes#50
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
seed_lots.plant_id is ON DELETE CASCADE, so deleting a plant would have silently
destroyed the purchase records attached to it — vendor, cost, germination rate,
gone with no warning. Plantings were already guarded with ErrPlantInUse because
their FK is RESTRICT; lots needed the same guard for the opposite reason, since
their FK would have obliged rather than refused.
Refusing lets the owner delete the lots deliberately if that is really what they
meant, which is the same shape as the existing clones-and-edits path.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
🪰Gadfly consensus review — 21 inline findings on changed lines. See the consensus comment for the full ranked summary.
Advisory only — does not block merge.
<!-- gadfly-inline-review -->
🪰 **Gadfly consensus review** — 21 inline findings on changed lines. See the consensus comment for the full ranked summary.
<sub>Advisory only — does not block merge.</sub>
internal/api/seed_lots.go:60 — seedLotUpdateRequest.Version only declares binding:"required", omitting min=1. The plant (plants.go:49) and planting (plantings.go:76) update requests both enforce binding:"required,min=1". A version of 0 or a negative value therefore passes API validation and reaches the service, where it will fail the SQL version = ? guard and surface as a version conflict instead of an early INVALID_INPUT error. Verified by comparing the struct tags acr…
🪰 Gadfly · advisory
🟠 **seedLotUpdateRequest.Version missing min=1 binding validation**
_error-handling · flagged by 1 model_
- **`internal/api/seed_lots.go:60`** — `seedLotUpdateRequest.Version` only declares `binding:"required"`, omitting `min=1`. The plant (`plants.go:49`) and planting (`plantings.go:76`) update requests both enforce `binding:"required,min=1"`. A version of `0` or a negative value therefore passes API validation and reaches the service, where it will fail the SQL `version = ?` guard and surface as a version conflict instead of an early `INVALID_INPUT` error. Verified by comparing the struct tags acr…
<sub>🪰 Gadfly · advisory</sub>
🟠Generic parseNullable helper trapped in seed_lots.go but used cross-file
maintainability · flagged by 4 models
internal/api/seed_lots.go:86 — The new generic parseNullable[T any] is introduced in seed_lots.go but immediately consumed by plantings.go. This creates a hidden cross-file dependency: a handler in the planting file relies on a helper tucked inside a seed-lot file. The generic is cleaner than the existing parseNullableInt/parseNullableString duplicates, but it should live in a shared API utility file (or at least in a file whose name doesn't imply seed-lot ownership). Fix:…
🪰 Gadfly · advisory
🟠 **Generic parseNullable helper trapped in seed_lots.go but used cross-file**
_maintainability · flagged by 4 models_
- **`internal/api/seed_lots.go:86`** — The new generic `parseNullable[T any]` is introduced in `seed_lots.go` but immediately consumed by `plantings.go`. This creates a hidden cross-file dependency: a handler in the planting file relies on a helper tucked inside a seed-lot file. The generic is cleaner than the existing `parseNullableInt`/`parseNullableString` duplicates, but it should live in a shared API utility file (or at least in a file whose name doesn't imply seed-lot ownership). **Fix:**…
<sub>🪰 Gadfly · advisory</sub>
🔴UpdatePlanting re-validates the owner's lot on every edit, breaking shared editors who touch an existing lot-attributed planting
correctness, error-handling · flagged by 1 model
One real correctness bug, all in internal/service/plantings.go:162:
🪰 Gadfly · advisory
🔴 **UpdatePlanting re-validates the owner's lot on every edit, breaking shared editors who touch an existing lot-attributed planting**
_correctness, error-handling · flagged by 1 model_
One real correctness bug, all in `internal/service/plantings.go:162`:
<sub>🪰 Gadfly · advisory</sub>
internal/service/plants_test.go:187 — Formatting regression: the comment-aligned spacing in the table-driven test was broken (columns no longer align with their // comments), making the test harder to scan. gofmt won't fix comment alignment. Fix: Re-align the comment offsets to restore the readable table layout.
🪰 Gadfly · advisory
🟡 **Broken comment alignment in table-driven test**
_maintainability · flagged by 2 models_
- **`internal/service/plants_test.go:187`** — Formatting regression: the comment-aligned spacing in the table-driven test was broken (columns no longer align with their `//` comments), making the test harder to scan. `gofmt` won't fix comment alignment. **Fix:** Re-align the comment offsets to restore the readable table layout.
<sub>🪰 Gadfly · advisory</sub>
🟠GetSeedLot and UpdateSeedLot fetch all actor plantings to compute remaining for one lot
performance · flagged by 2 models
internal/service/seed_lots.go:93-114 and internal/store/seed_lots.go:159-184 — GetSeedLot and UpdateSeedLot each call fillRemaining for a single lot, but fillRemaining always executes SeedLotUsage(ctx, actorID) which fetches every active planting attributed to any of the actor's lots. For a single-lot read this is unnecessary work: if a user has 50 lots with hundreds of total plantings, computing derivedCount for every planting just to sum one lot's usage is wasteful. *…
🪰 Gadfly · advisory
🟠 **GetSeedLot and UpdateSeedLot fetch all actor plantings to compute remaining for one lot**
_performance · flagged by 2 models_
- `internal/service/seed_lots.go:93-114` and `internal/store/seed_lots.go:159-184` — `GetSeedLot` and `UpdateSeedLot` each call `fillRemaining` for a single lot, but `fillRemaining` always executes `SeedLotUsage(ctx, actorID)` which fetches **every** active planting attributed to **any** of the actor's lots. For a single-lot read this is unnecessary work: if a user has 50 lots with hundreds of total plantings, computing `derivedCount` for every planting just to sum one lot's usage is wasteful. *…
<sub>🪰 Gadfly · advisory</sub>
🟠GetSeedLot/UpdateSeedLot recompute usage over all of the owner's lots instead of the requested one
performance · flagged by 1 model
🪰 Gadfly · advisory
🟠 **GetSeedLot/UpdateSeedLot recompute usage over all of the owner's lots instead of the requested one**
_performance · flagged by 1 model_
<sub>🪰 Gadfly · advisory</sub>
🟠fillRemaining can produce negative Remaining with no guard or docs
error-handling · flagged by 1 model
internal/service/seed_lots.go:111 — Remaining can go negative without guard or documented behaviorfillRemaining does Quantity - Used with no floor. If a user plants more than they bought, or later reduces Quantity below current usage, Remaining becomes negative. The field is described as "how much is left," so a negative value is surprising and could confuse display logic. Fix: Cap at zero (max(0, Quantity - Used)) or, if negative is intentional to show over-commitment,…
🪰 Gadfly · advisory
🟠 **fillRemaining can produce negative Remaining with no guard or docs**
_error-handling · flagged by 1 model_
- **`internal/service/seed_lots.go:111` — `Remaining` can go negative without guard or documented behavior** `fillRemaining` does `Quantity - Used` with no floor. If a user plants more than they bought, or later reduces `Quantity` below current usage, `Remaining` becomes negative. The field is described as "how much is left," so a negative value is surprising and could confuse display logic. **Fix:** Cap at zero (`max(0, Quantity - Used)`) or, if negative is intentional to show over-commitment,…
<sub>🪰 Gadfly · advisory</sub>
⚪Comment // ErrNotFound on ownSeedLot's return is inaccurate for non-NotFound wrapped store errors
maintainability · flagged by 1 model
internal/service/seed_lots.go:122 — misleading inline comment.return nil, err // ErrNotFound asserts the returned error is ErrNotFound, but ownSeedLot returns whatever store.GetSeedLot returns, which includes wrapped non-NotFound errors (the store wraps scan failures in fmt.Errorf("store: get seed lot: %w", err)). The comment is wrong for those paths and could mislead a reader tracing error handling. Verified by reading GetSeedLot (store/seed_lots.go:48-58), which only maps…
🪰 Gadfly · advisory
⚪ **Comment `// ErrNotFound` on ownSeedLot's return is inaccurate for non-NotFound wrapped store errors**
_maintainability · flagged by 1 model_
- **`internal/service/seed_lots.go:122` — misleading inline comment.** `return nil, err // ErrNotFound` asserts the returned error is `ErrNotFound`, but `ownSeedLot` returns whatever `store.GetSeedLot` returns, which includes wrapped non-NotFound errors (the store wraps scan failures in `fmt.Errorf("store: get seed lot: %w", err)`). The comment is wrong for those paths and could mislead a reader tracing error handling. Verified by reading `GetSeedLot` (store/seed_lots.go:48-58), which only maps…
<sub>🪰 Gadfly · advisory</sub>
🟠GetSeedLot/UpdateSeedLot over-fetch all owner's active plantings via unscoped SeedLotUsage to compute one lot's remaining
performance · flagged by 2 models
internal/service/seed_lots.go:131 (GetSeedLot) and :186 (UpdateSeedLot) — single-lot reads over-fetch the entire owner's active plantings. Both paths build a one-element slice and call s.fillRemaining(ctx, actorID, one), which (seed_lots.go:97) calls s.store.SeedLotUsage(ctx, actorID). That store query (internal/store/seed_lots.go:159-166) selects every active planting joined across all the owner's lots (WHERE sl.owner_id = ? AND pl.removed_at IS NULL, no `pl.seed_lot_id…
🪰 Gadfly · advisory
🟠 **GetSeedLot/UpdateSeedLot over-fetch all owner's active plantings via unscoped SeedLotUsage to compute one lot's remaining**
_performance · flagged by 2 models_
- **`internal/service/seed_lots.go:131` (`GetSeedLot`) and `:186` (`UpdateSeedLot`) — single-lot reads over-fetch the entire owner's active plantings.** Both paths build a one-element slice and call `s.fillRemaining(ctx, actorID, one)`, which (seed_lots.go:97) calls `s.store.SeedLotUsage(ctx, actorID)`. That store query (`internal/store/seed_lots.go:159-166`) selects every active planting joined across *all* the owner's lots (`WHERE sl.owner_id = ? AND pl.removed_at IS NULL`, no `pl.seed_lot_id…
<sub>🪰 Gadfly · advisory</sub>
🟠Seed-lot mutations not recorded in history, breaking project invariant
maintainability · flagged by 1 model
internal/service/seed_lots.go:146 — CreateSeedLot never calls s.record(...), and domain.go has no EntitySeedLot constant. CLAUDE.md states as an invariant: "Every service mutation lands in history (#48). If you add one, record it." The existing record helper is garden-scoped, but seed lots are actor-scoped; even so, the PR neither records them nor explains why they are exempt, which leaves the history surface incomplete and breaks a documented project convention. Fix: a…
🪰 Gadfly · advisory
🟠 **Seed-lot mutations not recorded in history, breaking project invariant**
_maintainability · flagged by 1 model_
- **`internal/service/seed_lots.go:146`** — `CreateSeedLot` never calls `s.record(...)`, and `domain.go` has no `EntitySeedLot` constant. `CLAUDE.md` states as an invariant: *"Every service mutation lands in history (#48). If you add one, record it."* The existing `record` helper is garden-scoped, but seed lots are actor-scoped; even so, the PR neither records them nor explains why they are exempt, which leaves the history surface incomplete and breaks a documented project convention. **Fix:** a…
<sub>🪰 Gadfly · advisory</sub>
🔴CreateSeedLot returns a lot with Remaining/Used unset (reports remaining: 0 instead of the purchased quantity)
correctness, error-handling · flagged by 2 models
internal/service/seed_lots.go:168 — CreateSeedLot returns s.store.CreateSeedLot(ctx, l) directly without calling s.fillRemaining. domain.SeedLot.Remaining/Used are computed-only fields, zero-valued at construction, so a freshly created lot is returned with remaining: 0 regardless of the purchased quantity (e.g. buying 100 seeds shows "remaining": 0 in the create response instead of 100). Confirmed no test checks Remaining/Used on the CreateSeedLot return value — `seed_lot…
🪰 Gadfly · advisory
🔴 **CreateSeedLot returns a lot with Remaining/Used unset (reports remaining: 0 instead of the purchased quantity)**
_correctness, error-handling · flagged by 2 models_
- `internal/service/seed_lots.go:168` — `CreateSeedLot` returns `s.store.CreateSeedLot(ctx, l)` directly without calling `s.fillRemaining`. `domain.SeedLot.Remaining`/`Used` are computed-only fields, zero-valued at construction, so a freshly created lot is returned with `remaining: 0` regardless of the purchased quantity (e.g. buying 100 seeds shows `"remaining": 0` in the create response instead of 100). Confirmed no test checks `Remaining`/`Used` on the `CreateSeedLot` return value — `seed_lot…
<sub>🪰 Gadfly · advisory</sub>
🔴CreateSeedLot returns lot without computed Used/Remaining fields
correctness, maintainability · flagged by 2 models
internal/service/seed_lots.go:175 — CreateSeedLot returns the newly created lot without calling fillRemaining, so Used and Remaining are both zero in the 201 Created response. A client that reads remaining immediately after creation sees 0 instead of the lot's actual Quantity. The existing pattern in CreatePlanting (created.DerivedCount = …) shows write endpoints should return complete, derived fields.
🪰 Gadfly · advisory
🔴 **CreateSeedLot returns lot without computed Used/Remaining fields**
_correctness, maintainability · flagged by 2 models_
* **`internal/service/seed_lots.go:175`** — `CreateSeedLot` returns the newly created lot without calling `fillRemaining`, so `Used` and `Remaining` are both zero in the `201 Created` response. A client that reads `remaining` immediately after creation sees `0` instead of the lot's actual `Quantity`. The existing pattern in `CreatePlanting` (`created.DerivedCount = …`) shows write endpoints should return complete, derived fields.
<sub>🪰 Gadfly · advisory</sub>
🔴UpdateSeedLot version conflict omits Used/Remaining computation
correctness, error-handling · flagged by 3 models
internal/service/seed_lots.go:182-184 — UpdateSeedLot returns a version-conflict response without computing Used/Remaining. When the store returns (currentRow, ErrVersionConflict), the service immediately returns updated, err and skips fillRemaining. The conflict response therefore leaves Used=0 and Remaining=0 (zero values) instead of the derived amounts. This is inconsistent with GetSeedLot and ListSeedLots, which always populate the fields, and the API handler (`in…
🪰 Gadfly · advisory
🔴 **UpdateSeedLot version conflict omits Used/Remaining computation**
_correctness, error-handling · flagged by 3 models_
- **`internal/service/seed_lots.go:182-184`** — `UpdateSeedLot` returns a version-conflict response without computing `Used`/`Remaining`. When the store returns `(currentRow, ErrVersionConflict)`, the service immediately returns `updated, err` and skips `fillRemaining`. The conflict response therefore leaves `Used=0` and `Remaining=0` (zero values) instead of the derived amounts. This is inconsistent with `GetSeedLot` and `ListSeedLots`, which always populate the fields, and the API handler (`in…
<sub>🪰 Gadfly · advisory</sub>
🔴UpdateSeedLot version conflict returns stale Used/Remaining zeros
correctness · flagged by 1 model
internal/service/seed_lots.go:190 — UpdateSeedLot returns the version-conflict current row (return updated, err) without ever running fillRemaining. The 409 Conflict payload therefore serializes Used: 0 and Remaining: 0, which is stale and misleading. UpdatePlanting already solves the same problem by calling enrichDerived on the conflict row before returning; UpdateSeedLot should do the equivalent for Used/Remaining.
🪰 Gadfly · advisory
🔴 **UpdateSeedLot version conflict returns stale Used/Remaining zeros**
_correctness · flagged by 1 model_
* **`internal/service/seed_lots.go:190`** — `UpdateSeedLot` returns the version-conflict current row (`return updated, err`) without ever running `fillRemaining`. The `409 Conflict` payload therefore serializes `Used: 0` and `Remaining: 0`, which is stale and misleading. `UpdatePlanting` already solves the same problem by calling `enrichDerived` on the conflict row before returning; `UpdateSeedLot` should do the equivalent for `Used`/`Remaining`.
<sub>🪰 Gadfly · advisory</sub>
🟠Seed-lot mutations not recorded in history, breaking project invariant
maintainability · flagged by 1 model
internal/service/seed_lots.go:196 — DeleteSeedLot never calls s.record(...), and domain.go has no EntitySeedLot constant. CLAUDE.md states as an invariant: "Every service mutation lands in history (#48). If you add one, record it." The existing record helper is garden-scoped, but seed lots are actor-scoped; even so, the PR neither records them nor explains why they are exempt, which leaves the history surface incomplete and breaks a documented project convention. Fix: a…
🪰 Gadfly · advisory
🟠 **Seed-lot mutations not recorded in history, breaking project invariant**
_maintainability · flagged by 1 model_
- **`internal/service/seed_lots.go:196`** — `DeleteSeedLot` never calls `s.record(...)`, and `domain.go` has no `EntitySeedLot` constant. `CLAUDE.md` states as an invariant: *"Every service mutation lands in history (#48). If you add one, record it."* The existing `record` helper is garden-scoped, but seed lots are actor-scoped; even so, the PR neither records them nor explains why they are exempt, which leaves the history surface incomplete and breaks a documented project convention. **Fix:** a…
<sub>🪰 Gadfly · advisory</sub>
🟠No check prevents attributing more plantings to a lot than its quantity, yielding negative Remaining
error-handling · flagged by 1 model
internal/service/seed_lots.go:207 (seedLotForPlanting) / :111 (fillRemaining): There is no check that a lot has enough quantity left before attributing a planting to it. seedLotForPlanting validates only ownership and plant-match, and fillRemaining computes Remaining = Quantity - Used unconditionally. A user can attribute 200 plants to a 50-seed lot (or plant 100, then add 100 more) and the lot silently reports a negative remaining. For an inventory feature this is an unhandled…
🪰 Gadfly · advisory
🟠 **No check prevents attributing more plantings to a lot than its quantity, yielding negative Remaining**
_error-handling · flagged by 1 model_
- `internal/service/seed_lots.go:207` (`seedLotForPlanting`) / `:111` (`fillRemaining`): There is no check that a lot has enough quantity left before attributing a planting to it. `seedLotForPlanting` validates only ownership and plant-match, and `fillRemaining` computes `Remaining = Quantity - Used` unconditionally. A user can attribute 200 plants to a 50-seed lot (or plant 100, then add 100 more) and the lot silently reports a negative `remaining`. For an inventory feature this is an unhandled…
<sub>🪰 Gadfly · advisory</sub>
🟡seedLotForPlanting name implies getter, not validation
maintainability · flagged by 1 model
internal/service/seed_lots.go:213 — seedLotForPlanting is named like a getter/fetcher but actually performs validation and returns only an error. The name is confusing when scanning call sites. The comment explains the intent ("validates a planting's lot attribution"), but the function name doesn't. Fix: Rename to validateSeedLotForPlanting or checkSeedLotAttribution to match its boolean/validation nature and align with other helpers in the package.
🪰 Gadfly · advisory
🟡 **seedLotForPlanting name implies getter, not validation**
_maintainability · flagged by 1 model_
- **`internal/service/seed_lots.go:213`** — `seedLotForPlanting` is named like a getter/fetcher but actually performs validation and returns only an error. The name is confusing when scanning call sites. The comment explains the intent ("validates a planting's lot attribution"), but the function name doesn't. **Fix:** Rename to `validateSeedLotForPlanting` or `checkSeedLotAttribution` to match its boolean/validation nature and align with other helpers in the package.
<sub>🪰 Gadfly · advisory</sub>
🟡Duplicate intPtr test helper when ptrInt already exists in same package
maintainability · flagged by 3 models
internal/service/seed_lots_test.go:345 — func intPtr(v int) *int duplicates ptrInt in plants_test.go:12 (same package, different name). Having two names for the same helper is needless churn for future readers. Fix: delete intPtr and use ptrInt everywhere in the package.
🪰 Gadfly · advisory
🟡 **Duplicate intPtr test helper when ptrInt already exists in same package**
_maintainability · flagged by 3 models_
- **`internal/service/seed_lots_test.go:345`** — `func intPtr(v int) *int` duplicates `ptrInt` in `plants_test.go:12` (same package, different name). Having two names for the same helper is needless churn for future readers. **Fix:** delete `intPtr` and use `ptrInt` everywhere in the package.
<sub>🪰 Gadfly · advisory</sub>
🔴ListSeedLotsForOwner is unbounded: no LIMIT on query
performance · flagged by 2 models
internal/store/seed_lots.go:63 — ListSeedLotsForOwner issues an unbounded SELECT with no LIMIT. Every other list endpoint in the codebase (gardens, plants, shares) carries a defensive cap (e.g. maxGardensListed = 1000, maxPlantsListed = 5000). A gardener who records seed purchases year-over-year will see response size and memory use grow without bound. Fix: add a maxSeedLotsListed constant and LIMIT ? to the query.
🪰 Gadfly · advisory
🔴 **ListSeedLotsForOwner is unbounded: no LIMIT on query**
_performance · flagged by 2 models_
* **`internal/store/seed_lots.go:63`** — `ListSeedLotsForOwner` issues an unbounded `SELECT` with no `LIMIT`. Every other list endpoint in the codebase (`gardens`, `plants`, `shares`) carries a defensive cap (e.g. `maxGardensListed = 1000`, `maxPlantsListed = 5000`). A gardener who records seed purchases year-over-year will see response size and memory use grow without bound. **Fix:** add a `maxSeedLotsListed` constant and `LIMIT ?` to the query.
<sub>🪰 Gadfly · advisory</sub>
🟠ListSeedLotsForOwner has no LIMIT cap, unlike ListPlantsForActor
performance · flagged by 2 models
internal/store/seed_lots.go:69-91 — ListSeedLotsForOwner returns an unbounded slice with no LIMIT or pagination cap. The analogous ListPlantsForActor in the same store has a maxPlantsListed = 5000 defensive backstop (internal/store/plants.go:62), but the seed-lots list query lacks any equivalent ceiling. A user who records many purchases over multiple years could cause unbounded memory allocation in both the DB and the service. Suggested fix: Add a LIMIT constant (and a note th…
🪰 Gadfly · advisory
🟠 **ListSeedLotsForOwner has no LIMIT cap, unlike ListPlantsForActor**
_performance · flagged by 2 models_
- `internal/store/seed_lots.go:69-91` — `ListSeedLotsForOwner` returns an unbounded slice with no LIMIT or pagination cap. The analogous `ListPlantsForActor` in the same store has a `maxPlantsListed = 5000` defensive backstop (`internal/store/plants.go:62`), but the seed-lots list query lacks any equivalent ceiling. A user who records many purchases over multiple years could cause unbounded memory allocation in both the DB and the service. **Suggested fix:** Add a `LIMIT` constant (and a note th…
<sub>🪰 Gadfly · advisory</sub>
🟡Concurrent lot deletion during update returns underlying error, not ErrVersionConflict, so handler mis-maps it
error-handling · flagged by 1 model
internal/store/seed_lots.go:118 (UpdateSeedLot conflict path): When the version-guarded update hits sql.ErrNoRows, it re-reads via GetSeedLot; if that fails (e.g. the lot was deleted concurrently), it returns (nil, gerr) rather than ErrVersionConflict. The handler at internal/api/seed_lots.go:162 only special-cases ErrVersionConflict, so a concurrent-delete-during-update surfaces as a generic service error instead of a clean 404/conflict. Minor (concurrency race), but the err…
🪰 Gadfly · advisory
🟡 **Concurrent lot deletion during update returns underlying error, not ErrVersionConflict, so handler mis-maps it**
_error-handling · flagged by 1 model_
- `internal/store/seed_lots.go:118` (`UpdateSeedLot` conflict path): When the version-guarded update hits `sql.ErrNoRows`, it re-reads via `GetSeedLot`; if *that* fails (e.g. the lot was deleted concurrently), it returns `(nil, gerr)` rather than `ErrVersionConflict`. The handler at `internal/api/seed_lots.go:162` only special-cases `ErrVersionConflict`, so a concurrent-delete-during-update surfaces as a generic service error instead of a clean 404/conflict. Minor (concurrency race), but the err…
<sub>🪰 Gadfly · advisory</sub>
I traced the security-relevant surface of this PR — authz on seed lots, the new source_url validation, and the planting-attribution checks — against the actual code (not just the diff).
What I verified:
Ownership scoping: ownSeedLot (internal/service/seed_lots.go:64) checks l.OwnerID != actorID and returns ErrNotFound (not ErrForbidden), consistent with the codebase's existence-masking convention. All handlers (Get/Update/Delete/list) route through it. Confirmed by reading internal/api/seed_lots.go and the store layer — every route is behind h.requireAuth() (internal/api/api.go:127).
Store queries are parameterized throughout internal/store/seed_lots.go — no string-built SQL, no injection surface. SeedLotUsage correctly scopes to sl.owner_id = ? via an inner join, so a user can never pull another owner's lot usage.
Plant-attribution IDOR check: seedLotForPlanting (internal/service/seed_lots.go:172) requires the lot to belong to the actor and match the plant, and is re-run on every UpdatePlanting, not just when the lot field changes — confirmed at internal/service/plantings.go:110 and :162. Another user's lot ID collapses to ErrInvalidInput via ownSeedLot's ErrNotFound, not a distinguishable "forbidden," so it doesn't leak the lot's existence.
source_url validation (validSourceURL, internal/service/seed_lots.go:296) requires http/https scheme and a non-empty host, rejecting javascript:, data:, schemeless (//host), and relative URLs — this is the right check for the stated threat (stored XSS via a rendered link). I confirmed Go's net/url.Parse lowercases the scheme, so HTTP:// isn't a bypass (it just normalizes to the already-allowed http), and opaque forms like http:evil.com leave Host empty and get rejected. Applied identically to both plants.source_url (internal/service/plants.go:205) and seed_lots.source_url.
No server-side fetch of source_url/vendor anywhere in the codebase (grepped internal/) — it's stored and returned verbatim, so there's no SSRF surface from this field.
Mass-assignment: SeedLotPatch has no PlantID/OwnerID fields and applySeedLotPatch never touches them — a lot can't be re-pointed at another plant or reassigned to another owner via PATCH.
Immutable-column integrity: the migration's plant_id/owner_id REFERENCES ... ON DELETE CASCADE on seed_lots, and seed_lot_id ... ON DELETE SET NULL on plantings, match the stated design and don't create a cross-tenant cascade (a plant's owner_id gates who can delete it in the first place).
Agent tools (internal/agent/tools.go) don't yet expose seed lots at all, so there's no bypass of these checks via that path.
Nothing else in this diff touches authn, secrets, or deserialization. The security-relevant design choices called out in the PR description (URL scheme+host validation, ownership masking, re-checked attribution) are implemented as described.
🎯 Correctness — Blocking issues found
Confirms the draft's claim that no test checks the create-response's Remaining/Used field directly. Both findings hold up. Final review:
VERDICT: Blocking issues found
internal/service/seed_lots.go:168 — CreateSeedLot returns s.store.CreateSeedLot(ctx, l) directly without calling s.fillRemaining. domain.SeedLot.Remaining/Used are computed-only fields, zero-valued at construction, so a freshly created lot is returned with remaining: 0 regardless of the purchased quantity (e.g. buying 100 seeds shows "remaining": 0 in the create response instead of 100). Confirmed no test checks Remaining/Used on the CreateSeedLot return value — seed_lots_test.go only checks these fields via subsequent GetSeedLot/ListSeedLots calls. Fix: call s.fillRemaining on the created row before returning, mirroring GetSeedLot/ListSeedLots/the success path of UpdateSeedLot.
internal/service/seed_lots.go:182-184 — On a version conflict, UpdateSeedLot returns the store's current row (updated, err) without filling Remaining/Used, so the row embedded in the 409 response's "current" field reports remaining: 0/used: 0 even when real usage/remaining exists. Confirmed the store (internal/store/seed_lots.go:112-118) does return a real, non-nil current row alongside domain.ErrVersionConflict. This is inconsistent with the established pattern: UpdatePlanting (internal/service/plantings.go:167-173) explicitly calls s.enrichDerived(ctx, updated) on ErrVersionConflict before returning the current row for exactly this reason. Fix: call s.fillRemaining (best-effort) on the conflict path too.
Both bugs share the same root cause — a derived-field enrichment step skipped on two return paths — and both undermine the PR's headline feature of reporting remaining seed-lot quantity.
🧹 Code cleanliness & maintainability — Minor issues
Review
VERDICT: Minor issues
internal/service/seed_lots_test.go:345 defines func intPtr(v int) *int { return &v }, but internal/service/plants_test.go:12 already declares an identical helper ptrInt(n int) *int { return &n } in the same package service. Confirmed both files are package service, both bodies are byte-identical, and intPtr is used at lines 56 and 60 of seed_lots_test.go where ptrInt would work just as well. Needless duplicate helper in one package. Fix: drop intPtr and use the existing ptrInt.
Everything else in-lens is solid: seed_lots.go (store and service) closely mirrors the existing plants.go/plantings.go patterns, parseNullable[T] in internal/api/seed_lots.go is a reasonable small generic to de-duplicate the four nullable-patch fields rather than repeating the pattern by hand, and the effective-count formula is correctly reused from derivedCount in plantings.go rather than re-implemented.
(The second draft finding — that the plants_test.go:186-195 hunk is a gratuitous whitespace-only realignment violating the project's "leave this file alone" convention — did not survive verification. Manually recomputing the code-content length of each entry in the bad table shows the padding before each // comment is consistent with standard gofmt tabwriter alignment: line 192's code content is the same length as lines 187/189 (the dropped emoji rune in Icon: "" is offset by the added x in Name: "x"), and its padding matches theirs exactly — it is not "pushed further right despite shorter content" as claimed. With that supporting evidence contradicted, and no diff for this file reachable in this sandbox to confirm a hunk exists here at all, the finding was dropped.)
⚡ Performance — Minor issues
Verified against internal/service/seed_lots.go and internal/store/seed_lots.go: fillRemaining (lines 93-114) unconditionally calls s.store.SeedLotUsage(ctx, actorID), whose SQL (internal/store/seed_lots.go:159-166) joins plantings ⋈ seed_lots ⋈ plants filtered only by sl.owner_id = ?, with no lot_id predicate. GetSeedLot (lines 131-141) and UpdateSeedLot (lines 172-190) both pass a one-element slice into fillRemaining and discard all but the matching LotID. The finding holds.
VERDICT: Minor issues
internal/service/seed_lots.go:93-114,131-141,178 (fillRemaining used by GetSeedLot/UpdateSeedLot) — every single-lot read or update re-fetches and aggregates all of the owner's active seed-lot-attributed plantings across every lot they've ever recorded, just to pull out one lot's usage. GetSeedLot builds a one-element slice (one := []domain.SeedLot{*l}) and passes it to fillRemaining, but fillRemaining ignores which lots were asked for and calls s.store.SeedLotUsage(ctx, actorID) (internal/store/seed_lots.go:159), an unfiltered join across plantings ⋈ seed_lots ⋈ plants scoped only by owner_id, then discards everything except the one matching LotID (internal/service/seed_lots.go:101-111). UpdateSeedLot (internal/service/seed_lots.go:178) does the same for its single updated row. Since the PR's own selling point is that lots accumulate over years ("the same garlic from two vendors in two years"), this means viewing or editing any one lot gets slower as the user's total inventory history grows, rather than scaling with that lot's own plantings. A cheap fix: add a store method that scopes SeedLotUsage by lot_id IN (...) (or a single lot_id = ? variant) and have fillRemaining pass the requested lot IDs through, keeping the unfiltered form only for ListSeedLots, which legitimately needs all of them at once.
Everything else in this lens is clean: ListSeedLotsForOwner/SeedLotUsage are appropriately indexed (idx_seed_lots_owner, partial idx_plantings_seed_lot, both confirmed in internal/store/migrations/0007_seed_lots.sql) and used correctly for the list case; seedLotForPlanting and visiblePlant are single indexed lookups with no loops; there's no N+1 pattern in create/update paths.
🧯 Error handling & edge cases — Blocking issues found
Line numbers match the draft exactly (168 for CreateSeedLot's return, 182-184 for the update conflict path). Both findings are fully confirmed against the actual source.
VERDICT: Blocking issues found
internal/service/seed_lots.go:168 — CreateSeedLot returns s.store.CreateSeedLot(ctx, l) directly, never calling fillRemaining. domain.SeedLot.Used/Remaining (internal/domain/domain.go) are plain zero-valued fields with no default computation, and store.CreateSeedLot/scanSeedLot never touch them. Every freshly created lot is serialized back to the client with remaining: 0 instead of Quantity, even though nothing has been planted against it yet — wrong on the very first read of every lot's life. GetSeedLot, UpdateSeedLot (success path), and ListSeedLots all correctly call fillRemaining; CreateSeedLot is the one path that skips it.
Fix: call s.fillRemaining(ctx, actorID, []domain.SeedLot{*created}) (or set Remaining = Quantity directly) before returning.
internal/service/seed_lots.go:182-184 — On a version conflict, store.UpdateSeedLot (internal/store/seed_lots.go:98-123) returns (current, domain.ErrVersionConflict), and the service does return updated, err, skipping fillRemaining. That row is exactly what writeVersionConflict(c, lot) (internal/api/seed_lots.go:163-166, using internal/api/errors.go:65-70) sends to the client as "current" in the 409 body — so a client trying to show "you have N left, still want to save?" during conflict resolution sees used: 0, remaining: 0 regardless of the lot's true state.
Fix: fill Used/Remaining on the conflict row too, e.g. move the fillRemaining call before returning in the err != nil branch, or call it in both branches.
glm-5.2:cloud (ollama-cloud) — Blocking issues found
Through the security lens, this change is well-constructed. I verified the key paths by reading the actual checked-out code:
validSourceURL (internal/service/seed_lots.go:299-313) is the load-bearing XSS control, and it is correct: empty allowed, length-capped, url.Parse + Scheme ∈ {http,https} + Host != "". It rejects javascript:, data:, file:, schemeless //evil, relative /seeds, and https:// (no host) — matching the test table. It is enforced on both lots (finalizeSeedLot:271) and plants (finalizePlant:206), for create and update.
Authorization is owner-scoped everywhere: routes sit behind requireAuth() (internal/api/api.go:129); ownSeedLot masks existence via ErrNotFound on a non-owner lot (seed_lots.go:124); ListSeedLotsForOwner and SeedLotUsage both filter WHERE owner_id = ? with a parameterized placeholder (internal/store/seed_lots.go:64,165). No SQL injection surface — all queries use ? placeholders.
Attribution integrity (seedLotForPlanting:207-219) requires the lot be the actor's and a lot of the plant being planted, and is re-run on every update (plantings.go:162) so a plant swap can't leave a stale cross-variety attribution. Another user's lot → ErrInvalidInput, no existence leak.
Input validation in finalizeSeedLot (unit whitelist, finite/non-negative/capped quantity, text length caps, year range, non-negative cost, germination 0–100) and the plantId query param parse (api/seed_lots.go:103-108, id < 1 rejected) are sound.
No cross-user provenance leak: a user's own plants (with sourceUrl) are only visible to themselves; built-ins carry DEFAULT '' from the migration and the test confirms they stay clean.
One low-confidence observation (not rising to a reported finding): url.Parse is permissive vs. browser URL parsers, so a value with unusual host punctuation could in principle parse as valid here yet be interpreted differently by a browser if/when the frontend renders sourceUrl as a link. The web/ tree does not currently consume sourceUrl/seedLotId at all (grep found no matches), so there is no live rendering surface today; and React escapes attributes by default. I could not confirm any concrete bypass, so I'm not elevating this to a finding.
🎯 Correctness — Blocking issues found
VERDICT: Blocking issues found
internal/store/gardens.go:237 — garden copy double-counts seed-lot usage.CopyGarden's copy loop re-inserts each active planting via plantingInsertArgs(objectID, &p), and plantingInsertArgs (internal/store/plantings.go:163) now passes p.SeedLotID straight through. The cloned planting therefore keeps the source planting's seed_lot_id, pointing at the same lot. SeedLotUsage (internal/store/seed_lots.go:159) sums the effective count of all ACTIVE plantings (removed_at IS NULL) attributed to a lot, and fillRemaining (internal/service/seed_lots.go:93-114) rolls that sum into Used/Remaining. So copying a garden makes the lot's Used jump by the copy's effective count and Remaining drop — even though no new seed was planted. This contradicts the PR's own invariant in DESIGN.md ("Lots are never shared along with a garden") and silently corrupts the inventory number the feature exists to report. Fix: null out SeedLotID on the cloned planting in the copy loop (a copy didn't consume seed), or otherwise decide and document the intended semantics.
internal/service/seed_lots.go:182-185 — version-conflict row returned without Used/Remaining. On a store-level ErrVersionConflict, UpdateSeedLot returns (updated, err) at line 184, where updated is the row from GetSeedLot → scanSeedLot (internal/store/seed_lots.go:48-58,17-27), which does not touch Used/Remaining, leaving them at their zero values — the fillRemaining call at line 187 is skipped on the error path. The API handler (internal/api/seed_lots.go:160-164) then hands that row to writeVersionConflict (internal/api/errors.go:65), so the conflict payload's current lot reports used=0, remaining=0. Minor: the "current row" the conflict contract promises to carry is misleading for this resource. Fix: compute Used/Remaining on the conflict path too before returning.
🧹 Code cleanliness & maintainability — Minor issues
All three findings are confirmed against the actual code.
parseNullable[T] (seed_lots.go:86), parseNullableString (objects.go:93), and parseNullableInt (plants.go:69) all coexist with identical absent/null/value semantics; plantings.go:toPatch mixes all three (lines 54, 58, 62, 66, 70). Confirmed.
plants.go:201 validates p.Vendor against maxLotTextLen, which is defined only in seed_lots.go:18 and named for lots. Confirmed via grep (3 references, all elsewhere are lot code).
seed_lots.go:122 comment // ErrNotFound on ownSeedLot's return of store.GetSeedLot's error is inaccurate: GetSeedLot (store/seed_lots.go:48-58) maps only sql.ErrNoRows to ErrNotFound and wraps everything else as store: get seed lot: %w. Confirmed.
Verdict: Minor issues
internal/api/seed_lots.go:86 (and plantings.go:66) — three nullable-parsers where one generic suffices. The PR introduces a generic parseNullable[T] that is strictly more general than the pre-existing parseNullableString (objects.go:93) and parseNullableInt (plants.go:69) — same (value, present, err) semantics, same absent/null/value handling. Rather than consolidate, the change adds a third parser and even mixes all three within a single function (plantings.go:53-73 uses parseNullableInt, parseNullableString, and parseNullable[int64] side by side). Readability/maintenance smell: future readers must learn three near-identical helpers and decide which to use. Suggested fix: replace parseNullableString/parseNullableInt with parseNullable[string]/parseNullable[int] (or keep thin deprecating wrappers) for one canonical helper. Verified by reading all three definitions and the toPatch call sites.
internal/service/plants.go:201 — finalizePlant reaches across files into a lot constant. Plant validation uses maxLotTextLen (defined in seed_lots.go:18) to bound p.Vendor. A plant-domain validation function depending on a constant named for lots (and defined in another file) is a leaky, surprising coupling — a maintainer touching lot bounds won't expect plant validation to depend on them, and the naming is actively misleading ("this is a lot limit" used for a plant field). Verified by grep: maxLotTextLen only lives in seed_lots.go (definition + lot-side validation). Suggested fix: add a plant-side constant (e.g. maxPlantVendorLen = 200 in plants.go) or hoist a shared maxShortTextLen.
internal/service/seed_lots.go:122 — misleading inline comment.return nil, err // ErrNotFound asserts the returned error is ErrNotFound, but ownSeedLot returns whatever store.GetSeedLot returns, which includes wrapped non-NotFound errors (the store wraps scan failures in fmt.Errorf("store: get seed lot: %w", err)). The comment is wrong for those paths and could mislead a reader tracing error handling. Verified by reading GetSeedLot (store/seed_lots.go:48-58), which only maps sql.ErrNoRows to ErrNotFound and wraps everything else. Suggested fix: drop the // ErrNotFound annotation or rewrite to "GetSeedLot already maps missing → ErrNotFound".
These are minor maintainability nits; no blocking cleanliness problems.
⚡ Performance — Minor issues
Both findings are confirmed against the actual code.
Verdict: Minor issues
internal/service/seed_lots.go:131 (GetSeedLot) and :186 (UpdateSeedLot) — single-lot reads over-fetch the entire owner's active plantings. Both paths build a one-element slice and call s.fillRemaining(ctx, actorID, one), which (seed_lots.go:97) calls s.store.SeedLotUsage(ctx, actorID). That store query (internal/store/seed_lots.go:159-166) selects every active planting joined across all the owner's lots (WHERE sl.owner_id = ? AND pl.removed_at IS NULL, no pl.seed_lot_id = ? filter, no LIMIT), then the service sums the whole result into a map just to read used[lotID]. A GET /seed-lots/:id or PATCH /seed-lots/:id is therefore O(total active plantings owned by the actor) instead of O(plantings of that one lot). A repo-wide grep confirms SeedLotUsage takes only ownerID and no lot-scoped variant exists. Fix: add an optional lot filter (or a SeedLotUsageForLot) and narrow the WHERE to pl.seed_lot_id = ? for the single-lot paths; leave the all-lots query for ListSeedLots, where the full scan is actually needed.
internal/store/seed_lots.go:69 — ListSeedLotsForOwner has no defensive LIMIT, unlike every sibling list query. The query (SELECT ... FROM seed_lots WHERE owner_id = ? [AND plant_id = ?] ORDER BY ..., lines 64-71) returns the entire unbounded set, and fillRemaining then runs a second unbounded query over all the owner's active plantings on top of it. The siblings all cap: ListPlantsForActor uses maxPlantsListed = 5000 (plants.go:62/74), ListSharesForGarden uses maxSharesListed = 1000 (shares.go:45/56), and ListGardensForActor uses maxGardensListed (gardens.go:93). Lots accumulate year over year, so this grows without bound. Fix: add a maxLotsListed constant and LIMIT ?, matching the established pattern.
🧯 Error handling & edge cases — Minor issues
All three findings are confirmed by the code.
Finding 1: seedLotForPlanting (line 207) validates only ownership and plant-match — no quantity check. fillRemaining (line 111) computes Remaining = Quantity - Used unconditionally, allowing negative stock. Confirmed.
Finding 2: UpdateSeedLot (line 182-189): after a successful store.UpdateSeedLot, a fillRemaining failure returns nil, ferr, discarding the already-persisted updated row. The DB write is committed but the caller receives an error and nil lot. Confirmed.
Finding 3: UpdateSeedLot store (line 112-117): on sql.ErrNoRows it calls GetSeedLot; if that fails it returns (nil, gerr) rather than ErrVersionConflict. The handler at seed_lots.go:162 only special-cases ErrVersionConflict. A concurrent-delete-during-update surfaces as a generic service error. Confirmed.
Verdict: Minor issues
internal/service/seed_lots.go:207 (seedLotForPlanting) / :111 (fillRemaining): There is no check that a lot has enough quantity left before attributing a planting to it. seedLotForPlanting validates only ownership and plant-match, and fillRemaining computes Remaining = Quantity - Used unconditionally. A user can attribute 200 plants to a 50-seed lot (or plant 100, then add 100 more) and the lot silently reports a negative remaining. For an inventory feature this is an unhandled boundary: negative stock is nonsensical, and nothing rejects or flags it. Suggested fix: in CreatePlanting/UpdatePlanting, after seedLotForPlanting, compute the lot's current Used and reject (or warn) when Used + effectiveCount > Quantity.
internal/service/seed_lots.go:186 (UpdateSeedLot): After a successfuls.store.UpdateSeedLot, a subsequent fillRemaining failure returns nil, ferr, discarding the already-persisted updated row. The update is committed to the DB but the caller receives an error and a nil lot — a succeeded operation reported as failure. Suggested fix: return updated (with Used/Remaining left at zero) rather than nil when only the derived-field enrichment fails.
internal/store/seed_lots.go:118 (UpdateSeedLot conflict path): When the version-guarded update hits sql.ErrNoRows, it re-reads via GetSeedLot; if that fails (e.g. the lot was deleted concurrently), it returns (nil, gerr) rather than ErrVersionConflict. The handler at internal/api/seed_lots.go:162 only special-cases ErrVersionConflict, so a concurrent-delete-during-update surfaces as a generic service error instead of a clean 404/conflict. Minor (concurrency race), but the error type is not what the handler expects.
kimi-k2.6:cloud (ollama-cloud) — Blocking issues found
Through the security lens, the PR's controls are sound:
validSourceURL (internal/service/seed_lots.go:299) is load-bearing XSS prevention and correctly rejects javascript:, data:, file:, schemeless (//…), relative (/…), and host-less (https://) URLs. It is applied to both Plant.SourceURL and SeedLot.SourceURL before storage.
Attribution authorization (internal/service/seed_lots.go:207) enforces that a planting's lot belongs to the actor and matches the planted variety, returning ErrInvalidInput (not ErrNotFound) so existence stays masked.
Seed-lot isolation (internal/service/seed_lots.go:119) returns ErrNotFound for cross-user access, consistently masking existence across GET/PATCH/DELETE.
SQL is fully parameterized throughout; no injection surface.
ON DELETE SET NULL on plantings.seed_lot_id prevents history destruction when a lot is deleted.
Built-in immutability is preserved: writablePlant returns ErrForbidden for built-ins, so the new provenance columns on seeded plants cannot be altered.
Nothing in the change introduces an authn/authz gap, injection vector, or unsafe handling of untrusted data.
🎯 Correctness — Blocking issues found
Blocking issues found
internal/service/seed_lots.go:175 — CreateSeedLot returns the newly created lot without calling fillRemaining, so Used and Remaining are both zero in the 201 Created response. A client that reads remaining immediately after creation sees 0 instead of the lot's actual Quantity. The existing pattern in CreatePlanting (created.DerivedCount = …) shows write endpoints should return complete, derived fields.
internal/service/seed_lots.go:190 — UpdateSeedLot returns the version-conflict current row (return updated, err) without ever running fillRemaining. The 409 Conflict payload therefore serializes Used: 0 and Remaining: 0, which is stale and misleading. UpdatePlanting already solves the same problem by calling enrichDerived on the conflict row before returning; UpdateSeedLot should do the equivalent for Used/Remaining.
🧹 Code cleanliness & maintainability — Minor issues
Verdict: Minor issues
internal/api/seed_lots.go:86 — The new generic parseNullable[T any] is introduced in seed_lots.go but immediately consumed by plantings.go. This creates a hidden cross-file dependency: a handler in the planting file relies on a helper tucked inside a seed-lot file. The generic is cleaner than the existing parseNullableInt/parseNullableString duplicates, but it should live in a shared API utility file (or at least in a file whose name doesn't imply seed-lot ownership). Fix: Move parseNullable[T any] to api.go or a new api/util.go so its scope matches its consumers.
internal/service/seed_lots.go:213 — seedLotForPlanting is named like a getter/fetcher but actually performs validation and returns only an error. The name is confusing when scanning call sites. The comment explains the intent ("validates a planting's lot attribution"), but the function name doesn't. Fix: Rename to validateSeedLotForPlanting or checkSeedLotAttribution to match its boolean/validation nature and align with other helpers in the package.
internal/service/plants_test.go:187 — Formatting regression: the comment-aligned spacing in the table-driven test was broken (columns no longer align with their // comments), making the test harder to scan. gofmt won't fix comment alignment. Fix: Re-align the comment offsets to restore the readable table layout.
⚡ Performance — Minor issues
Minor issues
internal/store/seed_lots.go:69-91 — ListSeedLotsForOwner returns an unbounded slice with no LIMIT or pagination cap. The analogous ListPlantsForActor in the same store has a maxPlantsListed = 5000 defensive backstop (internal/store/plants.go:62), but the seed-lots list query lacks any equivalent ceiling. A user who records many purchases over multiple years could cause unbounded memory allocation in both the DB and the service. Suggested fix: Add a LIMIT constant (and a note that pagination is post-v1) to match the defensive pattern used for plants.
internal/service/seed_lots.go:93-114 and internal/store/seed_lots.go:159-184 — GetSeedLot and UpdateSeedLot each call fillRemaining for a single lot, but fillRemaining always executes SeedLotUsage(ctx, actorID) which fetches every active planting attributed to any of the actor's lots. For a single-lot read this is unnecessary work: if a user has 50 lots with hundreds of total plantings, computing derivedCount for every planting just to sum one lot's usage is wasteful. Suggested fix: Add a lot-ID filter parameter to SeedLotUsage so single-lot reads can scope the query; at minimum pre-filter the uses slice to only rows whose LotID is in the requested set.
🧯 Error handling & edge cases — Blocking issues found
Blocking issues found
internal/service/seed_lots.go:182-184 — UpdateSeedLot returns a version-conflict response without computing Used/Remaining. When the store returns (currentRow, ErrVersionConflict), the service immediately returns updated, err and skips fillRemaining. The conflict response therefore leaves Used=0 and Remaining=0 (zero values) instead of the derived amounts. This is inconsistent with GetSeedLot and ListSeedLots, which always populate the fields, and the API handler (internal/api/seed_lots.go:161-168) forwards that incomplete row to the client. Verified by reading UpdateSeedLot in service/seed_lots.go and comparing with UpdatePlanting in service/plantings.go:167-174, which enriches its conflict response.
internal/api/seed_lots.go:60 — seedLotUpdateRequest.Version only declares binding:"required", omitting min=1. The plant (plants.go:49) and planting (plantings.go:76) update requests both enforce binding:"required,min=1". A version of 0 or a negative value therefore passes API validation and reaches the service, where it will fail the SQL version = ? guard and surface as a version conflict instead of an early INVALID_INPUT error. Verified by comparing the struct tags across the three update request types.
opencode/glm-5.2:cloud (opencode) — Blocking issues found
I reviewed this change through the security lens — authn/authz, injection, XSS via the sourceUrl clickable link, SSRF, and existence-masking. Verified each path by reading the actual checked-out code, not just the diff hunks.
sourceUrl XSS protection is sound.validSourceURL (internal/service/seed_lots.go:299) rejects everything except empty or absolute http/https with a non-empty host. I traced the rejection table in the test (seed_lots_test.go:174): javascript:, JavaScript: (Go's url.Parse lowercases the scheme, so the case variant is caught), data:, schemeless //evil…, relative /seeds/garlic, file:, and host-less https:// are all refused. The same check is reused for plants.source_url via finalizePlant (plants.go:206), so neither surface accepts a script URL. Verified that built-ins can't be used as an injection vector either: writablePlant returns ErrForbidden for owner_id IS NULL (plants.go:104), so a user can't write a javascript: link onto a built-in row.
Authorization on seed lots is consistently enforced.ownSeedLot (seed_lots.go:119) loads then checks l.OwnerID == actorID, returning ErrNotFound otherwise (existence masked, matching the project convention). GetSeedLot, UpdateSeedLot, DeleteSeedLot all go through ownSeedLot; ListSeedLots/ListSeedLotsForOwner filter by owner_id = ? (store/seed_lots.go:64). The seed-lots route group is behind requireAuth() (api.go:129). I confirmed mustActor(c).ID is the session actor in every handler.
Planting→lot attribution is checked, not trusted, and re-checked on update.seedLotForPlanting (seed_lots.go:207) requires the lot be the actor's and be a lot of the plant being planted; on UpdatePlanting it runs against the post-patch pl.PlantID so a plant swap can't leave a stale cross-variety attribution (plantings.go:162). Another user's lot is normalized to ErrInvalidInput, not leaked. A shared editor attributing a plop in someone else's garden to their own lot is allowed by design and not an authz bypass — the lot, not the garden, is the unit of ownership.
No injection / SSRF. All store queries use ? placeholders (store/seed_lots.go, store/plantings.go, store/plants.go); plantID query param is parsed with bounds checking (api/seed_lots.go:104). sourceUrl is stored and rendered as a link; the server never fetches it, so there is no SSRF surface.
Defense in depth on usage accounting.SeedLotUsage (store/seed_lots.go:159) joins seed_lots sl and filters sl.owner_id = ?, so even a hypothetical cross-owner attribution (prevented upstream) would not inflate another user's remaining.
parseNullable / json.RawMessage handling is a pure decode with no eval/deserialization-of-types risk; encoding/json only.
No security findings to report within this lens.
🎯 Correctness — Minor issues
VERDICT: Minor issues
Overall the remaining derivation, attribution re-checks, URL validation, version-guard contract, and ON DELETE SET NULL behavior are all correct. The remaining formula is right (sum of effective count = explicit count else derivedCount(radius, spacing); only removed_at IS NULL rows), and it lives in one place (fillRemaining). The mismatched-lot and not-your-lot rejections on both create and update are correct. No correctness issue in the store layer's column/scan ordering, the version-conflict path, or the migration.
One real correctness bug, all in internal/service/plantings.go:162:
Shared editors can no longer edit existing lot-attributed plantings.UpdatePlanting re-runs seedLotForPlanting(ctx, actorID, pl.SeedLotID, pl.PlantID) on every update (line 162). seedLotForPlanting → ownSeedLot (seed_lots.go:119-128) returns ErrNotFound for any lot the actor doesn't own, which seedLotForPlanting (seed_lots.go:211-213) converts to ErrInvalidInput. The update path already special-cases the plant visibility for exactly this reason (the comment at plantings.go:138-143 calls out "a shared editor in the owner's garden using the owner's private plant"), gating the visiblePlant re-check on pl.PlantID != originalPlantID — but the lot check at line 162 has no equivalent carve-out. So a shared editor who edits anything about an existing planting (label, radius, soft-remove, anything) — where the owner had attributed that plop to one of the owner's lots — now gets ErrInvalidInput and cannot save the edit, even when not touching seedLotId at all. The asymmetry is the bug: the actor can legitimately create plantings they couldn't re-validate, and the no-op re-validation on edit refuses a state that came from elsewhere. Verified by reading UpdatePlanting and the seedLotForPlanting/ownSeedLot chain. Fix: gate the re-check the same way the plant visibility is gated — only enforce the lot attribution when patch.SetSeedLotID is true (the actor is actually trying to set it), or when the plant actually changed (the swap case the comment at line 160-161 cares about). Confidence high that the refusal is real; the precise fix shape is a judgement call.
🧹 Code cleanliness & maintainability — Minor issues
VERDICT: Minor issues
internal/api/seed_lots.go:86 — parseNullable duplicates parseNullableString/parseNullableInt. The new generic helper at seed_lots.go:86 is functionally equivalent to the pre-existing parseNullableString (objects.go:93) and parseNullableInt (plants.go:69): absent → (nil, false), null → (nil, true), otherwise the decoded value. plantings.go:66 already uses the generic parseNullable[int64] for seedLotId while lines 54, 58, 62, 70 of the same toPatch still call the typed helpers — a half-migration within a single function. Consolidate to the generic and delete the two typed helpers, or revert to the typed ones; the mix is the worst of both.
internal/service/plants.go:201 — plant validation reaches into a lot-named constant.finalizePlant checks len(p.Vendor) > maxLotTextLen, but maxLotTextLen is declared in seed_lots.go:18 alongside the other maxLot* constants, while plants.go keeps its own maxPlantNameLen/maxPlantNotesLen (plants.go:15-16). A reader of plants.go sees a "lot" constant enforcing a plant field and must grep across files to find it. Same cross-file dependency with validSourceURL (defined seed_lots.go:299, called plants.go:206). These are shared provenance rules now — lift them into a neutral location (e.g. provenance.go) or rename to maxProvenanceTextLen rather than having plants.go depend on seed_lots.go for its validation primitives.
internal/service/seed_lots_test.go:345 — intPtr duplicates ptrInt.plants_test.go:12 defines func ptrInt(n int) *int { return &n }; the new test file adds func intPtr(v int) *int { return &v } — same helper, different name, same package. Drop intPtr and use ptrInt, or pick one name and consolidate.
⚡ Performance — Minor issues
I'll verify both findings against the actual code.
Both findings confirmed. Let me verify the analogous plants/gardens list queries have LIMIT caps.
Both findings confirmed against the actual code:
Finding 1 (N+1 in fillRemaining/GetSeedLot): Confirmed. GetSeedLot (line 131) calls fillRemaining (line 137) with a single-element slice, and fillRemaining (line 93) calls s.store.SeedLotUsage(ctx, actorID) (line 97) which returns all active plantings for the owner across all lots/gardens (store line 159-166), then re-sums the entire slice (lines 101-108) regardless of how many lots were requested. Looping callers become quadratic.
Finding 2 (missing LIMIT): Confirmed. ListSeedLotsForOwner (store line 63) has no LIMIT, while ListPlantsForActor (plants.go:73, maxPlantsListed=5000) and ListGardensForActor (gardens.go:93, maxGardensListed=1000) both cap results.
VERDICT: Minor issues
internal/service/seed_lots.go:131 (GetSeedLot) — for a single-lot fetch, fillRemaining issues SeedLotUsage, which loads and scans every active planting attributed to any of the owner's lots across all gardens, then aggregates them in Go. Fetching one lot therefore scans the actor's entire planting history; calling GetSeedLot in a loop (e.g. an index page rendering lots one by one) is quadratic in plantings × lots. Verified: SeedLotUsage (internal/store/seed_lots.go:159) takes only ownerID and returns all of the owner's usages, and fillRemaining (internal/service/seed_lots.go:93) re-sums the whole slice each call. Suggested fix: add a SeedLotUsageForLots(ownerID, lotIDs []int64) store path that filters pl.seed_lot_id IN (...), or compute used for all of the owner's lots once and index into it — the single-lot read then pays O(1) over a pre-grouped map rather than re-scanning every planting.
internal/store/seed_lots.go:63 (ListSeedLotsForOwner) — unbounded SELECT (no LIMIT), unlike the analogous ListPlantsForActor (plants.go:73, maxPlantsListed=5000) and ListGardensForActor (gardens.go:93, maxGardensListed=1000), both of which cap with the same "household scale, pagination post-v1" rationale. A user accumulating many lots over years has no upper bound. Verified by grep: this is the only owner-scoped list query in internal/store without a LIMIT. Suggested fix: add LIMIT N (e.g. 5000) consistent with the other list endpoints, or note explicitly why seed lots are expected to stay bounded when plants/gardens are not.
The N+1 in fillRemaining is the material one; the missing cap is a minor consistency nit given the household-scale assumption stated elsewhere.
🧯 Error handling & edge cases — Blocking issues found
Both findings verified against the actual source.
Finding 1 confirmed: UpdatePlanting (plantings.go:162) calls s.seedLotForPlanting unconditionally; seedLotForPlanting (seed_lots.go:207-219) → ownSeedLot (119-128) returns ErrNotFound for non-owners, converted to ErrInvalidInput at line 213. The editor path is reachable: objectForRole(..., roleEditor) at line 130, effectiveGardenRole returns roleEditor for non-owners (gardens.go:102-103). Contrast with the PlantID branch which is gated on pl.PlantID != originalPlantID (line 144); SeedLotID has no such gate — always re-checked. A shared editor editing the owner's lot-attributed plop without touching seedLotId is rejected.
Finding 2 confirmed: RestorePlanting (store/plantings.go:88-103) re-inserts p.SeedLotID verbatim from the revision snapshot (line 97). ON DELETE SET NULL only nulls the live row, not the historical snapshot. Revert of a planting delete routes through RestorePlanting (revisions.go:549). After restore with a dangling seed_lot_id, the next UpdatePlanting hits seedLotForPlanting → ownSeedLot → GetSeedLot returns ErrNotFound → ErrInvalidInput, bricking the plop against edits.
VERDICT: Blocking issues found
internal/service/plantings.go:162 — shared-garden editors can no longer update any lot-attributed planting.UpdatePlanting runs s.seedLotForPlanting(ctx, actorID, pl.SeedLotID, pl.PlantID) on every update. seedLotForPlanting (seed_lots.go:207) calls ownSeedLot, which returns ErrNotFound for anyone but the lot's owner (seed_lots.go:124), and seedLotForPlanting converts that to ErrInvalidInput (seed_lots.go:213). A garden shared with an editor is reachable: effectiveGardenRole returns roleEditor for non-owners (gardens.go:102-103), and UpdatePlanting authorizes via objectForRole(..., roleEditor) (plantings.go:130). An editor can move, soft-remove, or relabel a plop; but if that plop carries the owner's seedLotId (the only kind it can carry — lots are private, per seed_lots.go:14), the editor's update is now rejected with ErrInvalidInput even when they never touch seedLotId. This is asymmetric with the adjacent PlantID handling, which is deliberately gated on pl.PlantID != originalPlantID (plantings.go:144) for exactly this shared-editor reason (comment at plantings.go:138-142). The minimal fix mirroring that gating is to re-check seedLotForPlanting only when patch.SetSeedLotID is true.
internal/service/plantings.go:162 — re-check also bricking a plop whose lot was deleted after a revert.seedLotForPlanting returns ErrInvalidInput when ownSeedLot returns ErrNotFound (seed_lots.go:213). The migration sets seed_lot_id to NULL on lot deletion (ON DELETE SET NULL), so the live path is safe — but the revision snapshot still holds the old lot id, and RestorePlanting re-inserts p.SeedLotID verbatim from the snapshot (store/plantings.go:97) without nulling a dangling reference. Revert of a planting delete goes through RestorePlanting (revisions.go:549). After such a restore, the restored row carries a seed_lot_id pointing at a non-existent lot, and the very next UpdatePlanting hits ownSeedLot → ErrNotFound → ErrInvalidInput, permanently bricking the plop against edits. Suggested fix: in seedLotForPlanting, treat ErrNotFound from ownSeedLot as "clear the dangling link to NULL and allow" rather than ErrInvalidInput, OR null out SeedLotID during restore when the lot no longer exists.
opencode/kimi-k2.6:cloud (opencode) — Blocking issues found
URL validation (validSourceURL) correctly rejects javascript:, data:, file:, schemeless, relative, and host-less URLs while allowing empty strings (clearing the field). The scheme check is case-insensitive because url.Parse normalizes schemes to lowercase. This is verified by the rejection table in TestSourceURLValidation.
Authn/authz is consistent: every seed-lot handler gates on mustActor(c).ID; ownSeedLot masks other users' lots as ErrNotFound; seedLotForPlanting enforces that a planting's lot belongs to the actor and matches the plant being planted, preventing cross-variety attribution that would corrupt remaining.
Input validation is comprehensive: length caps, unit whitelist, quantity/date/germination bounds, and finite-number checks all live in finalizeSeedLot/finalizePlant, shared by create and update paths. No bypass path exists—patch fields flow through apply*Patch into the same finalizers.
SQL is fully parameterized; no injection vectors.
🎯 Correctness — ⚠️ could not complete
⚠️ This reviewer failed to complete: opencode run returned no output
🧹 Code cleanliness & maintainability — Minor issues
Minor issues
internal/api/seed_lots.go:86 — parseNullable[T] duplicates the functionality of the existing parseNullableInt (plants.go:69) and parseNullableString (objects.go:93) helpers, yet it is defined in seed_lots.go instead of alongside them. Worse, plantings.go now mixes the new generic call (parseNullable[int64]) with the old non-generic ones (parseNullableInt, parseNullableString) in the same toPatch method, leaving the codebase in a half-migrated state. Fix: place the generic helper in objects.go with the other nullable parsers, replace the old ones with thin wrappers or remove them, and migrate plantings.go consistently.
internal/service/seed_lots_test.go:345 — func intPtr(v int) *int duplicates ptrInt in plants_test.go:12 (same package, different name). Having two names for the same helper is needless churn for future readers. Fix: delete intPtr and use ptrInt everywhere in the package.
internal/service/plants_test.go:187 — The only changes to this file are whitespace re-alignment of // comments. CLAUDE.md explicitly calls this file out as "already unformatted on main — leave it alone unless you're touching it, so the diff stays about your change." Reformatting it anyway adds noise and violates an established project convention. Fix: revert the formatting-only changes.
internal/service/seed_lots.go:146 — CreateSeedLot never calls s.record(...), and domain.go has no EntitySeedLot constant. CLAUDE.md states as an invariant: "Every service mutation lands in history (#48). If you add one, record it." The existing record helper is garden-scoped, but seed lots are actor-scoped; even so, the PR neither records them nor explains why they are exempt, which leaves the history surface incomplete and breaks a documented project convention. Fix: add EntitySeedLot to domain.go and record seed-lot mutations (this may require extending the history seam to support actor-scoped entities).
internal/service/seed_lots.go:172 — UpdateSeedLot never calls s.record(...), and domain.go has no EntitySeedLot constant. CLAUDE.md states as an invariant: "Every service mutation lands in history (#48). If you add one, record it." The existing record helper is garden-scoped, but seed lots are actor-scoped; even so, the PR neither records them nor explains why they are exempt, which leaves the history surface incomplete and breaks a documented project convention. Fix: add EntitySeedLot to domain.go and record seed-lot mutations (this may require extending the history seam to support actor-scoped entities).
internal/service/seed_lots.go:196 — DeleteSeedLot never calls s.record(...), and domain.go has no EntitySeedLot constant. CLAUDE.md states as an invariant: "Every service mutation lands in history (#48). If you add one, record it." The existing record helper is garden-scoped, but seed lots are actor-scoped; even so, the PR neither records them nor explains why they are exempt, which leaves the history surface incomplete and breaks a documented project convention. Fix: add EntitySeedLot to domain.go and record seed-lot mutations (this may require extending the history seam to support actor-scoped entities).
⚡ Performance — Blocking issues found
Blocking issues found
internal/store/seed_lots.go:63 — ListSeedLotsForOwner issues an unbounded SELECT with no LIMIT. Every other list endpoint in the codebase (gardens, plants, shares) carries a defensive cap (e.g. maxGardensListed = 1000, maxPlantsListed = 5000). A gardener who records seed purchases year-over-year will see response size and memory use grow without bound. Fix: add a maxSeedLotsListed constant and LIMIT ? to the query.
internal/service/seed_lots.go:93 / internal/store/seed_lots.go:159 — fillRemaining fetches all of the actor's active plantings via SeedLotUsage(ctx, actorID) even when called from GetSeedLot or UpdateSeedLot, which only need one lot. This is O(total active plantings across every lot) I/O and computation for a single-lot read. Fix: add an overload or optional lotID filter to SeedLotUsage (e.g. SeedLotUsage(ctx, actorID, lotID *int64)) and use the targeted query in GetSeedLot/UpdateSeedLot; keep the broad query for ListSeedLots where it is actually needed.
🧯 Error handling & edge cases — Blocking issues found
Blocking issues found
internal/service/seed_lots.go:168 — CreateSeedLot returns stale Used/Remaining zero-values GetSeedLot, ListSeedLots, and UpdateSeedLot all call fillRemaining so the client sees the live used/remaining derived fields. CreateSeedLot does not, so a newly-created lot with quantity: 100 is serialized as {\"used\":0,\"remaining\":0,\"quantity\":100}, which is actively misleading. The API should return a consistent shape on every verb. Fix: Either call fillRemaining on a single-element slice before returning, or at minimum set Used=0, Remaining=l.Quantity.
internal/service/revisions.go:448 + internal/store/plantings.go:88 — Revert of a deleted planting hard-fails if its lot was deleted in the meantime RestorePlanting re-inserts the row with its original seed_lot_id. If the referenced lot was deleted after the planting was originally deleted, the FK REFERENCES seed_lots(id) throws a constraint error. revertEntity has no blockRestore hook for plantings, so the entire revert aborts. The PR explicitly says "retiring a lot must never destroy planting history — the plants really were in the ground," but the revert path hasn't been taught that the lot may now be gone. Fix: In the planting revert ops (or RestorePlanting itself), check lot existence and clear seed_lot_id to NULL when the lot is missing, matching the ON DELETE SET NULL semantics the rest of the system relies on.
internal/service/seed_lots.go:111 — Remaining can go negative without guard or documented behavior fillRemaining does Quantity - Used with no floor. If a user plants more than they bought, or later reduces Quantity below current usage, Remaining becomes negative. The field is described as "how much is left," so a negative value is surprising and could confuse display logic. Fix: Cap at zero (max(0, Quantity - Used)) or, if negative is intentional to show over-commitment, document it explicitly in the struct comment.
Automated adversarial review by Gadfly — consensus across the model swarm. Advisory only — does not block merge.
<!-- gadfly-consensus -->
## 🪰 Gadfly review — consensus across 5 models
**Verdict: Blocking issues found** · 25 findings (11 with multi-model agreement)
| | Finding | Where | Models | Lens |
|--|--|--|--|--|
| 🟠 | Generic parseNullable helper trapped in seed_lots.go but used cross-file | `internal/api/seed_lots.go:86` | 4/5 | maintainability |
| 🔴 | UpdateSeedLot version conflict omits Used/Remaining computation | `internal/service/seed_lots.go:182` | 3/5 | correctness, error-handling |
| 🟡 | Duplicate intPtr test helper when ptrInt already exists in same package | `internal/service/seed_lots_test.go:345` | 3/5 | maintainability |
| 🔴 | CreateSeedLot returns a lot with Remaining/Used unset (reports remaining: 0 instead of the purchased quantity) | `internal/service/seed_lots.go:168` | 2/5 | correctness, error-handling |
| 🔴 | CreateSeedLot returns lot without computed Used/Remaining fields | `internal/service/seed_lots.go:172` | 2/5 | correctness, maintainability |
| 🔴 | ListSeedLotsForOwner is unbounded: no LIMIT on query | `internal/store/seed_lots.go:63` | 2/5 | performance |
| 🟠 | GetSeedLot and UpdateSeedLot fetch all actor plantings to compute remaining for one lot | `internal/service/seed_lots.go:93` | 2/5 | performance |
| 🟠 | GetSeedLot/UpdateSeedLot over-fetch all owner's active plantings via unscoped SeedLotUsage to compute one lot's remaining | `internal/service/seed_lots.go:131` | 2/5 | performance |
| 🟠 | ListSeedLotsForOwner has no LIMIT cap, unlike ListPlantsForActor | `internal/store/seed_lots.go:69` | 2/5 | performance |
| 🟡 | finalizePlant validates a plant field against maxLotTextLen, a constant defined in/named for seed_lots.go — leaky cross-file coupling | `internal/service/plants.go:201` | 2/5 | maintainability |
| 🟡 | Broken comment alignment in table-driven test | `internal/service/plants_test.go:187` | 2/5 | maintainability |
<details><summary>14 single-model findings (lower confidence)</summary>
| | Finding | Where | Model | Lens |
|--|--|--|--|--|
| 🔴 | UpdatePlanting re-validates the owner's lot on every edit, breaking shared editors who touch an existing lot-attributed planting | `internal/service/plantings.go:162` | opencode/glm-5.2:cloud | correctness, error-handling |
| 🔴 | Revert of deleted planting fails if its seed lot was later deleted | `internal/service/revisions.go:448` | opencode/kimi-k2.6:cloud | error-handling |
| 🔴 | UpdateSeedLot version conflict returns stale Used/Remaining zeros | `internal/service/seed_lots.go:190` | kimi-k2.6:cloud | correctness |
| 🔴 | CopyGarden carries seed_lot_id into the cloned planting, double-counting lot usage and violating the 'lots are never shared with a garden' invariant | `internal/store/gardens.go:237` | glm-5.2:cloud | correctness |
| 🔴 | RestorePlanting blindly restores seed_lot_id without checking lot still exists | `internal/store/plantings.go:88` | opencode/kimi-k2.6:cloud | error-handling |
| 🟠 | seedLotUpdateRequest.Version missing min=1 binding validation | `internal/api/seed_lots.go:60` | kimi-k2.6:cloud | error-handling |
| 🟠 | GetSeedLot/UpdateSeedLot recompute usage over all of the owner's lots instead of the requested one | `internal/service/seed_lots.go:97` | claude-code/sonnet | performance |
| 🟠 | fillRemaining can produce negative Remaining with no guard or docs | `internal/service/seed_lots.go:111` | opencode/kimi-k2.6:cloud | error-handling |
| 🟠 | Seed-lot mutations not recorded in history, breaking project invariant | `internal/service/seed_lots.go:146` | opencode/kimi-k2.6:cloud | maintainability |
| 🟠 | Seed-lot mutations not recorded in history, breaking project invariant | `internal/service/seed_lots.go:196` | opencode/kimi-k2.6:cloud | maintainability |
| 🟠 | No check prevents attributing more plantings to a lot than its quantity, yielding negative Remaining | `internal/service/seed_lots.go:207` | glm-5.2:cloud | error-handling |
| 🟡 | seedLotForPlanting name implies getter, not validation | `internal/service/seed_lots.go:213` | kimi-k2.6:cloud | maintainability |
| 🟡 | Concurrent lot deletion during update returns underlying error, not ErrVersionConflict, so handler mis-maps it | `internal/store/seed_lots.go:118` | glm-5.2:cloud | error-handling |
| ⚪ | Comment '// ErrNotFound' on ownSeedLot's return is inaccurate for non-NotFound wrapped store errors | `internal/service/seed_lots.go:122` | glm-5.2:cloud | maintainability |
</details>
<details><summary>Per-model detail</summary>
<details><summary><b>claude-code/sonnet</b> (claude-code) — Blocking issues found</summary>
**Verdict: Blocking issues found** — 5 reviewers: security, correctness, maintainability, performance, error-handling
<details><summary><b>🔒 Security</b> — No material issues found</summary>
## Review
**VERDICT: No material issues found**
I traced the security-relevant surface of this PR — authz on seed lots, the new `source_url` validation, and the planting-attribution checks — against the actual code (not just the diff).
**What I verified:**
- **Ownership scoping**: `ownSeedLot` (`internal/service/seed_lots.go:64`) checks `l.OwnerID != actorID` and returns `ErrNotFound` (not `ErrForbidden`), consistent with the codebase's existence-masking convention. All handlers (`Get`/`Update`/`Delete`/list) route through it. Confirmed by reading `internal/api/seed_lots.go` and the store layer — every route is behind `h.requireAuth()` (`internal/api/api.go:127`).
- **Store queries are parameterized** throughout `internal/store/seed_lots.go` — no string-built SQL, no injection surface. `SeedLotUsage` correctly scopes to `sl.owner_id = ?` via an inner join, so a user can never pull another owner's lot usage.
- **Plant-attribution IDOR check**: `seedLotForPlanting` (`internal/service/seed_lots.go:172`) requires the lot to belong to the actor *and* match the plant, and is re-run on every `UpdatePlanting`, not just when the lot field changes — confirmed at `internal/service/plantings.go:110` and `:162`. Another user's lot ID collapses to `ErrInvalidInput` via `ownSeedLot`'s `ErrNotFound`, not a distinguishable "forbidden," so it doesn't leak the lot's existence.
- **`source_url` validation** (`validSourceURL`, `internal/service/seed_lots.go:296`) requires `http`/`https` scheme and a non-empty host, rejecting `javascript:`, `data:`, schemeless (`//host`), and relative URLs — this is the right check for the stated threat (stored XSS via a rendered link). I confirmed Go's `net/url.Parse` lowercases the scheme, so `HTTP://` isn't a bypass (it just normalizes to the already-allowed `http`), and opaque forms like `http:evil.com` leave `Host` empty and get rejected. Applied identically to both `plants.source_url` (`internal/service/plants.go:205`) and `seed_lots.source_url`.
- **No server-side fetch of `source_url`/`vendor`** anywhere in the codebase (grepped `internal/`) — it's stored and returned verbatim, so there's no SSRF surface from this field.
- **Mass-assignment**: `SeedLotPatch` has no `PlantID`/`OwnerID` fields and `applySeedLotPatch` never touches them — a lot can't be re-pointed at another plant or reassigned to another owner via PATCH.
- **Immutable-column integrity**: the migration's `plant_id`/`owner_id REFERENCES ... ON DELETE CASCADE` on `seed_lots`, and `seed_lot_id ... ON DELETE SET NULL` on `plantings`, match the stated design and don't create a cross-tenant cascade (a plant's `owner_id` gates who can delete it in the first place).
- Agent tools (`internal/agent/tools.go`) don't yet expose seed lots at all, so there's no bypass of these checks via that path.
Nothing else in this diff touches authn, secrets, or deserialization. The security-relevant design choices called out in the PR description (URL scheme+host validation, ownership masking, re-checked attribution) are implemented as described.
</details>
<details><summary><b>🎯 Correctness</b> — Blocking issues found</summary>
Confirms the draft's claim that no test checks the create-response's Remaining/Used field directly. Both findings hold up. Final review:
**VERDICT: Blocking issues found**
- `internal/service/seed_lots.go:168` — `CreateSeedLot` returns `s.store.CreateSeedLot(ctx, l)` directly without calling `s.fillRemaining`. `domain.SeedLot.Remaining`/`Used` are computed-only fields, zero-valued at construction, so a freshly created lot is returned with `remaining: 0` regardless of the purchased quantity (e.g. buying 100 seeds shows `"remaining": 0` in the create response instead of 100). Confirmed no test checks `Remaining`/`Used` on the `CreateSeedLot` return value — `seed_lots_test.go` only checks these fields via subsequent `GetSeedLot`/`ListSeedLots` calls. Fix: call `s.fillRemaining` on the created row before returning, mirroring `GetSeedLot`/`ListSeedLots`/the success path of `UpdateSeedLot`.
- `internal/service/seed_lots.go:182-184` — On a version conflict, `UpdateSeedLot` returns the store's current row (`updated, err`) without filling `Remaining`/`Used`, so the row embedded in the 409 response's `"current"` field reports `remaining: 0`/`used: 0` even when real usage/remaining exists. Confirmed the store (`internal/store/seed_lots.go:112-118`) does return a real, non-nil current row alongside `domain.ErrVersionConflict`. This is inconsistent with the established pattern: `UpdatePlanting` (`internal/service/plantings.go:167-173`) explicitly calls `s.enrichDerived(ctx, updated)` on `ErrVersionConflict` before returning the current row for exactly this reason. Fix: call `s.fillRemaining` (best-effort) on the conflict path too.
Both bugs share the same root cause — a derived-field enrichment step skipped on two return paths — and both undermine the PR's headline feature of reporting remaining seed-lot quantity.
</details>
<details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary>
## Review
**VERDICT: Minor issues**
- `internal/service/seed_lots_test.go:345` defines `func intPtr(v int) *int { return &v }`, but `internal/service/plants_test.go:12` already declares an identical helper `ptrInt(n int) *int { return &n }` in the same `package service`. Confirmed both files are `package service`, both bodies are byte-identical, and `intPtr` is used at lines 56 and 60 of `seed_lots_test.go` where `ptrInt` would work just as well. Needless duplicate helper in one package. Fix: drop `intPtr` and use the existing `ptrInt`.
Everything else in-lens is solid: `seed_lots.go` (store and service) closely mirrors the existing `plants.go`/`plantings.go` patterns, `parseNullable[T]` in `internal/api/seed_lots.go` is a reasonable small generic to de-duplicate the four nullable-patch fields rather than repeating the pattern by hand, and the effective-count formula is correctly reused from `derivedCount` in `plantings.go` rather than re-implemented.
(The second draft finding — that the `plants_test.go:186-195` hunk is a gratuitous whitespace-only realignment violating the project's "leave this file alone" convention — did not survive verification. Manually recomputing the code-content length of each entry in the `bad` table shows the padding before each `//` comment is consistent with standard gofmt tabwriter alignment: line 192's code content is the same length as lines 187/189 (the dropped emoji rune in `Icon: ""` is offset by the added `x` in `Name: "x"`), and its padding matches theirs exactly — it is not "pushed further right despite shorter content" as claimed. With that supporting evidence contradicted, and no diff for this file reachable in this sandbox to confirm a hunk exists here at all, the finding was dropped.)
</details>
<details><summary><b>⚡ Performance</b> — Minor issues</summary>
Verified against `internal/service/seed_lots.go` and `internal/store/seed_lots.go`: `fillRemaining` (lines 93-114) unconditionally calls `s.store.SeedLotUsage(ctx, actorID)`, whose SQL (`internal/store/seed_lots.go:159-166`) joins `plantings ⋈ seed_lots ⋈ plants` filtered only by `sl.owner_id = ?`, with no `lot_id` predicate. `GetSeedLot` (lines 131-141) and `UpdateSeedLot` (lines 172-190) both pass a one-element slice into `fillRemaining` and discard all but the matching `LotID`. The finding holds.
## VERDICT: Minor issues
- **`internal/service/seed_lots.go:93-114,131-141,178` (`fillRemaining` used by `GetSeedLot`/`UpdateSeedLot`)** — every single-lot read or update re-fetches and aggregates **all** of the owner's active seed-lot-attributed plantings across every lot they've ever recorded, just to pull out one lot's usage. `GetSeedLot` builds a one-element slice (`one := []domain.SeedLot{*l}`) and passes it to `fillRemaining`, but `fillRemaining` ignores which lots were asked for and calls `s.store.SeedLotUsage(ctx, actorID)` (`internal/store/seed_lots.go:159`), an unfiltered join across `plantings ⋈ seed_lots ⋈ plants` scoped only by `owner_id`, then discards everything except the one matching `LotID` (`internal/service/seed_lots.go:101-111`). `UpdateSeedLot` (`internal/service/seed_lots.go:178`) does the same for its single updated row. Since the PR's own selling point is that lots accumulate over years ("the same garlic from two vendors in two years"), this means viewing or editing any one lot gets slower as the user's total inventory history grows, rather than scaling with that lot's own plantings. A cheap fix: add a store method that scopes `SeedLotUsage` by `lot_id IN (...)` (or a single `lot_id = ?` variant) and have `fillRemaining` pass the requested lot IDs through, keeping the unfiltered form only for `ListSeedLots`, which legitimately needs all of them at once.
Everything else in this lens is clean: `ListSeedLotsForOwner`/`SeedLotUsage` are appropriately indexed (`idx_seed_lots_owner`, partial `idx_plantings_seed_lot`, both confirmed in `internal/store/migrations/0007_seed_lots.sql`) and used correctly for the list case; `seedLotForPlanting` and `visiblePlant` are single indexed lookups with no loops; there's no N+1 pattern in create/update paths.
</details>
<details><summary><b>🧯 Error handling & edge cases</b> — Blocking issues found</summary>
Line numbers match the draft exactly (168 for CreateSeedLot's return, 182-184 for the update conflict path). Both findings are fully confirmed against the actual source.
VERDICT: Blocking issues found
- `internal/service/seed_lots.go:168` — `CreateSeedLot` returns `s.store.CreateSeedLot(ctx, l)` directly, never calling `fillRemaining`. `domain.SeedLot.Used`/`Remaining` (`internal/domain/domain.go`) are plain zero-valued fields with no default computation, and `store.CreateSeedLot`/`scanSeedLot` never touch them. Every freshly created lot is serialized back to the client with `remaining: 0` instead of `Quantity`, even though nothing has been planted against it yet — wrong on the very first read of every lot's life. `GetSeedLot`, `UpdateSeedLot` (success path), and `ListSeedLots` all correctly call `fillRemaining`; `CreateSeedLot` is the one path that skips it.
- Fix: call `s.fillRemaining(ctx, actorID, []domain.SeedLot{*created})` (or set `Remaining = Quantity` directly) before returning.
- `internal/service/seed_lots.go:182-184` — On a version conflict, `store.UpdateSeedLot` (`internal/store/seed_lots.go:98-123`) returns `(current, domain.ErrVersionConflict)`, and the service does `return updated, err`, skipping `fillRemaining`. That row is exactly what `writeVersionConflict(c, lot)` (`internal/api/seed_lots.go:163-166`, using `internal/api/errors.go:65-70`) sends to the client as `"current"` in the 409 body — so a client trying to show "you have N left, still want to save?" during conflict resolution sees `used: 0, remaining: 0` regardless of the lot's true state.
- Fix: fill `Used`/`Remaining` on the conflict row too, e.g. move the `fillRemaining` call before returning in the `err != nil` branch, or call it in both branches.
</details>
</details>
<details><summary><b>glm-5.2:cloud</b> (ollama-cloud) — Blocking issues found</summary>
**Verdict: Blocking issues found** — 5 reviewers: security, correctness, maintainability, performance, error-handling
<details><summary><b>🔒 Security</b> — No material issues found</summary>
**Verdict: No material issues found**
Through the security lens, this change is well-constructed. I verified the key paths by reading the actual checked-out code:
- **`validSourceURL`** (`internal/service/seed_lots.go:299-313`) is the load-bearing XSS control, and it is correct: empty allowed, length-capped, `url.Parse` + `Scheme ∈ {http,https}` + `Host != ""`. It rejects `javascript:`, `data:`, `file:`, schemeless `//evil`, relative `/seeds`, and `https://` (no host) — matching the test table. It is enforced on **both** lots (`finalizeSeedLot:271`) and plants (`finalizePlant:206`), for create and update.
- **Authorization** is owner-scoped everywhere: routes sit behind `requireAuth()` (`internal/api/api.go:129`); `ownSeedLot` masks existence via `ErrNotFound` on a non-owner lot (`seed_lots.go:124`); `ListSeedLotsForOwner` and `SeedLotUsage` both filter `WHERE owner_id = ?` with a parameterized placeholder (`internal/store/seed_lots.go:64,165`). No SQL injection surface — all queries use `?` placeholders.
- **Attribution integrity** (`seedLotForPlanting:207-219`) requires the lot be the actor's *and* a lot of the plant being planted, and is re-run on every update (`plantings.go:162`) so a plant swap can't leave a stale cross-variety attribution. Another user's lot → `ErrInvalidInput`, no existence leak.
- **Input validation** in `finalizeSeedLot` (unit whitelist, finite/non-negative/capped quantity, text length caps, year range, non-negative cost, germination 0–100) and the `plantId` query param parse (`api/seed_lots.go:103-108`, `id < 1` rejected) are sound.
- **No cross-user provenance leak**: a user's own plants (with `sourceUrl`) are only visible to themselves; built-ins carry `DEFAULT ''` from the migration and the test confirms they stay clean.
One low-confidence observation (not rising to a reported finding): `url.Parse` is permissive vs. browser URL parsers, so a value with unusual host punctuation could in principle parse as valid here yet be interpreted differently by a browser if/when the frontend renders `sourceUrl` as a link. The `web/` tree does not currently consume `sourceUrl`/`seedLotId` at all (grep found no matches), so there is no live rendering surface today; and React escapes attributes by default. I could not confirm any concrete bypass, so I'm not elevating this to a finding.
</details>
<details><summary><b>🎯 Correctness</b> — Blocking issues found</summary>
VERDICT: Blocking issues found
- **`internal/store/gardens.go:237` — garden copy double-counts seed-lot usage.** `CopyGarden`'s copy loop re-inserts each active planting via `plantingInsertArgs(objectID, &p)`, and `plantingInsertArgs` (internal/store/plantings.go:163) now passes `p.SeedLotID` straight through. The cloned planting therefore keeps the source planting's `seed_lot_id`, pointing at the *same* lot. `SeedLotUsage` (internal/store/seed_lots.go:159) sums the effective count of all ACTIVE plantings (`removed_at IS NULL`) attributed to a lot, and `fillRemaining` (internal/service/seed_lots.go:93-114) rolls that sum into `Used`/`Remaining`. So copying a garden makes the lot's `Used` jump by the copy's effective count and `Remaining` drop — even though no new seed was planted. This contradicts the PR's own invariant in DESIGN.md ("Lots are never shared along with a garden") and silently corrupts the inventory number the feature exists to report. Fix: null out `SeedLotID` on the cloned planting in the copy loop (a copy didn't consume seed), or otherwise decide and document the intended semantics.
- **`internal/service/seed_lots.go:182-185` — version-conflict row returned without `Used`/`Remaining`.** On a store-level `ErrVersionConflict`, `UpdateSeedLot` returns `(updated, err)` at line 184, where `updated` is the row from `GetSeedLot` → `scanSeedLot` (internal/store/seed_lots.go:48-58,17-27), which does not touch `Used`/`Remaining`, leaving them at their zero values — the `fillRemaining` call at line 187 is skipped on the error path. The API handler (`internal/api/seed_lots.go:160-164`) then hands that row to `writeVersionConflict` (internal/api/errors.go:65), so the conflict payload's `current` lot reports `used=0, remaining=0`. Minor: the "current row" the conflict contract promises to carry is misleading for this resource. Fix: compute `Used`/`Remaining` on the conflict path too before returning.
</details>
<details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary>
All three findings are confirmed against the actual code.
1. `parseNullable[T]` (seed_lots.go:86), `parseNullableString` (objects.go:93), and `parseNullableInt` (plants.go:69) all coexist with identical absent/null/value semantics; `plantings.go:toPatch` mixes all three (lines 54, 58, 62, 66, 70). Confirmed.
2. `plants.go:201` validates `p.Vendor` against `maxLotTextLen`, which is defined only in `seed_lots.go:18` and named for lots. Confirmed via grep (3 references, all elsewhere are lot code).
3. `seed_lots.go:122` comment `// ErrNotFound` on `ownSeedLot`'s return of `store.GetSeedLot`'s error is inaccurate: `GetSeedLot` (store/seed_lots.go:48-58) maps only `sql.ErrNoRows` to `ErrNotFound` and wraps everything else as `store: get seed lot: %w`. Confirmed.
## Verdict: Minor issues
- **`internal/api/seed_lots.go:86` (and `plantings.go:66`) — three nullable-parsers where one generic suffices.** The PR introduces a generic `parseNullable[T]` that is strictly more general than the pre-existing `parseNullableString` (`objects.go:93`) and `parseNullableInt` (`plants.go:69`) — same `(value, present, err)` semantics, same absent/null/value handling. Rather than consolidate, the change adds a third parser and even mixes all three within a single function (`plantings.go:53-73` uses `parseNullableInt`, `parseNullableString`, and `parseNullable[int64]` side by side). Readability/maintenance smell: future readers must learn three near-identical helpers and decide which to use. Suggested fix: replace `parseNullableString`/`parseNullableInt` with `parseNullable[string]`/`parseNullable[int]` (or keep thin deprecating wrappers) for one canonical helper. Verified by reading all three definitions and the `toPatch` call sites.
- **`internal/service/plants.go:201` — `finalizePlant` reaches across files into a lot constant.** Plant validation uses `maxLotTextLen` (defined in `seed_lots.go:18`) to bound `p.Vendor`. A plant-domain validation function depending on a constant named for lots (and defined in another file) is a leaky, surprising coupling — a maintainer touching lot bounds won't expect plant validation to depend on them, and the naming is actively misleading ("this is a lot limit" used for a plant field). Verified by grep: `maxLotTextLen` only lives in `seed_lots.go` (definition + lot-side validation). Suggested fix: add a plant-side constant (e.g. `maxPlantVendorLen = 200` in plants.go) or hoist a shared `maxShortTextLen`.
- **`internal/service/seed_lots.go:122` — misleading inline comment.** `return nil, err // ErrNotFound` asserts the returned error is `ErrNotFound`, but `ownSeedLot` returns whatever `store.GetSeedLot` returns, which includes wrapped non-NotFound errors (the store wraps scan failures in `fmt.Errorf("store: get seed lot: %w", err)`). The comment is wrong for those paths and could mislead a reader tracing error handling. Verified by reading `GetSeedLot` (store/seed_lots.go:48-58), which only maps `sql.ErrNoRows` to `ErrNotFound` and wraps everything else. Suggested fix: drop the `// ErrNotFound` annotation or rewrite to "`GetSeedLot` already maps missing → ErrNotFound".
These are minor maintainability nits; no blocking cleanliness problems.
</details>
<details><summary><b>⚡ Performance</b> — Minor issues</summary>
Both findings are confirmed against the actual code.
**Verdict: Minor issues**
- **`internal/service/seed_lots.go:131` (`GetSeedLot`) and `:186` (`UpdateSeedLot`) — single-lot reads over-fetch the entire owner's active plantings.** Both paths build a one-element slice and call `s.fillRemaining(ctx, actorID, one)`, which (seed_lots.go:97) calls `s.store.SeedLotUsage(ctx, actorID)`. That store query (`internal/store/seed_lots.go:159-166`) selects every active planting joined across *all* the owner's lots (`WHERE sl.owner_id = ? AND pl.removed_at IS NULL`, no `pl.seed_lot_id = ?` filter, no `LIMIT`), then the service sums the whole result into a map just to read `used[lotID]`. A `GET /seed-lots/:id` or `PATCH /seed-lots/:id` is therefore O(total active plantings owned by the actor) instead of O(plantings of that one lot). A repo-wide grep confirms `SeedLotUsage` takes only `ownerID` and no lot-scoped variant exists. Fix: add an optional lot filter (or a `SeedLotUsageForLot`) and narrow the `WHERE` to `pl.seed_lot_id = ?` for the single-lot paths; leave the all-lots query for `ListSeedLots`, where the full scan is actually needed.
- **`internal/store/seed_lots.go:69` — `ListSeedLotsForOwner` has no defensive `LIMIT`, unlike every sibling list query.** The query (`SELECT ... FROM seed_lots WHERE owner_id = ? [AND plant_id = ?] ORDER BY ...`, lines 64-71) returns the entire unbounded set, and `fillRemaining` then runs a second unbounded query over all the owner's active plantings on top of it. The siblings all cap: `ListPlantsForActor` uses `maxPlantsListed = 5000` (plants.go:62/74), `ListSharesForGarden` uses `maxSharesListed = 1000` (shares.go:45/56), and `ListGardensForActor` uses `maxGardensListed` (gardens.go:93). Lots accumulate year over year, so this grows without bound. Fix: add a `maxLotsListed` constant and `LIMIT ?`, matching the established pattern.
</details>
<details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary>
All three findings are confirmed by the code.
**Finding 1**: `seedLotForPlanting` (line 207) validates only ownership and plant-match — no quantity check. `fillRemaining` (line 111) computes `Remaining = Quantity - Used` unconditionally, allowing negative stock. Confirmed.
**Finding 2**: `UpdateSeedLot` (line 182-189): after a successful `store.UpdateSeedLot`, a `fillRemaining` failure returns `nil, ferr`, discarding the already-persisted `updated` row. The DB write is committed but the caller receives an error and nil lot. Confirmed.
**Finding 3**: `UpdateSeedLot` store (line 112-117): on `sql.ErrNoRows` it calls `GetSeedLot`; if that fails it returns `(nil, gerr)` rather than `ErrVersionConflict`. The handler at `seed_lots.go:162` only special-cases `ErrVersionConflict`. A concurrent-delete-during-update surfaces as a generic service error. Confirmed.
**Verdict: Minor issues**
- `internal/service/seed_lots.go:207` (`seedLotForPlanting`) / `:111` (`fillRemaining`): There is no check that a lot has enough quantity left before attributing a planting to it. `seedLotForPlanting` validates only ownership and plant-match, and `fillRemaining` computes `Remaining = Quantity - Used` unconditionally. A user can attribute 200 plants to a 50-seed lot (or plant 100, then add 100 more) and the lot silently reports a negative `remaining`. For an inventory feature this is an unhandled boundary: negative stock is nonsensical, and nothing rejects or flags it. Suggested fix: in `CreatePlanting`/`UpdatePlanting`, after `seedLotForPlanting`, compute the lot's current `Used` and reject (or warn) when `Used + effectiveCount > Quantity`.
- `internal/service/seed_lots.go:186` (`UpdateSeedLot`): After a *successful* `s.store.UpdateSeedLot`, a subsequent `fillRemaining` failure returns `nil, ferr`, discarding the already-persisted updated row. The update is committed to the DB but the caller receives an error and a nil lot — a succeeded operation reported as failure. Suggested fix: return `updated` (with `Used`/`Remaining` left at zero) rather than `nil` when only the derived-field enrichment fails.
- `internal/store/seed_lots.go:118` (`UpdateSeedLot` conflict path): When the version-guarded update hits `sql.ErrNoRows`, it re-reads via `GetSeedLot`; if *that* fails (e.g. the lot was deleted concurrently), it returns `(nil, gerr)` rather than `ErrVersionConflict`. The handler at `internal/api/seed_lots.go:162` only special-cases `ErrVersionConflict`, so a concurrent-delete-during-update surfaces as a generic service error instead of a clean 404/conflict. Minor (concurrency race), but the error type is not what the handler expects.
</details>
</details>
<details><summary><b>kimi-k2.6:cloud</b> (ollama-cloud) — Blocking issues found</summary>
**Verdict: Blocking issues found** — 5 reviewers: security, correctness, maintainability, performance, error-handling
<details><summary><b>🔒 Security</b> — No material issues found</summary>
**No material issues found**
Through the security lens, the PR's controls are sound:
- **`validSourceURL`** (`internal/service/seed_lots.go:299`) is load-bearing XSS prevention and correctly rejects `javascript:`, `data:`, `file:`, schemeless (`//…`), relative (`/…`), and host-less (`https://`) URLs. It is applied to both `Plant.SourceURL` and `SeedLot.SourceURL` before storage.
- **Attribution authorization** (`internal/service/seed_lots.go:207`) enforces that a planting's lot belongs to the actor *and* matches the planted variety, returning `ErrInvalidInput` (not `ErrNotFound`) so existence stays masked.
- **Seed-lot isolation** (`internal/service/seed_lots.go:119`) returns `ErrNotFound` for cross-user access, consistently masking existence across GET/PATCH/DELETE.
- **SQL** is fully parameterized throughout; no injection surface.
- **`ON DELETE SET NULL`** on `plantings.seed_lot_id` prevents history destruction when a lot is deleted.
- **Built-in immutability** is preserved: `writablePlant` returns `ErrForbidden` for built-ins, so the new provenance columns on seeded plants cannot be altered.
Nothing in the change introduces an authn/authz gap, injection vector, or unsafe handling of untrusted data.
</details>
<details><summary><b>🎯 Correctness</b> — Blocking issues found</summary>
**Blocking issues found**
* **`internal/service/seed_lots.go:175`** — `CreateSeedLot` returns the newly created lot without calling `fillRemaining`, so `Used` and `Remaining` are both zero in the `201 Created` response. A client that reads `remaining` immediately after creation sees `0` instead of the lot's actual `Quantity`. The existing pattern in `CreatePlanting` (`created.DerivedCount = …`) shows write endpoints should return complete, derived fields.
* **`internal/service/seed_lots.go:190`** — `UpdateSeedLot` returns the version-conflict current row (`return updated, err`) without ever running `fillRemaining`. The `409 Conflict` payload therefore serializes `Used: 0` and `Remaining: 0`, which is stale and misleading. `UpdatePlanting` already solves the same problem by calling `enrichDerived` on the conflict row before returning; `UpdateSeedLot` should do the equivalent for `Used`/`Remaining`.
</details>
<details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary>
**Verdict: Minor issues**
- **`internal/api/seed_lots.go:86`** — The new generic `parseNullable[T any]` is introduced in `seed_lots.go` but immediately consumed by `plantings.go`. This creates a hidden cross-file dependency: a handler in the planting file relies on a helper tucked inside a seed-lot file. The generic is cleaner than the existing `parseNullableInt`/`parseNullableString` duplicates, but it should live in a shared API utility file (or at least in a file whose name doesn't imply seed-lot ownership). **Fix:** Move `parseNullable[T any]` to `api.go` or a new `api/util.go` so its scope matches its consumers.
- **`internal/service/seed_lots.go:213`** — `seedLotForPlanting` is named like a getter/fetcher but actually performs validation and returns only an error. The name is confusing when scanning call sites. The comment explains the intent ("validates a planting's lot attribution"), but the function name doesn't. **Fix:** Rename to `validateSeedLotForPlanting` or `checkSeedLotAttribution` to match its boolean/validation nature and align with other helpers in the package.
- **`internal/service/plants_test.go:187`** — Formatting regression: the comment-aligned spacing in the table-driven test was broken (columns no longer align with their `//` comments), making the test harder to scan. `gofmt` won't fix comment alignment. **Fix:** Re-align the comment offsets to restore the readable table layout.
</details>
<details><summary><b>⚡ Performance</b> — Minor issues</summary>
**Minor issues**
- `internal/store/seed_lots.go:69-91` — `ListSeedLotsForOwner` returns an unbounded slice with no LIMIT or pagination cap. The analogous `ListPlantsForActor` in the same store has a `maxPlantsListed = 5000` defensive backstop (`internal/store/plants.go:62`), but the seed-lots list query lacks any equivalent ceiling. A user who records many purchases over multiple years could cause unbounded memory allocation in both the DB and the service. **Suggested fix:** Add a `LIMIT` constant (and a note that pagination is post-v1) to match the defensive pattern used for plants.
- `internal/service/seed_lots.go:93-114` and `internal/store/seed_lots.go:159-184` — `GetSeedLot` and `UpdateSeedLot` each call `fillRemaining` for a single lot, but `fillRemaining` always executes `SeedLotUsage(ctx, actorID)` which fetches **every** active planting attributed to **any** of the actor's lots. For a single-lot read this is unnecessary work: if a user has 50 lots with hundreds of total plantings, computing `derivedCount` for every planting just to sum one lot's usage is wasteful. **Suggested fix:** Add a lot-ID filter parameter to `SeedLotUsage` so single-lot reads can scope the query; at minimum pre-filter the `uses` slice to only rows whose `LotID` is in the requested set.
</details>
<details><summary><b>🧯 Error handling & edge cases</b> — Blocking issues found</summary>
**Blocking issues found**
- **`internal/service/seed_lots.go:182-184`** — `UpdateSeedLot` returns a version-conflict response without computing `Used`/`Remaining`. When the store returns `(currentRow, ErrVersionConflict)`, the service immediately returns `updated, err` and skips `fillRemaining`. The conflict response therefore leaves `Used=0` and `Remaining=0` (zero values) instead of the derived amounts. This is inconsistent with `GetSeedLot` and `ListSeedLots`, which always populate the fields, and the API handler (`internal/api/seed_lots.go:161-168`) forwards that incomplete row to the client. Verified by reading `UpdateSeedLot` in `service/seed_lots.go` and comparing with `UpdatePlanting` in `service/plantings.go:167-174`, which enriches its conflict response.
- **`internal/api/seed_lots.go:60`** — `seedLotUpdateRequest.Version` only declares `binding:"required"`, omitting `min=1`. The plant (`plants.go:49`) and planting (`plantings.go:76`) update requests both enforce `binding:"required,min=1"`. A version of `0` or a negative value therefore passes API validation and reaches the service, where it will fail the SQL `version = ?` guard and surface as a version conflict instead of an early `INVALID_INPUT` error. Verified by comparing the struct tags across the three update request types.
</details>
</details>
<details><summary><b>opencode/glm-5.2:cloud</b> (opencode) — Blocking issues found</summary>
**Verdict: Blocking issues found** — 5 reviewers: security, correctness, maintainability, performance, error-handling
<details><summary><b>🔒 Security</b> — No material issues found</summary>
VERDICT: No material issues found
I reviewed this change through the security lens — authn/authz, injection, XSS via the `sourceUrl` clickable link, SSRF, and existence-masking. Verified each path by reading the actual checked-out code, not just the diff hunks.
- **`sourceUrl` XSS protection is sound.** `validSourceURL` (`internal/service/seed_lots.go:299`) rejects everything except empty or absolute `http`/`https` with a non-empty host. I traced the rejection table in the test (`seed_lots_test.go:174`): `javascript:`, `JavaScript:` (Go's `url.Parse` lowercases the scheme, so the case variant is caught), `data:`, schemeless `//evil…`, relative `/seeds/garlic`, `file:`, and host-less `https://` are all refused. The same check is reused for `plants.source_url` via `finalizePlant` (`plants.go:206`), so neither surface accepts a script URL. Verified that built-ins can't be used as an injection vector either: `writablePlant` returns `ErrForbidden` for `owner_id IS NULL` (`plants.go:104`), so a user can't write a `javascript:` link onto a built-in row.
- **Authorization on seed lots is consistently enforced.** `ownSeedLot` (`seed_lots.go:119`) loads then checks `l.OwnerID == actorID`, returning `ErrNotFound` otherwise (existence masked, matching the project convention). `GetSeedLot`, `UpdateSeedLot`, `DeleteSeedLot` all go through `ownSeedLot`; `ListSeedLots`/`ListSeedLotsForOwner` filter by `owner_id = ?` (`store/seed_lots.go:64`). The `seed-lots` route group is behind `requireAuth()` (`api.go:129`). I confirmed `mustActor(c).ID` is the session actor in every handler.
- **Planting→lot attribution is checked, not trusted, and re-checked on update.** `seedLotForPlanting` (`seed_lots.go:207`) requires the lot be the actor's *and* be a lot of the plant being planted; on `UpdatePlanting` it runs against the post-patch `pl.PlantID` so a plant swap can't leave a stale cross-variety attribution (`plantings.go:162`). Another user's lot is normalized to `ErrInvalidInput`, not leaked. A shared editor attributing a plop in someone else's garden to *their own* lot is allowed by design and not an authz bypass — the lot, not the garden, is the unit of ownership.
- **No injection / SSRF.** All store queries use `?` placeholders (`store/seed_lots.go`, `store/plantings.go`, `store/plants.go`); `plantID` query param is parsed with bounds checking (`api/seed_lots.go:104`). `sourceUrl` is stored and rendered as a link; the server never fetches it, so there is no SSRF surface.
- **Defense in depth on usage accounting.** `SeedLotUsage` (`store/seed_lots.go:159`) joins `seed_lots sl` and filters `sl.owner_id = ?`, so even a hypothetical cross-owner attribution (prevented upstream) would not inflate another user's `remaining`.
- **`parseNullable` / `json.RawMessage` handling** is a pure decode with no eval/deserialization-of-types risk; `encoding/json` only.
No security findings to report within this lens.
</details>
<details><summary><b>🎯 Correctness</b> — Minor issues</summary>
VERDICT: Minor issues
Overall the `remaining` derivation, attribution re-checks, URL validation, version-guard contract, and `ON DELETE SET NULL` behavior are all correct. The `remaining` formula is right (sum of effective count = explicit `count` else `derivedCount(radius, spacing)`; only `removed_at IS NULL` rows), and it lives in one place (`fillRemaining`). The mismatched-lot and not-your-lot rejections on both create and update are correct. No correctness issue in the store layer's column/scan ordering, the version-conflict path, or the migration.
One real correctness bug, all in `internal/service/plantings.go:162`:
- **Shared editors can no longer edit existing lot-attributed plantings.** `UpdatePlanting` re-runs `seedLotForPlanting(ctx, actorID, pl.SeedLotID, pl.PlantID)` on every update (line 162). `seedLotForPlanting` → `ownSeedLot` (seed_lots.go:119-128) returns `ErrNotFound` for any lot the actor doesn't own, which `seedLotForPlanting` (seed_lots.go:211-213) converts to `ErrInvalidInput`. The update path already special-cases the plant visibility for exactly this reason (the comment at plantings.go:138-143 calls out "a shared editor in the owner's garden using the owner's private plant"), gating the `visiblePlant` re-check on `pl.PlantID != originalPlantID` — but the lot check at line 162 has no equivalent carve-out. So a shared editor who edits anything about an existing planting (label, radius, soft-remove, anything) — where the owner had attributed that plop to one of the owner's lots — now gets `ErrInvalidInput` and cannot save the edit, even when not touching `seedLotId` at all. The asymmetry is the bug: the actor can legitimately create plantings they couldn't re-validate, and the no-op re-validation on edit refuses a state that came from elsewhere. Verified by reading `UpdatePlanting` and the `seedLotForPlanting`/`ownSeedLot` chain. Fix: gate the re-check the same way the plant visibility is gated — only enforce the lot attribution when `patch.SetSeedLotID` is true (the actor is actually trying to set it), or when the plant actually changed (the swap case the comment at line 160-161 cares about). Confidence high that the refusal is real; the precise fix shape is a judgement call.
</details>
<details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary>
**VERDICT: Minor issues**
- **`internal/api/seed_lots.go:86` — `parseNullable` duplicates `parseNullableString`/`parseNullableInt`.** The new generic helper at `seed_lots.go:86` is functionally equivalent to the pre-existing `parseNullableString` (`objects.go:93`) and `parseNullableInt` (`plants.go:69`): absent → `(nil, false)`, `null` → `(nil, true)`, otherwise the decoded value. `plantings.go:66` already uses the generic `parseNullable[int64]` for `seedLotId` while lines 54, 58, 62, 70 of the same `toPatch` still call the typed helpers — a half-migration within a single function. Consolidate to the generic and delete the two typed helpers, or revert to the typed ones; the mix is the worst of both.
- **`internal/service/plants.go:201` — plant validation reaches into a lot-named constant.** `finalizePlant` checks `len(p.Vendor) > maxLotTextLen`, but `maxLotTextLen` is declared in `seed_lots.go:18` alongside the other `maxLot*` constants, while `plants.go` keeps its own `maxPlantNameLen`/`maxPlantNotesLen` (`plants.go:15-16`). A reader of `plants.go` sees a "lot" constant enforcing a plant field and must grep across files to find it. Same cross-file dependency with `validSourceURL` (defined `seed_lots.go:299`, called `plants.go:206`). These are shared provenance rules now — lift them into a neutral location (e.g. `provenance.go`) or rename to `maxProvenanceTextLen` rather than having `plants.go` depend on `seed_lots.go` for its validation primitives.
- **`internal/service/seed_lots_test.go:345` — `intPtr` duplicates `ptrInt`.** `plants_test.go:12` defines `func ptrInt(n int) *int { return &n }`; the new test file adds `func intPtr(v int) *int { return &v }` — same helper, different name, same package. Drop `intPtr` and use `ptrInt`, or pick one name and consolidate.
</details>
<details><summary><b>⚡ Performance</b> — Minor issues</summary>
I'll verify both findings against the actual code.
Both findings confirmed. Let me verify the analogous plants/gardens list queries have LIMIT caps.
Both findings confirmed against the actual code:
- **Finding 1** (N+1 in `fillRemaining`/`GetSeedLot`): Confirmed. `GetSeedLot` (line 131) calls `fillRemaining` (line 137) with a single-element slice, and `fillRemaining` (line 93) calls `s.store.SeedLotUsage(ctx, actorID)` (line 97) which returns *all* active plantings for the owner across all lots/gardens (store line 159-166), then re-sums the entire slice (lines 101-108) regardless of how many lots were requested. Looping callers become quadratic.
- **Finding 2** (missing LIMIT): Confirmed. `ListSeedLotsForOwner` (store line 63) has no `LIMIT`, while `ListPlantsForActor` (plants.go:73, `maxPlantsListed=5000`) and `ListGardensForActor` (gardens.go:93, `maxGardensListed=1000`) both cap results.
VERDICT: Minor issues
- `internal/service/seed_lots.go:131` (`GetSeedLot`) — for a single-lot fetch, `fillRemaining` issues `SeedLotUsage`, which loads and scans **every active planting attributed to any of the owner's lots** across all gardens, then aggregates them in Go. Fetching one lot therefore scans the actor's entire planting history; calling `GetSeedLot` in a loop (e.g. an index page rendering lots one by one) is quadratic in `plantings × lots`. Verified: `SeedLotUsage` (`internal/store/seed_lots.go:159`) takes only `ownerID` and returns *all* of the owner's usages, and `fillRemaining` (`internal/service/seed_lots.go:93`) re-sums the whole slice each call. Suggested fix: add a `SeedLotUsageForLots(ownerID, lotIDs []int64)` store path that filters `pl.seed_lot_id IN (...)`, or compute `used` for all of the owner's lots once and index into it — the single-lot read then pays O(1) over a pre-grouped map rather than re-scanning every planting.
- `internal/store/seed_lots.go:63` (`ListSeedLotsForOwner`) — unbounded SELECT (no `LIMIT`), unlike the analogous `ListPlantsForActor` (`plants.go:73`, `maxPlantsListed=5000`) and `ListGardensForActor` (`gardens.go:93`, `maxGardensListed=1000`), both of which cap with the same "household scale, pagination post-v1" rationale. A user accumulating many lots over years has no upper bound. Verified by grep: this is the only owner-scoped list query in `internal/store` without a `LIMIT`. Suggested fix: add `LIMIT N` (e.g. 5000) consistent with the other list endpoints, or note explicitly why seed lots are expected to stay bounded when plants/gardens are not.
The N+1 in `fillRemaining` is the material one; the missing cap is a minor consistency nit given the household-scale assumption stated elsewhere.
</details>
<details><summary><b>🧯 Error handling & edge cases</b> — Blocking issues found</summary>
Both findings verified against the actual source.
Finding 1 confirmed: `UpdatePlanting` (plantings.go:162) calls `s.seedLotForPlanting` unconditionally; `seedLotForPlanting` (seed_lots.go:207-219) → `ownSeedLot` (119-128) returns `ErrNotFound` for non-owners, converted to `ErrInvalidInput` at line 213. The editor path is reachable: `objectForRole(..., roleEditor)` at line 130, `effectiveGardenRole` returns `roleEditor` for non-owners (gardens.go:102-103). Contrast with the `PlantID` branch which is gated on `pl.PlantID != originalPlantID` (line 144); `SeedLotID` has no such gate — always re-checked. A shared editor editing the owner's lot-attributed plop without touching `seedLotId` is rejected.
Finding 2 confirmed: `RestorePlanting` (store/plantings.go:88-103) re-inserts `p.SeedLotID` verbatim from the revision snapshot (line 97). `ON DELETE SET NULL` only nulls the live row, not the historical snapshot. Revert of a planting delete routes through `RestorePlanting` (revisions.go:549). After restore with a dangling `seed_lot_id`, the next `UpdatePlanting` hits `seedLotForPlanting` → `ownSeedLot` → `GetSeedLot` returns `ErrNotFound` → `ErrInvalidInput`, bricking the plop against edits.
## VERDICT: Blocking issues found
- **`internal/service/plantings.go:162` — shared-garden editors can no longer update any lot-attributed planting.** `UpdatePlanting` runs `s.seedLotForPlanting(ctx, actorID, pl.SeedLotID, pl.PlantID)` on every update. `seedLotForPlanting` (seed_lots.go:207) calls `ownSeedLot`, which returns `ErrNotFound` for anyone but the lot's owner (seed_lots.go:124), and `seedLotForPlanting` converts that to `ErrInvalidInput` (seed_lots.go:213). A garden shared with an `editor` is reachable: `effectiveGardenRole` returns `roleEditor` for non-owners (gardens.go:102-103), and `UpdatePlanting` authorizes via `objectForRole(..., roleEditor)` (plantings.go:130). An editor can move, soft-remove, or relabel a plop; but if that plop carries the owner's `seedLotId` (the only kind it can carry — lots are private, per seed_lots.go:14), the editor's update is now rejected with `ErrInvalidInput` even when they never touch `seedLotId`. This is asymmetric with the adjacent `PlantID` handling, which is deliberately gated on `pl.PlantID != originalPlantID` (plantings.go:144) for exactly this shared-editor reason (comment at plantings.go:138-142). The minimal fix mirroring that gating is to re-check `seedLotForPlanting` only when `patch.SetSeedLotID` is true.
- **`internal/service/plantings.go:162` — re-check also bricking a plop whose lot was deleted after a revert.** `seedLotForPlanting` returns `ErrInvalidInput` when `ownSeedLot` returns `ErrNotFound` (seed_lots.go:213). The migration sets `seed_lot_id` to NULL on lot deletion (`ON DELETE SET NULL`), so the live path is safe — but the revision snapshot still holds the old lot id, and `RestorePlanting` re-inserts `p.SeedLotID` verbatim from the snapshot (store/plantings.go:97) without nulling a dangling reference. Revert of a planting delete goes through `RestorePlanting` (revisions.go:549). After such a restore, the restored row carries a `seed_lot_id` pointing at a non-existent lot, and the very next `UpdatePlanting` hits `ownSeedLot → ErrNotFound → ErrInvalidInput`, permanently bricking the plop against edits. Suggested fix: in `seedLotForPlanting`, treat `ErrNotFound` from `ownSeedLot` as "clear the dangling link to NULL and allow" rather than `ErrInvalidInput`, OR null out `SeedLotID` during restore when the lot no longer exists.
</details>
</details>
<details><summary><b>opencode/kimi-k2.6:cloud</b> (opencode) — Blocking issues found</summary>
**Verdict: Blocking issues found · ⚠️ 1/5 lens(es) errored** — 5 reviewers: security, correctness, maintainability, performance, error-handling
<details><summary><b>🔒 Security</b> — No material issues found</summary>
No material issues found
The PR's security posture is sound:
- **URL validation (`validSourceURL`)** correctly rejects `javascript:`, `data:`, `file:`, schemeless, relative, and host-less URLs while allowing empty strings (clearing the field). The scheme check is case-insensitive because `url.Parse` normalizes schemes to lowercase. This is verified by the rejection table in `TestSourceURLValidation`.
- **Authn/authz** is consistent: every seed-lot handler gates on `mustActor(c).ID`; `ownSeedLot` masks other users' lots as `ErrNotFound`; `seedLotForPlanting` enforces that a planting's lot belongs to the actor and matches the plant being planted, preventing cross-variety attribution that would corrupt `remaining`.
- **Input validation** is comprehensive: length caps, unit whitelist, quantity/date/germination bounds, and finite-number checks all live in `finalizeSeedLot`/`finalizePlant`, shared by create and update paths. No bypass path exists—patch fields flow through `apply*Patch` into the same finalizers.
- **SQL** is fully parameterized; no injection vectors.
</details>
<details><summary><b>🎯 Correctness</b> — ⚠️ could not complete</summary>
⚠️ This reviewer failed to complete: opencode run returned no output
</details>
<details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary>
Minor issues
- **`internal/api/seed_lots.go:86`** — `parseNullable[T]` duplicates the functionality of the existing `parseNullableInt` (`plants.go:69`) and `parseNullableString` (`objects.go:93`) helpers, yet it is defined in `seed_lots.go` instead of alongside them. Worse, `plantings.go` now mixes the new generic call (`parseNullable[int64]`) with the old non-generic ones (`parseNullableInt`, `parseNullableString`) in the same `toPatch` method, leaving the codebase in a half-migrated state. **Fix:** place the generic helper in `objects.go` with the other nullable parsers, replace the old ones with thin wrappers or remove them, and migrate `plantings.go` consistently.
- **`internal/service/seed_lots_test.go:345`** — `func intPtr(v int) *int` duplicates `ptrInt` in `plants_test.go:12` (same package, different name). Having two names for the same helper is needless churn for future readers. **Fix:** delete `intPtr` and use `ptrInt` everywhere in the package.
- **`internal/service/plants_test.go:187`** — The only changes to this file are whitespace re-alignment of `//` comments. `CLAUDE.md` explicitly calls this file out as "already unformatted on `main` — leave it alone unless you're touching it, so the diff stays about your change." Reformatting it anyway adds noise and violates an established project convention. **Fix:** revert the formatting-only changes.
- **`internal/service/seed_lots.go:146`** — `CreateSeedLot` never calls `s.record(...)`, and `domain.go` has no `EntitySeedLot` constant. `CLAUDE.md` states as an invariant: *"Every service mutation lands in history (#48). If you add one, record it."* The existing `record` helper is garden-scoped, but seed lots are actor-scoped; even so, the PR neither records them nor explains why they are exempt, which leaves the history surface incomplete and breaks a documented project convention. **Fix:** add `EntitySeedLot` to `domain.go` and record seed-lot mutations (this may require extending the history seam to support actor-scoped entities).
- **`internal/service/seed_lots.go:172`** — `UpdateSeedLot` never calls `s.record(...)`, and `domain.go` has no `EntitySeedLot` constant. `CLAUDE.md` states as an invariant: *"Every service mutation lands in history (#48). If you add one, record it."* The existing `record` helper is garden-scoped, but seed lots are actor-scoped; even so, the PR neither records them nor explains why they are exempt, which leaves the history surface incomplete and breaks a documented project convention. **Fix:** add `EntitySeedLot` to `domain.go` and record seed-lot mutations (this may require extending the history seam to support actor-scoped entities).
- **`internal/service/seed_lots.go:196`** — `DeleteSeedLot` never calls `s.record(...)`, and `domain.go` has no `EntitySeedLot` constant. `CLAUDE.md` states as an invariant: *"Every service mutation lands in history (#48). If you add one, record it."* The existing `record` helper is garden-scoped, but seed lots are actor-scoped; even so, the PR neither records them nor explains why they are exempt, which leaves the history surface incomplete and breaks a documented project convention. **Fix:** add `EntitySeedLot` to `domain.go` and record seed-lot mutations (this may require extending the history seam to support actor-scoped entities).
</details>
<details><summary><b>⚡ Performance</b> — Blocking issues found</summary>
**Blocking issues found**
* **`internal/store/seed_lots.go:63`** — `ListSeedLotsForOwner` issues an unbounded `SELECT` with no `LIMIT`. Every other list endpoint in the codebase (`gardens`, `plants`, `shares`) carries a defensive cap (e.g. `maxGardensListed = 1000`, `maxPlantsListed = 5000`). A gardener who records seed purchases year-over-year will see response size and memory use grow without bound. **Fix:** add a `maxSeedLotsListed` constant and `LIMIT ?` to the query.
* **`internal/service/seed_lots.go:93`** / `internal/store/seed_lots.go:159` — `fillRemaining` fetches **all** of the actor's active plantings via `SeedLotUsage(ctx, actorID)` even when called from `GetSeedLot` or `UpdateSeedLot`, which only need one lot. This is O(total active plantings across every lot) I/O and computation for a single-lot read. **Fix:** add an overload or optional `lotID` filter to `SeedLotUsage` (e.g. `SeedLotUsage(ctx, actorID, lotID *int64)`) and use the targeted query in `GetSeedLot`/`UpdateSeedLot`; keep the broad query for `ListSeedLots` where it is actually needed.
</details>
<details><summary><b>🧯 Error handling & edge cases</b> — Blocking issues found</summary>
**Blocking issues found**
- **`internal/service/seed_lots.go:168` — `CreateSeedLot` returns stale `Used`/`Remaining` zero-values**
`GetSeedLot`, `ListSeedLots`, and `UpdateSeedLot` all call `fillRemaining` so the client sees the live `used`/`remaining` derived fields. `CreateSeedLot` does not, so a newly-created lot with `quantity: 100` is serialized as `{\"used\":0,\"remaining\":0,\"quantity\":100}`, which is actively misleading. The API should return a consistent shape on every verb.
**Fix:** Either call `fillRemaining` on a single-element slice before returning, or at minimum set `Used=0, Remaining=l.Quantity`.
- **`internal/service/revisions.go:448` + `internal/store/plantings.go:88` — Revert of a deleted planting hard-fails if its lot was deleted in the meantime**
`RestorePlanting` re-inserts the row with its original `seed_lot_id`. If the referenced lot was deleted after the planting was originally deleted, the FK `REFERENCES seed_lots(id)` throws a constraint error. `revertEntity` has no `blockRestore` hook for plantings, so the entire revert aborts. The PR explicitly says "retiring a lot must never destroy planting history — the plants really were in the ground," but the revert path hasn't been taught that the lot may now be gone.
**Fix:** In the planting revert ops (or `RestorePlanting` itself), check lot existence and clear `seed_lot_id` to `NULL` when the lot is missing, matching the `ON DELETE SET NULL` semantics the rest of the system relies on.
- **`internal/service/seed_lots.go:111` — `Remaining` can go negative without guard or documented behavior**
`fillRemaining` does `Quantity - Used` with no floor. If a user plants more than they bought, or later reduces `Quantity` below current usage, `Remaining` becomes negative. The field is described as "how much is left," so a negative value is surprising and could confuse display logic.
**Fix:** Cap at zero (`max(0, Quantity - Used)`) or, if negative is intentional to show over-commitment, document it explicitly in the struct comment.
</details>
</details>
</details>
<sub>Automated adversarial review by Gadfly — consensus across the model swarm. Advisory only — does not block merge.</sub>
Three real bugs, two of which would have quietly corrupted inventory numbers.
CopyGarden carried seed_lot_id into the copied plantings, so copying a garden
double-counted the original lot's usage — the copy is a plan, not a second
planting out of the same packet. It also quietly attached a private lot to a
garden that may later be shared, contradicting the "lots are never shared with a
garden" invariant this feature was built on. The copy now drops the link.
RestorePlanting restored a seed_lot_id verbatim from a history snapshot. Live
rows get their link nulled by ON DELETE SET NULL when a lot is deleted, but the
snapshot still holds the old id, so undoing a planting deletion after retiring
its lot would violate the foreign key and abort the whole revert. The revert now
drops a dangling link before restoring: the plant really was in the ground,
which is the part worth restoring.
CreateSeedLot returned a lot with Used/Remaining at their zero values, so a
packet you just bought reported "0 remaining" — the exact opposite of the truth.
The version-conflict path had the same gap, and that row is what the client
rebases on, so a 409 reporting remaining=0 is worse than no number at all. Both
fill them now.
Remaining can go negative and that is deliberate — it means more was planted
than the lot recorded buying, which happens (a miscounted packet, a lot entered
after the fact). Clamping to zero would hide the discrepancy at exactly the
moment it is worth seeing. Now documented and tested rather than incidental.
Performance and tidiness: SeedLotUsage is scoped to the lots being filled
instead of scanning every planting the owner has, so reading one lot is one
lot's work; ListSeedLotsForOwner gained the LIMIT every other list read has;
parseNullable moved out of seed_lots.go into the shared request helpers, since
every nullable field in the API needs that three-way absent/null/value
distinction; finalizePlant validates against its own maxPlantVendorLen rather
than borrowing a constant named for seed lots; seedLotForPlanting became
checkSeedLotForPlanting, since it validates rather than fetches; and the
duplicate intPtr test helper gave way to the existing ptrInt.
Declined: recording seed-lot mutations in the revision history. change_sets are
anchored to a garden and lots are user-scoped, deliberately — they belong to
you, not to any one garden, so there is no garden to record them against.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
Fixed in 4a8fbc6. Three real bugs, two of which would have quietly corrupted inventory numbers — plus one I'd already caught and fixed before this landed.
CopyGarden carried seed_lot_id into the copy
Caught by glm-5.2, and it's the best finding here. Copying a garden double-counted the original lot's usage — the copy is a plan, not a second planting out of the same packet. Worse, it quietly attached a private lot to a garden that may later be shared, directly contradicting the "lots are never shared with a garden" invariant this whole feature is built on. The copy now drops the link, with a test asserting the original lot's used is unchanged.
RestorePlanting restored a dangling lot reference
Live rows get their link nulled by ON DELETE SET NULL when a lot is deleted — but a history snapshot still holds the old id. Undoing a planting deletion after retiring its lot would violate the foreign key and abort the entire revert. The revert now drops a dangling link before restoring: the plant really was in the ground, and that's the part worth restoring. Tested end-to-end.
Used/Remaining were unset on create and on the conflict row
Flagged by 3 models. A packet you just bought reported remaining: 0 — the exact opposite of the truth. The version-conflict path had the same gap, and that row is precisely what the client rebases on, so a 409 reporting remaining: 0 is worse than no number at all. Both fill them now.
Negative remaining is deliberate
Two models flagged it as unguarded. It means more was planted than the lot recorded buying — which genuinely happens (a miscounted packet, a lot entered after the fact). Clamping to zero would hide the discrepancy at exactly the moment it's worth seeing. Now documented and tested rather than incidental.
Already fixed before the review landed
Deleting a plant would have silently cascaded away its seed lots — vendor, cost, germination rate, gone with no warning, because seed_lots.plant_id is ON DELETE CASCADE while plantings is RESTRICT. Refused with ErrPlantInUse in c3e6e5c.
Tidiness
SeedLotUsage is scoped to the lots being filled instead of scanning every planting the owner has (4 models). ListSeedLotsForOwner gained the LIMIT every other list read has. parseNullable moved into the shared request helpers — every nullable API field needs that absent/null/value distinction, so it shouldn't live in whichever file wanted it first. finalizePlant validates against its own maxPlantVendorLen rather than borrowing a constant named for seed lots. seedLotForPlanting → checkSeedLotForPlanting, since it validates rather than fetches. Duplicate intPtr gave way to the existing ptrInt.
Declined
Seed-lot mutations aren't in the revision history.change_sets are anchored to a garden; lots are user-scoped deliberately — they belong to you, not to any one garden, so there's no garden to record them against.
Version lacks a min=1 binding.binding:"required" already rejects the zero value on an int64, and a negative version fails the guard on its own.
Concurrent lot deletion during update. That path already resolves correctly: ErrNoRows → GetSeedLot → ErrNotFound → 404, which is the right answer for a row that's gone.
go test ./..., go vet ./..., gofmt green.
Fixed in `4a8fbc6`. Three real bugs, two of which would have quietly corrupted inventory numbers — plus one I'd already caught and fixed before this landed.
### `CopyGarden` carried `seed_lot_id` into the copy
Caught by `glm-5.2`, and it's the best finding here. Copying a garden **double-counted the original lot's usage** — the copy is a plan, not a second planting out of the same packet. Worse, it quietly attached a *private* lot to a garden that may later be shared, directly contradicting the "lots are never shared with a garden" invariant this whole feature is built on. The copy now drops the link, with a test asserting the original lot's `used` is unchanged.
### `RestorePlanting` restored a dangling lot reference
Live rows get their link nulled by `ON DELETE SET NULL` when a lot is deleted — but a **history snapshot still holds the old id**. Undoing a planting deletion after retiring its lot would violate the foreign key and abort the entire revert. The revert now drops a dangling link before restoring: the plant really was in the ground, and that's the part worth restoring. Tested end-to-end.
### `Used`/`Remaining` were unset on create and on the conflict row
Flagged by 3 models. A packet you just bought reported **`remaining: 0`** — the exact opposite of the truth. The version-conflict path had the same gap, and that row is precisely what the client rebases on, so a 409 reporting `remaining: 0` is worse than no number at all. Both fill them now.
### Negative `remaining` is deliberate
Two models flagged it as unguarded. It means more was planted than the lot recorded buying — which genuinely happens (a miscounted packet, a lot entered after the fact). Clamping to zero would hide the discrepancy at exactly the moment it's worth seeing. Now documented and tested rather than incidental.
### Already fixed before the review landed
Deleting a plant would have silently cascaded away its seed lots — vendor, cost, germination rate, gone with no warning, because `seed_lots.plant_id` is `ON DELETE CASCADE` while `plantings` is `RESTRICT`. Refused with `ErrPlantInUse` in `c3e6e5c`.
### Tidiness
`SeedLotUsage` is scoped to the lots being filled instead of scanning every planting the owner has (4 models). `ListSeedLotsForOwner` gained the `LIMIT` every other list read has. `parseNullable` moved into the shared request helpers — every nullable API field needs that absent/null/value distinction, so it shouldn't live in whichever file wanted it first. `finalizePlant` validates against its own `maxPlantVendorLen` rather than borrowing a constant named for seed lots. `seedLotForPlanting` → `checkSeedLotForPlanting`, since it validates rather than fetches. Duplicate `intPtr` gave way to the existing `ptrInt`.
### Declined
- **Seed-lot mutations aren't in the revision history.** `change_sets` are anchored to a garden; lots are user-scoped *deliberately* — they belong to you, not to any one garden, so there's no garden to record them against.
- **`Version` lacks a `min=1` binding.** `binding:"required"` already rejects the zero value on an `int64`, and a negative version fails the guard on its own.
- **Concurrent lot deletion during update.** That path already resolves correctly: `ErrNoRows` → `GetSeedLot` → `ErrNotFound` → 404, which is the right answer for a row that's gone.
`go test ./...`, `go vet ./...`, `gofmt` green.
steve
merged commit 78a04672b5 into main2026-07-21 05:39:25 +00:00
steve
deleted branch feat/seed-lots2026-07-21 05:39:25 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #50. Opens Phase 1 of #58.
"German Red Garlic" now links back to the Johnny's page it came from, and pansy knows how much is left.
Two modelling calls, both of which read as omissions unless stated
The plant catalog stays flat. No species→variety hierarchy. A row in your catalog just is the thing you plant; the built-in "Garlic" is a generic starting point you clone. A hierarchy buys taxonomy nobody asked for and complicates every join.
Inventory belongs to a purchase, not to a variety. You might buy the same garlic from two vendors in two years at two prices and two germination rates — quantity columns on
plantswould collapse all of that into one number and lose it. So lots get their own table, andowner_idsits on the lot rather than being inherited from the plant: you can record buying generic built-in Garlic from Johnny's and the record is still yours alone. Lots are never shared along with a garden.remainingis derived, never storedquantity− the summed effective count of the active plantings attributed to the lot. The alternative — decrementing a column when you plant — drifts the moment anything edits or removes a planting behind its back, and once it has drifted there's no way to tell what the true number was. Removing a plop gives the seed back with nothing having to remember to.The usage query returns raw radius/count/spacing rather than a
SUM, because the effective-count formula (explicit count, else π·r²/spacing²) lives in the service. Reproducing it in SQL would guarantee the two drift apart.source_urlis untrusted input, not a tidy stringValidated as http(s) with a host, on both plants and lots. It renders as a clickable link, so that check is load-bearing: without it a stored
javascript:URL becomes script execution the moment someone clicks it. Schemeless (//evil.example.com) and relative URLs are refused too — they'd resolve against pansy's own origin, which is never what a vendor link means. There's a test with the whole rejection table.Attribution is checked, not trusted
A planting's lot must be the actor's and a lot of the plant being planted. Attributing a garlic planting to a tomato lot would silently corrupt every
remainingdownstream, so it's refused rather than stored — and re-checked on update, since a plant swap can invalidate a previously-valid attribution. Another user's lot isErrInvalidInput, not a leak of its existence.Deleting a lot leaves its plantings alone with a null link (
ON DELETE SET NULL): the plants really were in the ground, whatever happened to the record of the packet.API
Plus
sourceUrl/vendoron plants andseedLotIdon planting create/update.Verification
9 new tests, one per acceptance criterion: two lots of one plant tracking independently,
remainingreturning when a plop is soft-removed, a lot on a built-in plant staying private (ErrNotFoundfor everyone else, on every verb), the URL rejection table, provenance round-tripping while the 32 built-ins stay untouched, mismatched-lot rejection, planting history surviving lot deletion, and the version guard.go test ./...,go vet ./...,gofmtgreen.🤖 Generated with Claude Code
https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
🪰 Gadfly — live review status
5/5 reviewers finished · updated 2026-07-21 05:34:55Z
claude-code/sonnet· claude-code — ✅ doneglm-5.2:cloud· ollama-cloud — ✅ donekimi-k2.6:cloud· ollama-cloud — ✅ doneopencode/glm-5.2:cloud· opencode — ✅ doneopencode/kimi-k2.6:cloud· opencode — ✅ doneLive status board. Findings are posted in each model's own comment. Advisory only — does not block merge.
🪰 Gadfly consensus review — 21 inline findings on changed lines. See the consensus comment for the full ranked summary.
Advisory only — does not block merge.
@@ -0,0 +57,4 @@CostCents json.RawMessage `json:"costCents"`GerminationPct json.RawMessage `json:"germinationPct"`Notes *string `json:"notes"`Version int64 `json:"version" binding:"required"`🟠 seedLotUpdateRequest.Version missing min=1 binding validation
error-handling · flagged by 1 model
internal/api/seed_lots.go:60—seedLotUpdateRequest.Versiononly declaresbinding:"required", omittingmin=1. The plant (plants.go:49) and planting (plantings.go:76) update requests both enforcebinding:"required,min=1". A version of0or a negative value therefore passes API validation and reaches the service, where it will fail the SQLversion = ?guard and surface as a version conflict instead of an earlyINVALID_INPUTerror. Verified by comparing the struct tags acr…🪰 Gadfly · advisory
@@ -0,0 +83,4 @@// parseNullable decodes any JSON value into (value, present) for a nullable// column. Absent → (nil, false); null → (nil, true); otherwise the decoded value.func parseNullable[T any](raw json.RawMessage) (*T, bool, error) {🟠 Generic parseNullable helper trapped in seed_lots.go but used cross-file
maintainability · flagged by 4 models
internal/api/seed_lots.go:86— The new genericparseNullable[T any]is introduced inseed_lots.gobut immediately consumed byplantings.go. This creates a hidden cross-file dependency: a handler in the planting file relies on a helper tucked inside a seed-lot file. The generic is cleaner than the existingparseNullableInt/parseNullableStringduplicates, but it should live in a shared API utility file (or at least in a file whose name doesn't imply seed-lot ownership). Fix:…🪰 Gadfly · advisory
@@ -150,1 +159,4 @@}// Re-checked on every update, not just when the lot changes: a plant swap can// leave a previously-valid attribution pointing at a lot of the wrong variety.if err := s.seedLotForPlanting(ctx, actorID, pl.SeedLotID, pl.PlantID); err != nil {🔴 UpdatePlanting re-validates the owner's lot on every edit, breaking shared editors who touch an existing lot-attributed planting
correctness, error-handling · flagged by 1 model
One real correctness bug, all in
internal/service/plantings.go:162:🪰 Gadfly · advisory
@@ -190,3 +187,1 @@{Name: "x", Category: domain.CategoryHerb, SpacingCM: -5, Color: "#4a7c3f", Icon: "🌿"}, // negative spacing{Name: "x", Category: domain.CategoryHerb, SpacingCM: 10, Color: "nope", Icon: "🌿"}, // bad color{Name: "x", Category: domain.CategoryHerb, SpacingCM: 10, Color: "#4a7c3f", Icon: ""}, // empty icon{Name: "", Category: domain.CategoryHerb, SpacingCM: 10, Color: "#4a7c3f", Icon: "🌿"}, // empty name🟡 Broken comment alignment in table-driven test
maintainability · flagged by 2 models
internal/service/plants_test.go:187— Formatting regression: the comment-aligned spacing in the table-driven test was broken (columns no longer align with their//comments), making the test harder to scan.gofmtwon't fix comment alignment. Fix: Re-align the comment offsets to restore the readable table layout.🪰 Gadfly · advisory
@@ -0,0 +90,4 @@// when you plant — drifts the moment anything edits or removes a planting behind// its back, and once it has drifted there is no way to tell what the true number// was. Attribution plus arithmetic can always be recomputed and audited.func (s *Service) fillRemaining(ctx context.Context, actorID int64, lots []domain.SeedLot) error {🟠 GetSeedLot and UpdateSeedLot fetch all actor plantings to compute remaining for one lot
performance · flagged by 2 models
internal/service/seed_lots.go:93-114andinternal/store/seed_lots.go:159-184—GetSeedLotandUpdateSeedLoteach callfillRemainingfor a single lot, butfillRemainingalways executesSeedLotUsage(ctx, actorID)which fetches every active planting attributed to any of the actor's lots. For a single-lot read this is unnecessary work: if a user has 50 lots with hundreds of total plantings, computingderivedCountfor every planting just to sum one lot's usage is wasteful. *…🪰 Gadfly · advisory
@@ -0,0 +94,4 @@if len(lots) == 0 {return nil}uses, err := s.store.SeedLotUsage(ctx, actorID)🟠 GetSeedLot/UpdateSeedLot recompute usage over all of the owner's lots instead of the requested one
performance · flagged by 1 model
🪰 Gadfly · advisory
@@ -0,0 +108,4 @@}for i := range lots {lots[i].Used = used[lots[i].ID]lots[i].Remaining = lots[i].Quantity - lots[i].Used🟠 fillRemaining can produce negative Remaining with no guard or docs
error-handling · flagged by 1 model
internal/service/seed_lots.go:111—Remainingcan go negative without guard or documented behaviorfillRemainingdoesQuantity - Usedwith no floor. If a user plants more than they bought, or later reducesQuantitybelow current usage,Remainingbecomes negative. The field is described as "how much is left," so a negative value is surprising and could confuse display logic. Fix: Cap at zero (max(0, Quantity - Used)) or, if negative is intentional to show over-commitment,…🪰 Gadfly · advisory
@@ -0,0 +119,4 @@func (s *Service) ownSeedLot(ctx context.Context, actorID, lotID int64) (*domain.SeedLot, error) {l, err := s.store.GetSeedLot(ctx, lotID)if err != nil {return nil, err // ErrNotFound⚪ Comment
// ErrNotFoundon ownSeedLot's return is inaccurate for non-NotFound wrapped store errorsmaintainability · flagged by 1 model
internal/service/seed_lots.go:122— misleading inline comment.return nil, err // ErrNotFoundasserts the returned error isErrNotFound, butownSeedLotreturns whateverstore.GetSeedLotreturns, which includes wrapped non-NotFound errors (the store wraps scan failures infmt.Errorf("store: get seed lot: %w", err)). The comment is wrong for those paths and could mislead a reader tracing error handling. Verified by readingGetSeedLot(store/seed_lots.go:48-58), which only maps…🪰 Gadfly · advisory
@@ -0,0 +128,4 @@}// GetSeedLot returns one of the actor's own lots, with Used/Remaining filled in.func (s *Service) GetSeedLot(ctx context.Context, actorID, lotID int64) (*domain.SeedLot, error) {🟠 GetSeedLot/UpdateSeedLot over-fetch all owner's active plantings via unscoped SeedLotUsage to compute one lot's remaining
performance · flagged by 2 models
internal/service/seed_lots.go:131(GetSeedLot) and:186(UpdateSeedLot) — single-lot reads over-fetch the entire owner's active plantings. Both paths build a one-element slice and calls.fillRemaining(ctx, actorID, one), which (seed_lots.go:97) callss.store.SeedLotUsage(ctx, actorID). That store query (internal/store/seed_lots.go:159-166) selects every active planting joined across all the owner's lots (WHERE sl.owner_id = ? AND pl.removed_at IS NULL, no `pl.seed_lot_id…🪰 Gadfly · advisory
@@ -0,0 +143,4 @@// CreateSeedLot records a purchase owned by the actor. The plant must be one the// actor can see — a built-in or their own — since you can perfectly well buy// generic Garlic from a vendor.func (s *Service) CreateSeedLot(ctx context.Context, actorID int64, in SeedLotInput) (*domain.SeedLot, error) {🟠 Seed-lot mutations not recorded in history, breaking project invariant
maintainability · flagged by 1 model
internal/service/seed_lots.go:146—CreateSeedLotnever callss.record(...), anddomain.gohas noEntitySeedLotconstant.CLAUDE.mdstates as an invariant: "Every service mutation lands in history (#48). If you add one, record it." The existingrecordhelper is garden-scoped, but seed lots are actor-scoped; even so, the PR neither records them nor explains why they are exempt, which leaves the history surface incomplete and breaks a documented project convention. Fix: a…🪰 Gadfly · advisory
@@ -0,0 +165,4 @@if err := finalizeSeedLot(l); err != nil {return nil, err}return s.store.CreateSeedLot(ctx, l)🔴 CreateSeedLot returns a lot with Remaining/Used unset (reports remaining: 0 instead of the purchased quantity)
correctness, error-handling · flagged by 2 models
internal/service/seed_lots.go:168—CreateSeedLotreturnss.store.CreateSeedLot(ctx, l)directly without callings.fillRemaining.domain.SeedLot.Remaining/Usedare computed-only fields, zero-valued at construction, so a freshly created lot is returned withremaining: 0regardless of the purchased quantity (e.g. buying 100 seeds shows"remaining": 0in the create response instead of 100). Confirmed no test checksRemaining/Usedon theCreateSeedLotreturn value — `seed_lot…🪰 Gadfly · advisory
@@ -0,0 +169,4 @@}// UpdateSeedLot applies a partial, version-guarded patch to the actor's own lot.func (s *Service) UpdateSeedLot(ctx context.Context, actorID, lotID int64, patch SeedLotPatch, version int64) (*domain.SeedLot, error) {🔴 CreateSeedLot returns lot without computed Used/Remaining fields
correctness, maintainability · flagged by 2 models
internal/service/seed_lots.go:175—CreateSeedLotreturns the newly created lot without callingfillRemaining, soUsedandRemainingare both zero in the201 Createdresponse. A client that readsremainingimmediately after creation sees0instead of the lot's actualQuantity. The existing pattern inCreatePlanting(created.DerivedCount = …) shows write endpoints should return complete, derived fields.🪰 Gadfly · advisory
@@ -0,0 +179,4 @@return nil, err}l.Version = versionupdated, err := s.store.UpdateSeedLot(ctx, l)🔴 UpdateSeedLot version conflict omits Used/Remaining computation
correctness, error-handling · flagged by 3 models
internal/service/seed_lots.go:182-184—UpdateSeedLotreturns a version-conflict response without computingUsed/Remaining. When the store returns(currentRow, ErrVersionConflict), the service immediately returnsupdated, errand skipsfillRemaining. The conflict response therefore leavesUsed=0andRemaining=0(zero values) instead of the derived amounts. This is inconsistent withGetSeedLotandListSeedLots, which always populate the fields, and the API handler (`in…🪰 Gadfly · advisory
@@ -0,0 +187,4 @@if ferr := s.fillRemaining(ctx, actorID, one); ferr != nil {return nil, ferr}return &one[0], nil🔴 UpdateSeedLot version conflict returns stale Used/Remaining zeros
correctness · flagged by 1 model
internal/service/seed_lots.go:190—UpdateSeedLotreturns the version-conflict current row (return updated, err) without ever runningfillRemaining. The409 Conflictpayload therefore serializesUsed: 0andRemaining: 0, which is stale and misleading.UpdatePlantingalready solves the same problem by callingenrichDerivedon the conflict row before returning;UpdateSeedLotshould do the equivalent forUsed/Remaining.🪰 Gadfly · advisory
@@ -0,0 +193,4 @@// DeleteSeedLot removes the actor's own lot. Plantings attributed to it survive// with a null link (ON DELETE SET NULL) — the plants really were in the ground,// whatever happened to the record of the packet.func (s *Service) DeleteSeedLot(ctx context.Context, actorID, lotID int64) error {🟠 Seed-lot mutations not recorded in history, breaking project invariant
maintainability · flagged by 1 model
internal/service/seed_lots.go:196—DeleteSeedLotnever callss.record(...), anddomain.gohas noEntitySeedLotconstant.CLAUDE.mdstates as an invariant: "Every service mutation lands in history (#48). If you add one, record it." The existingrecordhelper is garden-scoped, but seed lots are actor-scoped; even so, the PR neither records them nor explains why they are exempt, which leaves the history surface incomplete and breaks a documented project convention. Fix: a…🪰 Gadfly · advisory
@@ -0,0 +204,4 @@// actor's, and must be a lot OF the plant being planted. Attributing a garlic// planting to a tomato lot is a data error that would silently corrupt every// remaining count downstream, so it's refused rather than stored.func (s *Service) seedLotForPlanting(ctx context.Context, actorID int64, lotID *int64, plantID int64) error {🟠 No check prevents attributing more plantings to a lot than its quantity, yielding negative Remaining
error-handling · flagged by 1 model
internal/service/seed_lots.go:207(seedLotForPlanting) /:111(fillRemaining): There is no check that a lot has enough quantity left before attributing a planting to it.seedLotForPlantingvalidates only ownership and plant-match, andfillRemainingcomputesRemaining = Quantity - Usedunconditionally. A user can attribute 200 plants to a 50-seed lot (or plant 100, then add 100 more) and the lot silently reports a negativeremaining. For an inventory feature this is an unhandled…🪰 Gadfly · advisory
@@ -0,0 +210,4 @@}l, err := s.ownSeedLot(ctx, actorID, *lotID)if err != nil {return domain.ErrInvalidInput // unknown or not yours: an invalid reference🟡 seedLotForPlanting name implies getter, not validation
maintainability · flagged by 1 model
internal/service/seed_lots.go:213—seedLotForPlantingis named like a getter/fetcher but actually performs validation and returns only an error. The name is confusing when scanning call sites. The comment explains the intent ("validates a planting's lot attribution"), but the function name doesn't. Fix: Rename tovalidateSeedLotForPlantingorcheckSeedLotAttributionto match its boolean/validation nature and align with other helpers in the package.🪰 Gadfly · advisory
@@ -0,0 +342,4 @@}}func intPtr(v int) *int { return &v }🟡 Duplicate intPtr test helper when ptrInt already exists in same package
maintainability · flagged by 3 models
internal/service/seed_lots_test.go:345—func intPtr(v int) *intduplicatesptrIntinplants_test.go:12(same package, different name). Having two names for the same helper is needless churn for future readers. Fix: deleteintPtrand useptrInteverywhere in the package.🪰 Gadfly · advisory
@@ -0,0 +60,4 @@// ListSeedLotsForOwner returns every lot the actor owns, newest purchase first// (undated lots last), then by id. Optionally narrowed to one plant. Always a// non-nil slice.func (d *DB) ListSeedLotsForOwner(ctx context.Context, ownerID int64, plantID *int64) ([]domain.SeedLot, error) {🔴 ListSeedLotsForOwner is unbounded: no LIMIT on query
performance · flagged by 2 models
internal/store/seed_lots.go:63—ListSeedLotsForOwnerissues an unboundedSELECTwith noLIMIT. Every other list endpoint in the codebase (gardens,plants,shares) carries a defensive cap (e.g.maxGardensListed = 1000,maxPlantsListed = 5000). A gardener who records seed purchases year-over-year will see response size and memory use grow without bound. Fix: add amaxSeedLotsListedconstant andLIMIT ?to the query.🪰 Gadfly · advisory
@@ -0,0 +66,4 @@if plantID != nil {query += ` AND plant_id = ?`args = append(args, *plantID)}🟠 ListSeedLotsForOwner has no LIMIT cap, unlike ListPlantsForActor
performance · flagged by 2 models
internal/store/seed_lots.go:69-91—ListSeedLotsForOwnerreturns an unbounded slice with no LIMIT or pagination cap. The analogousListPlantsForActorin the same store has amaxPlantsListed = 5000defensive backstop (internal/store/plants.go:62), but the seed-lots list query lacks any equivalent ceiling. A user who records many purchases over multiple years could cause unbounded memory allocation in both the DB and the service. Suggested fix: Add aLIMITconstant (and a note th…🪰 Gadfly · advisory
@@ -0,0 +115,4 @@return nil, gerr}return current, domain.ErrVersionConflict}🟡 Concurrent lot deletion during update returns underlying error, not ErrVersionConflict, so handler mis-maps it
error-handling · flagged by 1 model
internal/store/seed_lots.go:118(UpdateSeedLotconflict path): When the version-guarded update hitssql.ErrNoRows, it re-reads viaGetSeedLot; if that fails (e.g. the lot was deleted concurrently), it returns(nil, gerr)rather thanErrVersionConflict. The handler atinternal/api/seed_lots.go:162only special-casesErrVersionConflict, so a concurrent-delete-during-update surfaces as a generic service error instead of a clean 404/conflict. Minor (concurrency race), but the err…🪰 Gadfly · advisory
🪰 Gadfly review — consensus across 5 models
Verdict: Blocking issues found · 25 findings (11 with multi-model agreement)
internal/api/seed_lots.go:86internal/service/seed_lots.go:182internal/service/seed_lots_test.go:345internal/service/seed_lots.go:168internal/service/seed_lots.go:172internal/store/seed_lots.go:63internal/service/seed_lots.go:93internal/service/seed_lots.go:131internal/store/seed_lots.go:69internal/service/plants.go:201internal/service/plants_test.go:18714 single-model findings (lower confidence)
internal/service/plantings.go:162internal/service/revisions.go:448internal/service/seed_lots.go:190internal/store/gardens.go:237internal/store/plantings.go:88internal/api/seed_lots.go:60internal/service/seed_lots.go:97internal/service/seed_lots.go:111internal/service/seed_lots.go:146internal/service/seed_lots.go:196internal/service/seed_lots.go:207internal/service/seed_lots.go:213internal/store/seed_lots.go:118internal/service/seed_lots.go:122Per-model detail
claude-code/sonnet (claude-code) — Blocking issues found
Verdict: Blocking issues found — 5 reviewers: security, correctness, maintainability, performance, error-handling
🔒 Security — No material issues found
Review
VERDICT: No material issues found
I traced the security-relevant surface of this PR — authz on seed lots, the new
source_urlvalidation, and the planting-attribution checks — against the actual code (not just the diff).What I verified:
ownSeedLot(internal/service/seed_lots.go:64) checksl.OwnerID != actorIDand returnsErrNotFound(notErrForbidden), consistent with the codebase's existence-masking convention. All handlers (Get/Update/Delete/list) route through it. Confirmed by readinginternal/api/seed_lots.goand the store layer — every route is behindh.requireAuth()(internal/api/api.go:127).internal/store/seed_lots.go— no string-built SQL, no injection surface.SeedLotUsagecorrectly scopes tosl.owner_id = ?via an inner join, so a user can never pull another owner's lot usage.seedLotForPlanting(internal/service/seed_lots.go:172) requires the lot to belong to the actor and match the plant, and is re-run on everyUpdatePlanting, not just when the lot field changes — confirmed atinternal/service/plantings.go:110and:162. Another user's lot ID collapses toErrInvalidInputviaownSeedLot'sErrNotFound, not a distinguishable "forbidden," so it doesn't leak the lot's existence.source_urlvalidation (validSourceURL,internal/service/seed_lots.go:296) requireshttp/httpsscheme and a non-empty host, rejectingjavascript:,data:, schemeless (//host), and relative URLs — this is the right check for the stated threat (stored XSS via a rendered link). I confirmed Go'snet/url.Parselowercases the scheme, soHTTP://isn't a bypass (it just normalizes to the already-allowedhttp), and opaque forms likehttp:evil.comleaveHostempty and get rejected. Applied identically to bothplants.source_url(internal/service/plants.go:205) andseed_lots.source_url.source_url/vendoranywhere in the codebase (greppedinternal/) — it's stored and returned verbatim, so there's no SSRF surface from this field.SeedLotPatchhas noPlantID/OwnerIDfields andapplySeedLotPatchnever touches them — a lot can't be re-pointed at another plant or reassigned to another owner via PATCH.plant_id/owner_id REFERENCES ... ON DELETE CASCADEonseed_lots, andseed_lot_id ... ON DELETE SET NULLonplantings, match the stated design and don't create a cross-tenant cascade (a plant'sowner_idgates who can delete it in the first place).internal/agent/tools.go) don't yet expose seed lots at all, so there's no bypass of these checks via that path.Nothing else in this diff touches authn, secrets, or deserialization. The security-relevant design choices called out in the PR description (URL scheme+host validation, ownership masking, re-checked attribution) are implemented as described.
🎯 Correctness — Blocking issues found
Confirms the draft's claim that no test checks the create-response's Remaining/Used field directly. Both findings hold up. Final review:
VERDICT: Blocking issues found
internal/service/seed_lots.go:168—CreateSeedLotreturnss.store.CreateSeedLot(ctx, l)directly without callings.fillRemaining.domain.SeedLot.Remaining/Usedare computed-only fields, zero-valued at construction, so a freshly created lot is returned withremaining: 0regardless of the purchased quantity (e.g. buying 100 seeds shows"remaining": 0in the create response instead of 100). Confirmed no test checksRemaining/Usedon theCreateSeedLotreturn value —seed_lots_test.goonly checks these fields via subsequentGetSeedLot/ListSeedLotscalls. Fix: calls.fillRemainingon the created row before returning, mirroringGetSeedLot/ListSeedLots/the success path ofUpdateSeedLot.internal/service/seed_lots.go:182-184— On a version conflict,UpdateSeedLotreturns the store's current row (updated, err) without fillingRemaining/Used, so the row embedded in the 409 response's"current"field reportsremaining: 0/used: 0even when real usage/remaining exists. Confirmed the store (internal/store/seed_lots.go:112-118) does return a real, non-nil current row alongsidedomain.ErrVersionConflict. This is inconsistent with the established pattern:UpdatePlanting(internal/service/plantings.go:167-173) explicitly callss.enrichDerived(ctx, updated)onErrVersionConflictbefore returning the current row for exactly this reason. Fix: calls.fillRemaining(best-effort) on the conflict path too.Both bugs share the same root cause — a derived-field enrichment step skipped on two return paths — and both undermine the PR's headline feature of reporting remaining seed-lot quantity.
🧹 Code cleanliness & maintainability — Minor issues
Review
VERDICT: Minor issues
internal/service/seed_lots_test.go:345definesfunc intPtr(v int) *int { return &v }, butinternal/service/plants_test.go:12already declares an identical helperptrInt(n int) *int { return &n }in the samepackage service. Confirmed both files arepackage service, both bodies are byte-identical, andintPtris used at lines 56 and 60 ofseed_lots_test.gowhereptrIntwould work just as well. Needless duplicate helper in one package. Fix: dropintPtrand use the existingptrInt.Everything else in-lens is solid:
seed_lots.go(store and service) closely mirrors the existingplants.go/plantings.gopatterns,parseNullable[T]ininternal/api/seed_lots.gois a reasonable small generic to de-duplicate the four nullable-patch fields rather than repeating the pattern by hand, and the effective-count formula is correctly reused fromderivedCountinplantings.gorather than re-implemented.(The second draft finding — that the
plants_test.go:186-195hunk is a gratuitous whitespace-only realignment violating the project's "leave this file alone" convention — did not survive verification. Manually recomputing the code-content length of each entry in thebadtable shows the padding before each//comment is consistent with standard gofmt tabwriter alignment: line 192's code content is the same length as lines 187/189 (the dropped emoji rune inIcon: ""is offset by the addedxinName: "x"), and its padding matches theirs exactly — it is not "pushed further right despite shorter content" as claimed. With that supporting evidence contradicted, and no diff for this file reachable in this sandbox to confirm a hunk exists here at all, the finding was dropped.)⚡ Performance — Minor issues
Verified against
internal/service/seed_lots.goandinternal/store/seed_lots.go:fillRemaining(lines 93-114) unconditionally callss.store.SeedLotUsage(ctx, actorID), whose SQL (internal/store/seed_lots.go:159-166) joinsplantings ⋈ seed_lots ⋈ plantsfiltered only bysl.owner_id = ?, with nolot_idpredicate.GetSeedLot(lines 131-141) andUpdateSeedLot(lines 172-190) both pass a one-element slice intofillRemainingand discard all but the matchingLotID. The finding holds.VERDICT: Minor issues
internal/service/seed_lots.go:93-114,131-141,178(fillRemainingused byGetSeedLot/UpdateSeedLot) — every single-lot read or update re-fetches and aggregates all of the owner's active seed-lot-attributed plantings across every lot they've ever recorded, just to pull out one lot's usage.GetSeedLotbuilds a one-element slice (one := []domain.SeedLot{*l}) and passes it tofillRemaining, butfillRemainingignores which lots were asked for and callss.store.SeedLotUsage(ctx, actorID)(internal/store/seed_lots.go:159), an unfiltered join acrossplantings ⋈ seed_lots ⋈ plantsscoped only byowner_id, then discards everything except the one matchingLotID(internal/service/seed_lots.go:101-111).UpdateSeedLot(internal/service/seed_lots.go:178) does the same for its single updated row. Since the PR's own selling point is that lots accumulate over years ("the same garlic from two vendors in two years"), this means viewing or editing any one lot gets slower as the user's total inventory history grows, rather than scaling with that lot's own plantings. A cheap fix: add a store method that scopesSeedLotUsagebylot_id IN (...)(or a singlelot_id = ?variant) and havefillRemainingpass the requested lot IDs through, keeping the unfiltered form only forListSeedLots, which legitimately needs all of them at once.Everything else in this lens is clean:
ListSeedLotsForOwner/SeedLotUsageare appropriately indexed (idx_seed_lots_owner, partialidx_plantings_seed_lot, both confirmed ininternal/store/migrations/0007_seed_lots.sql) and used correctly for the list case;seedLotForPlantingandvisiblePlantare single indexed lookups with no loops; there's no N+1 pattern in create/update paths.🧯 Error handling & edge cases — Blocking issues found
Line numbers match the draft exactly (168 for CreateSeedLot's return, 182-184 for the update conflict path). Both findings are fully confirmed against the actual source.
VERDICT: Blocking issues found
internal/service/seed_lots.go:168—CreateSeedLotreturnss.store.CreateSeedLot(ctx, l)directly, never callingfillRemaining.domain.SeedLot.Used/Remaining(internal/domain/domain.go) are plain zero-valued fields with no default computation, andstore.CreateSeedLot/scanSeedLotnever touch them. Every freshly created lot is serialized back to the client withremaining: 0instead ofQuantity, even though nothing has been planted against it yet — wrong on the very first read of every lot's life.GetSeedLot,UpdateSeedLot(success path), andListSeedLotsall correctly callfillRemaining;CreateSeedLotis the one path that skips it.s.fillRemaining(ctx, actorID, []domain.SeedLot{*created})(or setRemaining = Quantitydirectly) before returning.internal/service/seed_lots.go:182-184— On a version conflict,store.UpdateSeedLot(internal/store/seed_lots.go:98-123) returns(current, domain.ErrVersionConflict), and the service doesreturn updated, err, skippingfillRemaining. That row is exactly whatwriteVersionConflict(c, lot)(internal/api/seed_lots.go:163-166, usinginternal/api/errors.go:65-70) sends to the client as"current"in the 409 body — so a client trying to show "you have N left, still want to save?" during conflict resolution seesused: 0, remaining: 0regardless of the lot's true state.Used/Remainingon the conflict row too, e.g. move thefillRemainingcall before returning in theerr != nilbranch, or call it in both branches.glm-5.2:cloud (ollama-cloud) — Blocking issues found
Verdict: Blocking issues found — 5 reviewers: security, correctness, maintainability, performance, error-handling
🔒 Security — No material issues found
Verdict: No material issues found
Through the security lens, this change is well-constructed. I verified the key paths by reading the actual checked-out code:
validSourceURL(internal/service/seed_lots.go:299-313) is the load-bearing XSS control, and it is correct: empty allowed, length-capped,url.Parse+Scheme ∈ {http,https}+Host != "". It rejectsjavascript:,data:,file:, schemeless//evil, relative/seeds, andhttps://(no host) — matching the test table. It is enforced on both lots (finalizeSeedLot:271) and plants (finalizePlant:206), for create and update.requireAuth()(internal/api/api.go:129);ownSeedLotmasks existence viaErrNotFoundon a non-owner lot (seed_lots.go:124);ListSeedLotsForOwnerandSeedLotUsageboth filterWHERE owner_id = ?with a parameterized placeholder (internal/store/seed_lots.go:64,165). No SQL injection surface — all queries use?placeholders.seedLotForPlanting:207-219) requires the lot be the actor's and a lot of the plant being planted, and is re-run on every update (plantings.go:162) so a plant swap can't leave a stale cross-variety attribution. Another user's lot →ErrInvalidInput, no existence leak.finalizeSeedLot(unit whitelist, finite/non-negative/capped quantity, text length caps, year range, non-negative cost, germination 0–100) and theplantIdquery param parse (api/seed_lots.go:103-108,id < 1rejected) are sound.sourceUrl) are only visible to themselves; built-ins carryDEFAULT ''from the migration and the test confirms they stay clean.One low-confidence observation (not rising to a reported finding):
url.Parseis permissive vs. browser URL parsers, so a value with unusual host punctuation could in principle parse as valid here yet be interpreted differently by a browser if/when the frontend renderssourceUrlas a link. Theweb/tree does not currently consumesourceUrl/seedLotIdat all (grep found no matches), so there is no live rendering surface today; and React escapes attributes by default. I could not confirm any concrete bypass, so I'm not elevating this to a finding.🎯 Correctness — Blocking issues found
VERDICT: Blocking issues found
internal/store/gardens.go:237— garden copy double-counts seed-lot usage.CopyGarden's copy loop re-inserts each active planting viaplantingInsertArgs(objectID, &p), andplantingInsertArgs(internal/store/plantings.go:163) now passesp.SeedLotIDstraight through. The cloned planting therefore keeps the source planting'sseed_lot_id, pointing at the same lot.SeedLotUsage(internal/store/seed_lots.go:159) sums the effective count of all ACTIVE plantings (removed_at IS NULL) attributed to a lot, andfillRemaining(internal/service/seed_lots.go:93-114) rolls that sum intoUsed/Remaining. So copying a garden makes the lot'sUsedjump by the copy's effective count andRemainingdrop — even though no new seed was planted. This contradicts the PR's own invariant in DESIGN.md ("Lots are never shared along with a garden") and silently corrupts the inventory number the feature exists to report. Fix: null outSeedLotIDon the cloned planting in the copy loop (a copy didn't consume seed), or otherwise decide and document the intended semantics.internal/service/seed_lots.go:182-185— version-conflict row returned withoutUsed/Remaining. On a store-levelErrVersionConflict,UpdateSeedLotreturns(updated, err)at line 184, whereupdatedis the row fromGetSeedLot→scanSeedLot(internal/store/seed_lots.go:48-58,17-27), which does not touchUsed/Remaining, leaving them at their zero values — thefillRemainingcall at line 187 is skipped on the error path. The API handler (internal/api/seed_lots.go:160-164) then hands that row towriteVersionConflict(internal/api/errors.go:65), so the conflict payload'scurrentlot reportsused=0, remaining=0. Minor: the "current row" the conflict contract promises to carry is misleading for this resource. Fix: computeUsed/Remainingon the conflict path too before returning.🧹 Code cleanliness & maintainability — Minor issues
All three findings are confirmed against the actual code.
parseNullable[T](seed_lots.go:86),parseNullableString(objects.go:93), andparseNullableInt(plants.go:69) all coexist with identical absent/null/value semantics;plantings.go:toPatchmixes all three (lines 54, 58, 62, 66, 70). Confirmed.plants.go:201validatesp.VendoragainstmaxLotTextLen, which is defined only inseed_lots.go:18and named for lots. Confirmed via grep (3 references, all elsewhere are lot code).seed_lots.go:122comment// ErrNotFoundonownSeedLot's return ofstore.GetSeedLot's error is inaccurate:GetSeedLot(store/seed_lots.go:48-58) maps onlysql.ErrNoRowstoErrNotFoundand wraps everything else asstore: get seed lot: %w. Confirmed.Verdict: Minor issues
internal/api/seed_lots.go:86(andplantings.go:66) — three nullable-parsers where one generic suffices. The PR introduces a genericparseNullable[T]that is strictly more general than the pre-existingparseNullableString(objects.go:93) andparseNullableInt(plants.go:69) — same(value, present, err)semantics, same absent/null/value handling. Rather than consolidate, the change adds a third parser and even mixes all three within a single function (plantings.go:53-73usesparseNullableInt,parseNullableString, andparseNullable[int64]side by side). Readability/maintenance smell: future readers must learn three near-identical helpers and decide which to use. Suggested fix: replaceparseNullableString/parseNullableIntwithparseNullable[string]/parseNullable[int](or keep thin deprecating wrappers) for one canonical helper. Verified by reading all three definitions and thetoPatchcall sites.internal/service/plants.go:201—finalizePlantreaches across files into a lot constant. Plant validation usesmaxLotTextLen(defined inseed_lots.go:18) to boundp.Vendor. A plant-domain validation function depending on a constant named for lots (and defined in another file) is a leaky, surprising coupling — a maintainer touching lot bounds won't expect plant validation to depend on them, and the naming is actively misleading ("this is a lot limit" used for a plant field). Verified by grep:maxLotTextLenonly lives inseed_lots.go(definition + lot-side validation). Suggested fix: add a plant-side constant (e.g.maxPlantVendorLen = 200in plants.go) or hoist a sharedmaxShortTextLen.internal/service/seed_lots.go:122— misleading inline comment.return nil, err // ErrNotFoundasserts the returned error isErrNotFound, butownSeedLotreturns whateverstore.GetSeedLotreturns, which includes wrapped non-NotFound errors (the store wraps scan failures infmt.Errorf("store: get seed lot: %w", err)). The comment is wrong for those paths and could mislead a reader tracing error handling. Verified by readingGetSeedLot(store/seed_lots.go:48-58), which only mapssql.ErrNoRowstoErrNotFoundand wraps everything else. Suggested fix: drop the// ErrNotFoundannotation or rewrite to "GetSeedLotalready maps missing → ErrNotFound".These are minor maintainability nits; no blocking cleanliness problems.
⚡ Performance — Minor issues
Both findings are confirmed against the actual code.
Verdict: Minor issues
internal/service/seed_lots.go:131(GetSeedLot) and:186(UpdateSeedLot) — single-lot reads over-fetch the entire owner's active plantings. Both paths build a one-element slice and calls.fillRemaining(ctx, actorID, one), which (seed_lots.go:97) callss.store.SeedLotUsage(ctx, actorID). That store query (internal/store/seed_lots.go:159-166) selects every active planting joined across all the owner's lots (WHERE sl.owner_id = ? AND pl.removed_at IS NULL, nopl.seed_lot_id = ?filter, noLIMIT), then the service sums the whole result into a map just to readused[lotID]. AGET /seed-lots/:idorPATCH /seed-lots/:idis therefore O(total active plantings owned by the actor) instead of O(plantings of that one lot). A repo-wide grep confirmsSeedLotUsagetakes onlyownerIDand no lot-scoped variant exists. Fix: add an optional lot filter (or aSeedLotUsageForLot) and narrow theWHEREtopl.seed_lot_id = ?for the single-lot paths; leave the all-lots query forListSeedLots, where the full scan is actually needed.internal/store/seed_lots.go:69—ListSeedLotsForOwnerhas no defensiveLIMIT, unlike every sibling list query. The query (SELECT ... FROM seed_lots WHERE owner_id = ? [AND plant_id = ?] ORDER BY ..., lines 64-71) returns the entire unbounded set, andfillRemainingthen runs a second unbounded query over all the owner's active plantings on top of it. The siblings all cap:ListPlantsForActorusesmaxPlantsListed = 5000(plants.go:62/74),ListSharesForGardenusesmaxSharesListed = 1000(shares.go:45/56), andListGardensForActorusesmaxGardensListed(gardens.go:93). Lots accumulate year over year, so this grows without bound. Fix: add amaxLotsListedconstant andLIMIT ?, matching the established pattern.🧯 Error handling & edge cases — Minor issues
All three findings are confirmed by the code.
Finding 1:
seedLotForPlanting(line 207) validates only ownership and plant-match — no quantity check.fillRemaining(line 111) computesRemaining = Quantity - Usedunconditionally, allowing negative stock. Confirmed.Finding 2:
UpdateSeedLot(line 182-189): after a successfulstore.UpdateSeedLot, afillRemainingfailure returnsnil, ferr, discarding the already-persistedupdatedrow. The DB write is committed but the caller receives an error and nil lot. Confirmed.Finding 3:
UpdateSeedLotstore (line 112-117): onsql.ErrNoRowsit callsGetSeedLot; if that fails it returns(nil, gerr)rather thanErrVersionConflict. The handler atseed_lots.go:162only special-casesErrVersionConflict. A concurrent-delete-during-update surfaces as a generic service error. Confirmed.Verdict: Minor issues
internal/service/seed_lots.go:207(seedLotForPlanting) /:111(fillRemaining): There is no check that a lot has enough quantity left before attributing a planting to it.seedLotForPlantingvalidates only ownership and plant-match, andfillRemainingcomputesRemaining = Quantity - Usedunconditionally. A user can attribute 200 plants to a 50-seed lot (or plant 100, then add 100 more) and the lot silently reports a negativeremaining. For an inventory feature this is an unhandled boundary: negative stock is nonsensical, and nothing rejects or flags it. Suggested fix: inCreatePlanting/UpdatePlanting, afterseedLotForPlanting, compute the lot's currentUsedand reject (or warn) whenUsed + effectiveCount > Quantity.internal/service/seed_lots.go:186(UpdateSeedLot): After a successfuls.store.UpdateSeedLot, a subsequentfillRemainingfailure returnsnil, ferr, discarding the already-persisted updated row. The update is committed to the DB but the caller receives an error and a nil lot — a succeeded operation reported as failure. Suggested fix: returnupdated(withUsed/Remainingleft at zero) rather thannilwhen only the derived-field enrichment fails.internal/store/seed_lots.go:118(UpdateSeedLotconflict path): When the version-guarded update hitssql.ErrNoRows, it re-reads viaGetSeedLot; if that fails (e.g. the lot was deleted concurrently), it returns(nil, gerr)rather thanErrVersionConflict. The handler atinternal/api/seed_lots.go:162only special-casesErrVersionConflict, so a concurrent-delete-during-update surfaces as a generic service error instead of a clean 404/conflict. Minor (concurrency race), but the error type is not what the handler expects.kimi-k2.6:cloud (ollama-cloud) — Blocking issues found
Verdict: Blocking issues found — 5 reviewers: security, correctness, maintainability, performance, error-handling
🔒 Security — No material issues found
No material issues found
Through the security lens, the PR's controls are sound:
validSourceURL(internal/service/seed_lots.go:299) is load-bearing XSS prevention and correctly rejectsjavascript:,data:,file:, schemeless (//…), relative (/…), and host-less (https://) URLs. It is applied to bothPlant.SourceURLandSeedLot.SourceURLbefore storage.internal/service/seed_lots.go:207) enforces that a planting's lot belongs to the actor and matches the planted variety, returningErrInvalidInput(notErrNotFound) so existence stays masked.internal/service/seed_lots.go:119) returnsErrNotFoundfor cross-user access, consistently masking existence across GET/PATCH/DELETE.ON DELETE SET NULLonplantings.seed_lot_idprevents history destruction when a lot is deleted.writablePlantreturnsErrForbiddenfor built-ins, so the new provenance columns on seeded plants cannot be altered.Nothing in the change introduces an authn/authz gap, injection vector, or unsafe handling of untrusted data.
🎯 Correctness — Blocking issues found
Blocking issues found
internal/service/seed_lots.go:175—CreateSeedLotreturns the newly created lot without callingfillRemaining, soUsedandRemainingare both zero in the201 Createdresponse. A client that readsremainingimmediately after creation sees0instead of the lot's actualQuantity. The existing pattern inCreatePlanting(created.DerivedCount = …) shows write endpoints should return complete, derived fields.internal/service/seed_lots.go:190—UpdateSeedLotreturns the version-conflict current row (return updated, err) without ever runningfillRemaining. The409 Conflictpayload therefore serializesUsed: 0andRemaining: 0, which is stale and misleading.UpdatePlantingalready solves the same problem by callingenrichDerivedon the conflict row before returning;UpdateSeedLotshould do the equivalent forUsed/Remaining.🧹 Code cleanliness & maintainability — Minor issues
Verdict: Minor issues
internal/api/seed_lots.go:86— The new genericparseNullable[T any]is introduced inseed_lots.gobut immediately consumed byplantings.go. This creates a hidden cross-file dependency: a handler in the planting file relies on a helper tucked inside a seed-lot file. The generic is cleaner than the existingparseNullableInt/parseNullableStringduplicates, but it should live in a shared API utility file (or at least in a file whose name doesn't imply seed-lot ownership). Fix: MoveparseNullable[T any]toapi.goor a newapi/util.goso its scope matches its consumers.internal/service/seed_lots.go:213—seedLotForPlantingis named like a getter/fetcher but actually performs validation and returns only an error. The name is confusing when scanning call sites. The comment explains the intent ("validates a planting's lot attribution"), but the function name doesn't. Fix: Rename tovalidateSeedLotForPlantingorcheckSeedLotAttributionto match its boolean/validation nature and align with other helpers in the package.internal/service/plants_test.go:187— Formatting regression: the comment-aligned spacing in the table-driven test was broken (columns no longer align with their//comments), making the test harder to scan.gofmtwon't fix comment alignment. Fix: Re-align the comment offsets to restore the readable table layout.⚡ Performance — Minor issues
Minor issues
internal/store/seed_lots.go:69-91—ListSeedLotsForOwnerreturns an unbounded slice with no LIMIT or pagination cap. The analogousListPlantsForActorin the same store has amaxPlantsListed = 5000defensive backstop (internal/store/plants.go:62), but the seed-lots list query lacks any equivalent ceiling. A user who records many purchases over multiple years could cause unbounded memory allocation in both the DB and the service. Suggested fix: Add aLIMITconstant (and a note that pagination is post-v1) to match the defensive pattern used for plants.internal/service/seed_lots.go:93-114andinternal/store/seed_lots.go:159-184—GetSeedLotandUpdateSeedLoteach callfillRemainingfor a single lot, butfillRemainingalways executesSeedLotUsage(ctx, actorID)which fetches every active planting attributed to any of the actor's lots. For a single-lot read this is unnecessary work: if a user has 50 lots with hundreds of total plantings, computingderivedCountfor every planting just to sum one lot's usage is wasteful. Suggested fix: Add a lot-ID filter parameter toSeedLotUsageso single-lot reads can scope the query; at minimum pre-filter theusesslice to only rows whoseLotIDis in the requested set.🧯 Error handling & edge cases — Blocking issues found
Blocking issues found
internal/service/seed_lots.go:182-184—UpdateSeedLotreturns a version-conflict response without computingUsed/Remaining. When the store returns(currentRow, ErrVersionConflict), the service immediately returnsupdated, errand skipsfillRemaining. The conflict response therefore leavesUsed=0andRemaining=0(zero values) instead of the derived amounts. This is inconsistent withGetSeedLotandListSeedLots, which always populate the fields, and the API handler (internal/api/seed_lots.go:161-168) forwards that incomplete row to the client. Verified by readingUpdateSeedLotinservice/seed_lots.goand comparing withUpdatePlantinginservice/plantings.go:167-174, which enriches its conflict response.internal/api/seed_lots.go:60—seedLotUpdateRequest.Versiononly declaresbinding:"required", omittingmin=1. The plant (plants.go:49) and planting (plantings.go:76) update requests both enforcebinding:"required,min=1". A version of0or a negative value therefore passes API validation and reaches the service, where it will fail the SQLversion = ?guard and surface as a version conflict instead of an earlyINVALID_INPUTerror. Verified by comparing the struct tags across the three update request types.opencode/glm-5.2:cloud (opencode) — Blocking issues found
Verdict: Blocking issues found — 5 reviewers: security, correctness, maintainability, performance, error-handling
🔒 Security — No material issues found
VERDICT: No material issues found
I reviewed this change through the security lens — authn/authz, injection, XSS via the
sourceUrlclickable link, SSRF, and existence-masking. Verified each path by reading the actual checked-out code, not just the diff hunks.sourceUrlXSS protection is sound.validSourceURL(internal/service/seed_lots.go:299) rejects everything except empty or absolutehttp/httpswith a non-empty host. I traced the rejection table in the test (seed_lots_test.go:174):javascript:,JavaScript:(Go'surl.Parselowercases the scheme, so the case variant is caught),data:, schemeless//evil…, relative/seeds/garlic,file:, and host-lesshttps://are all refused. The same check is reused forplants.source_urlviafinalizePlant(plants.go:206), so neither surface accepts a script URL. Verified that built-ins can't be used as an injection vector either:writablePlantreturnsErrForbiddenforowner_id IS NULL(plants.go:104), so a user can't write ajavascript:link onto a built-in row.Authorization on seed lots is consistently enforced.
ownSeedLot(seed_lots.go:119) loads then checksl.OwnerID == actorID, returningErrNotFoundotherwise (existence masked, matching the project convention).GetSeedLot,UpdateSeedLot,DeleteSeedLotall go throughownSeedLot;ListSeedLots/ListSeedLotsForOwnerfilter byowner_id = ?(store/seed_lots.go:64). Theseed-lotsroute group is behindrequireAuth()(api.go:129). I confirmedmustActor(c).IDis the session actor in every handler.Planting→lot attribution is checked, not trusted, and re-checked on update.
seedLotForPlanting(seed_lots.go:207) requires the lot be the actor's and be a lot of the plant being planted; onUpdatePlantingit runs against the post-patchpl.PlantIDso a plant swap can't leave a stale cross-variety attribution (plantings.go:162). Another user's lot is normalized toErrInvalidInput, not leaked. A shared editor attributing a plop in someone else's garden to their own lot is allowed by design and not an authz bypass — the lot, not the garden, is the unit of ownership.No injection / SSRF. All store queries use
?placeholders (store/seed_lots.go,store/plantings.go,store/plants.go);plantIDquery param is parsed with bounds checking (api/seed_lots.go:104).sourceUrlis stored and rendered as a link; the server never fetches it, so there is no SSRF surface.Defense in depth on usage accounting.
SeedLotUsage(store/seed_lots.go:159) joinsseed_lots sland filterssl.owner_id = ?, so even a hypothetical cross-owner attribution (prevented upstream) would not inflate another user'sremaining.parseNullable/json.RawMessagehandling is a pure decode with no eval/deserialization-of-types risk;encoding/jsononly.No security findings to report within this lens.
🎯 Correctness — Minor issues
VERDICT: Minor issues
Overall the
remainingderivation, attribution re-checks, URL validation, version-guard contract, andON DELETE SET NULLbehavior are all correct. Theremainingformula is right (sum of effective count = explicitcountelsederivedCount(radius, spacing); onlyremoved_at IS NULLrows), and it lives in one place (fillRemaining). The mismatched-lot and not-your-lot rejections on both create and update are correct. No correctness issue in the store layer's column/scan ordering, the version-conflict path, or the migration.One real correctness bug, all in
internal/service/plantings.go:162:UpdatePlantingre-runsseedLotForPlanting(ctx, actorID, pl.SeedLotID, pl.PlantID)on every update (line 162).seedLotForPlanting→ownSeedLot(seed_lots.go:119-128) returnsErrNotFoundfor any lot the actor doesn't own, whichseedLotForPlanting(seed_lots.go:211-213) converts toErrInvalidInput. The update path already special-cases the plant visibility for exactly this reason (the comment at plantings.go:138-143 calls out "a shared editor in the owner's garden using the owner's private plant"), gating thevisiblePlantre-check onpl.PlantID != originalPlantID— but the lot check at line 162 has no equivalent carve-out. So a shared editor who edits anything about an existing planting (label, radius, soft-remove, anything) — where the owner had attributed that plop to one of the owner's lots — now getsErrInvalidInputand cannot save the edit, even when not touchingseedLotIdat all. The asymmetry is the bug: the actor can legitimately create plantings they couldn't re-validate, and the no-op re-validation on edit refuses a state that came from elsewhere. Verified by readingUpdatePlantingand theseedLotForPlanting/ownSeedLotchain. Fix: gate the re-check the same way the plant visibility is gated — only enforce the lot attribution whenpatch.SetSeedLotIDis true (the actor is actually trying to set it), or when the plant actually changed (the swap case the comment at line 160-161 cares about). Confidence high that the refusal is real; the precise fix shape is a judgement call.🧹 Code cleanliness & maintainability — Minor issues
VERDICT: Minor issues
internal/api/seed_lots.go:86—parseNullableduplicatesparseNullableString/parseNullableInt. The new generic helper atseed_lots.go:86is functionally equivalent to the pre-existingparseNullableString(objects.go:93) andparseNullableInt(plants.go:69): absent →(nil, false),null→(nil, true), otherwise the decoded value.plantings.go:66already uses the genericparseNullable[int64]forseedLotIdwhile lines 54, 58, 62, 70 of the sametoPatchstill call the typed helpers — a half-migration within a single function. Consolidate to the generic and delete the two typed helpers, or revert to the typed ones; the mix is the worst of both.internal/service/plants.go:201— plant validation reaches into a lot-named constant.finalizePlantcheckslen(p.Vendor) > maxLotTextLen, butmaxLotTextLenis declared inseed_lots.go:18alongside the othermaxLot*constants, whileplants.gokeeps its ownmaxPlantNameLen/maxPlantNotesLen(plants.go:15-16). A reader ofplants.gosees a "lot" constant enforcing a plant field and must grep across files to find it. Same cross-file dependency withvalidSourceURL(definedseed_lots.go:299, calledplants.go:206). These are shared provenance rules now — lift them into a neutral location (e.g.provenance.go) or rename tomaxProvenanceTextLenrather than havingplants.godepend onseed_lots.gofor its validation primitives.internal/service/seed_lots_test.go:345—intPtrduplicatesptrInt.plants_test.go:12definesfunc ptrInt(n int) *int { return &n }; the new test file addsfunc intPtr(v int) *int { return &v }— same helper, different name, same package. DropintPtrand useptrInt, or pick one name and consolidate.⚡ Performance — Minor issues
I'll verify both findings against the actual code.
Both findings confirmed. Let me verify the analogous plants/gardens list queries have LIMIT caps.
Both findings confirmed against the actual code:
Finding 1 (N+1 in
fillRemaining/GetSeedLot): Confirmed.GetSeedLot(line 131) callsfillRemaining(line 137) with a single-element slice, andfillRemaining(line 93) callss.store.SeedLotUsage(ctx, actorID)(line 97) which returns all active plantings for the owner across all lots/gardens (store line 159-166), then re-sums the entire slice (lines 101-108) regardless of how many lots were requested. Looping callers become quadratic.Finding 2 (missing LIMIT): Confirmed.
ListSeedLotsForOwner(store line 63) has noLIMIT, whileListPlantsForActor(plants.go:73,maxPlantsListed=5000) andListGardensForActor(gardens.go:93,maxGardensListed=1000) both cap results.VERDICT: Minor issues
internal/service/seed_lots.go:131(GetSeedLot) — for a single-lot fetch,fillRemainingissuesSeedLotUsage, which loads and scans every active planting attributed to any of the owner's lots across all gardens, then aggregates them in Go. Fetching one lot therefore scans the actor's entire planting history; callingGetSeedLotin a loop (e.g. an index page rendering lots one by one) is quadratic inplantings × lots. Verified:SeedLotUsage(internal/store/seed_lots.go:159) takes onlyownerIDand returns all of the owner's usages, andfillRemaining(internal/service/seed_lots.go:93) re-sums the whole slice each call. Suggested fix: add aSeedLotUsageForLots(ownerID, lotIDs []int64)store path that filterspl.seed_lot_id IN (...), or computeusedfor all of the owner's lots once and index into it — the single-lot read then pays O(1) over a pre-grouped map rather than re-scanning every planting.internal/store/seed_lots.go:63(ListSeedLotsForOwner) — unbounded SELECT (noLIMIT), unlike the analogousListPlantsForActor(plants.go:73,maxPlantsListed=5000) andListGardensForActor(gardens.go:93,maxGardensListed=1000), both of which cap with the same "household scale, pagination post-v1" rationale. A user accumulating many lots over years has no upper bound. Verified by grep: this is the only owner-scoped list query ininternal/storewithout aLIMIT. Suggested fix: addLIMIT N(e.g. 5000) consistent with the other list endpoints, or note explicitly why seed lots are expected to stay bounded when plants/gardens are not.The N+1 in
fillRemainingis the material one; the missing cap is a minor consistency nit given the household-scale assumption stated elsewhere.🧯 Error handling & edge cases — Blocking issues found
Both findings verified against the actual source.
Finding 1 confirmed:
UpdatePlanting(plantings.go:162) callss.seedLotForPlantingunconditionally;seedLotForPlanting(seed_lots.go:207-219) →ownSeedLot(119-128) returnsErrNotFoundfor non-owners, converted toErrInvalidInputat line 213. The editor path is reachable:objectForRole(..., roleEditor)at line 130,effectiveGardenRolereturnsroleEditorfor non-owners (gardens.go:102-103). Contrast with thePlantIDbranch which is gated onpl.PlantID != originalPlantID(line 144);SeedLotIDhas no such gate — always re-checked. A shared editor editing the owner's lot-attributed plop without touchingseedLotIdis rejected.Finding 2 confirmed:
RestorePlanting(store/plantings.go:88-103) re-insertsp.SeedLotIDverbatim from the revision snapshot (line 97).ON DELETE SET NULLonly nulls the live row, not the historical snapshot. Revert of a planting delete routes throughRestorePlanting(revisions.go:549). After restore with a danglingseed_lot_id, the nextUpdatePlantinghitsseedLotForPlanting→ownSeedLot→GetSeedLotreturnsErrNotFound→ErrInvalidInput, bricking the plop against edits.VERDICT: Blocking issues found
internal/service/plantings.go:162— shared-garden editors can no longer update any lot-attributed planting.UpdatePlantingrunss.seedLotForPlanting(ctx, actorID, pl.SeedLotID, pl.PlantID)on every update.seedLotForPlanting(seed_lots.go:207) callsownSeedLot, which returnsErrNotFoundfor anyone but the lot's owner (seed_lots.go:124), andseedLotForPlantingconverts that toErrInvalidInput(seed_lots.go:213). A garden shared with aneditoris reachable:effectiveGardenRolereturnsroleEditorfor non-owners (gardens.go:102-103), andUpdatePlantingauthorizes viaobjectForRole(..., roleEditor)(plantings.go:130). An editor can move, soft-remove, or relabel a plop; but if that plop carries the owner'sseedLotId(the only kind it can carry — lots are private, per seed_lots.go:14), the editor's update is now rejected withErrInvalidInputeven when they never touchseedLotId. This is asymmetric with the adjacentPlantIDhandling, which is deliberately gated onpl.PlantID != originalPlantID(plantings.go:144) for exactly this shared-editor reason (comment at plantings.go:138-142). The minimal fix mirroring that gating is to re-checkseedLotForPlantingonly whenpatch.SetSeedLotIDis true.internal/service/plantings.go:162— re-check also bricking a plop whose lot was deleted after a revert.seedLotForPlantingreturnsErrInvalidInputwhenownSeedLotreturnsErrNotFound(seed_lots.go:213). The migration setsseed_lot_idto NULL on lot deletion (ON DELETE SET NULL), so the live path is safe — but the revision snapshot still holds the old lot id, andRestorePlantingre-insertsp.SeedLotIDverbatim from the snapshot (store/plantings.go:97) without nulling a dangling reference. Revert of a planting delete goes throughRestorePlanting(revisions.go:549). After such a restore, the restored row carries aseed_lot_idpointing at a non-existent lot, and the very nextUpdatePlantinghitsownSeedLot → ErrNotFound → ErrInvalidInput, permanently bricking the plop against edits. Suggested fix: inseedLotForPlanting, treatErrNotFoundfromownSeedLotas "clear the dangling link to NULL and allow" rather thanErrInvalidInput, OR null outSeedLotIDduring restore when the lot no longer exists.opencode/kimi-k2.6:cloud (opencode) — Blocking issues found
Verdict: Blocking issues found · ⚠️ 1/5 lens(es) errored — 5 reviewers: security, correctness, maintainability, performance, error-handling
🔒 Security — No material issues found
No material issues found
The PR's security posture is sound:
validSourceURL) correctly rejectsjavascript:,data:,file:, schemeless, relative, and host-less URLs while allowing empty strings (clearing the field). The scheme check is case-insensitive becauseurl.Parsenormalizes schemes to lowercase. This is verified by the rejection table inTestSourceURLValidation.mustActor(c).ID;ownSeedLotmasks other users' lots asErrNotFound;seedLotForPlantingenforces that a planting's lot belongs to the actor and matches the plant being planted, preventing cross-variety attribution that would corruptremaining.finalizeSeedLot/finalizePlant, shared by create and update paths. No bypass path exists—patch fields flow throughapply*Patchinto the same finalizers.🎯 Correctness — ⚠️ could not complete
⚠️ This reviewer failed to complete: opencode run returned no output
🧹 Code cleanliness & maintainability — Minor issues
Minor issues
internal/api/seed_lots.go:86—parseNullable[T]duplicates the functionality of the existingparseNullableInt(plants.go:69) andparseNullableString(objects.go:93) helpers, yet it is defined inseed_lots.goinstead of alongside them. Worse,plantings.gonow mixes the new generic call (parseNullable[int64]) with the old non-generic ones (parseNullableInt,parseNullableString) in the sametoPatchmethod, leaving the codebase in a half-migrated state. Fix: place the generic helper inobjects.gowith the other nullable parsers, replace the old ones with thin wrappers or remove them, and migrateplantings.goconsistently.internal/service/seed_lots_test.go:345—func intPtr(v int) *intduplicatesptrIntinplants_test.go:12(same package, different name). Having two names for the same helper is needless churn for future readers. Fix: deleteintPtrand useptrInteverywhere in the package.internal/service/plants_test.go:187— The only changes to this file are whitespace re-alignment of//comments.CLAUDE.mdexplicitly calls this file out as "already unformatted onmain— leave it alone unless you're touching it, so the diff stays about your change." Reformatting it anyway adds noise and violates an established project convention. Fix: revert the formatting-only changes.internal/service/seed_lots.go:146—CreateSeedLotnever callss.record(...), anddomain.gohas noEntitySeedLotconstant.CLAUDE.mdstates as an invariant: "Every service mutation lands in history (#48). If you add one, record it." The existingrecordhelper is garden-scoped, but seed lots are actor-scoped; even so, the PR neither records them nor explains why they are exempt, which leaves the history surface incomplete and breaks a documented project convention. Fix: addEntitySeedLottodomain.goand record seed-lot mutations (this may require extending the history seam to support actor-scoped entities).internal/service/seed_lots.go:172—UpdateSeedLotnever callss.record(...), anddomain.gohas noEntitySeedLotconstant.CLAUDE.mdstates as an invariant: "Every service mutation lands in history (#48). If you add one, record it." The existingrecordhelper is garden-scoped, but seed lots are actor-scoped; even so, the PR neither records them nor explains why they are exempt, which leaves the history surface incomplete and breaks a documented project convention. Fix: addEntitySeedLottodomain.goand record seed-lot mutations (this may require extending the history seam to support actor-scoped entities).internal/service/seed_lots.go:196—DeleteSeedLotnever callss.record(...), anddomain.gohas noEntitySeedLotconstant.CLAUDE.mdstates as an invariant: "Every service mutation lands in history (#48). If you add one, record it." The existingrecordhelper is garden-scoped, but seed lots are actor-scoped; even so, the PR neither records them nor explains why they are exempt, which leaves the history surface incomplete and breaks a documented project convention. Fix: addEntitySeedLottodomain.goand record seed-lot mutations (this may require extending the history seam to support actor-scoped entities).⚡ Performance — Blocking issues found
Blocking issues found
internal/store/seed_lots.go:63—ListSeedLotsForOwnerissues an unboundedSELECTwith noLIMIT. Every other list endpoint in the codebase (gardens,plants,shares) carries a defensive cap (e.g.maxGardensListed = 1000,maxPlantsListed = 5000). A gardener who records seed purchases year-over-year will see response size and memory use grow without bound. Fix: add amaxSeedLotsListedconstant andLIMIT ?to the query.internal/service/seed_lots.go:93/internal/store/seed_lots.go:159—fillRemainingfetches all of the actor's active plantings viaSeedLotUsage(ctx, actorID)even when called fromGetSeedLotorUpdateSeedLot, which only need one lot. This is O(total active plantings across every lot) I/O and computation for a single-lot read. Fix: add an overload or optionallotIDfilter toSeedLotUsage(e.g.SeedLotUsage(ctx, actorID, lotID *int64)) and use the targeted query inGetSeedLot/UpdateSeedLot; keep the broad query forListSeedLotswhere it is actually needed.🧯 Error handling & edge cases — Blocking issues found
Blocking issues found
internal/service/seed_lots.go:168—CreateSeedLotreturns staleUsed/Remainingzero-valuesGetSeedLot,ListSeedLots, andUpdateSeedLotall callfillRemainingso the client sees the liveused/remainingderived fields.CreateSeedLotdoes not, so a newly-created lot withquantity: 100is serialized as{\"used\":0,\"remaining\":0,\"quantity\":100}, which is actively misleading. The API should return a consistent shape on every verb.Fix: Either call
fillRemainingon a single-element slice before returning, or at minimum setUsed=0, Remaining=l.Quantity.internal/service/revisions.go:448+internal/store/plantings.go:88— Revert of a deleted planting hard-fails if its lot was deleted in the meantimeRestorePlantingre-inserts the row with its originalseed_lot_id. If the referenced lot was deleted after the planting was originally deleted, the FKREFERENCES seed_lots(id)throws a constraint error.revertEntityhas noblockRestorehook for plantings, so the entire revert aborts. The PR explicitly says "retiring a lot must never destroy planting history — the plants really were in the ground," but the revert path hasn't been taught that the lot may now be gone.Fix: In the planting revert ops (or
RestorePlantingitself), check lot existence and clearseed_lot_idtoNULLwhen the lot is missing, matching theON DELETE SET NULLsemantics the rest of the system relies on.internal/service/seed_lots.go:111—Remainingcan go negative without guard or documented behaviorfillRemainingdoesQuantity - Usedwith no floor. If a user plants more than they bought, or later reducesQuantitybelow current usage,Remainingbecomes negative. The field is described as "how much is left," so a negative value is surprising and could confuse display logic.Fix: Cap at zero (
max(0, Quantity - Used)) or, if negative is intentional to show over-commitment, document it explicitly in the struct comment.Automated adversarial review by Gadfly — consensus across the model swarm. Advisory only — does not block merge.
Fixed in
4a8fbc6. Three real bugs, two of which would have quietly corrupted inventory numbers — plus one I'd already caught and fixed before this landed.CopyGardencarriedseed_lot_idinto the copyCaught by
glm-5.2, and it's the best finding here. Copying a garden double-counted the original lot's usage — the copy is a plan, not a second planting out of the same packet. Worse, it quietly attached a private lot to a garden that may later be shared, directly contradicting the "lots are never shared with a garden" invariant this whole feature is built on. The copy now drops the link, with a test asserting the original lot'susedis unchanged.RestorePlantingrestored a dangling lot referenceLive rows get their link nulled by
ON DELETE SET NULLwhen a lot is deleted — but a history snapshot still holds the old id. Undoing a planting deletion after retiring its lot would violate the foreign key and abort the entire revert. The revert now drops a dangling link before restoring: the plant really was in the ground, and that's the part worth restoring. Tested end-to-end.Used/Remainingwere unset on create and on the conflict rowFlagged by 3 models. A packet you just bought reported
remaining: 0— the exact opposite of the truth. The version-conflict path had the same gap, and that row is precisely what the client rebases on, so a 409 reportingremaining: 0is worse than no number at all. Both fill them now.Negative
remainingis deliberateTwo models flagged it as unguarded. It means more was planted than the lot recorded buying — which genuinely happens (a miscounted packet, a lot entered after the fact). Clamping to zero would hide the discrepancy at exactly the moment it's worth seeing. Now documented and tested rather than incidental.
Already fixed before the review landed
Deleting a plant would have silently cascaded away its seed lots — vendor, cost, germination rate, gone with no warning, because
seed_lots.plant_idisON DELETE CASCADEwhileplantingsisRESTRICT. Refused withErrPlantInUseinc3e6e5c.Tidiness
SeedLotUsageis scoped to the lots being filled instead of scanning every planting the owner has (4 models).ListSeedLotsForOwnergained theLIMITevery other list read has.parseNullablemoved into the shared request helpers — every nullable API field needs that absent/null/value distinction, so it shouldn't live in whichever file wanted it first.finalizePlantvalidates against its ownmaxPlantVendorLenrather than borrowing a constant named for seed lots.seedLotForPlanting→checkSeedLotForPlanting, since it validates rather than fetches. DuplicateintPtrgave way to the existingptrInt.Declined
change_setsare anchored to a garden; lots are user-scoped deliberately — they belong to you, not to any one garden, so there's no garden to record them against.Versionlacks amin=1binding.binding:"required"already rejects the zero value on anint64, and a negative version fails the guard on its own.ErrNoRows→GetSeedLot→ErrNotFound→ 404, which is the right answer for a row that's gone.go test ./...,go vet ./...,gofmtgreen.