#50 stores provenance and lots; this is where you actually use them. That question gets asked twice — at the seed rack in January, and standing over a bed in May — so the answer shows up in both places.
At the seed rack
The plant card carries vendor and a source link, plus a seed count that sits with the actions rather than in the body: it's what you scan down a list of twenty packets deciding what to order, so it wants a fixed place on the card. Expanding shows each lot separately — vendor, purchase date, packed-for year, germination %, cost (and per-unit cost) — with its own remaining count. Two lots of one variety are two rows with independent numbers, which is the whole reason inventory lives on the purchase rather than the plant.
State is encoded in form as well as number: a coloured chip that says "low" or "empty" in words, plus a proportional bar. A bare number doesn't survive being skimmed, and skimming is the actual use.
over-planted is a state of its own rather than clamped to zero — planting more than you recorded buying happens (a miscounted packet, a lot entered after the fact), and hiding it defeats the point of tracking.
Standing over a bed
The plant picker shows remaining next to the plant you're about to place. A plant with several lots asks which packet it came out of; one lot auto-attributes and zero lots stays silent — forcing that question on every placement is how a nicety becomes an obstacle. The armed lot rides along with the armed plant, so repeat placement keeps attributing without asking again.
… · 85 seeds left only appears where lots exist. Showing "0 left" on every plant without a lot would be noise that trains you to ignore the number.
Links are treated as untrusted
Re-checked client-side before rendering, and rel="noopener noreferrer" on every outbound anchor. The server already scheme-checks them (#50) — but a link is rendered from whatever the client was handed: an old row, a different server version, a tampered response. "The backend validated it" isn't a reason to hand javascript: to an anchor tag, and the check costs nothing.
The form pre-validates too, so pasting johnnyseeds.com says which field and why instead of coming back as a generic server error.
Small things
A new lot inherits the plant's vendor and source link — the usual case is buying the variety you already recorded from the place you recorded it.
Retiring a lot says plainly that the plantings survive it, because "will this wipe my garden" is the reasonable fear.
Quantities are REAL (grams are fractional) but a whole number of seeds renders as 100, not 100.0.
Verification
12 new tests over the logic that carries the behaviour: the URL rejection table, every lot state including over and the no-quantity case, summarizing across lots (worst state wins; the unit is dropped rather than summing seeds and grams together), and cost formatting with extra precision where per-seed cost would otherwise round to $0.00.
npm test (76), tsc --noEmit, npm run build, go test ./... green.
Closes #51. Completes Phase 1 of #58.
#50 stores provenance and lots; this is where you actually use them. That question gets asked twice — **at the seed rack in January, and standing over a bed in May** — so the answer shows up in both places.
### At the seed rack
The plant card carries vendor and a source link, plus a **seed count that sits with the actions** rather than in the body: it's what you scan down a list of twenty packets deciding what to order, so it wants a fixed place on the card. Expanding shows each lot separately — vendor, purchase date, packed-for year, germination %, cost (and per-unit cost) — with its own remaining count. Two lots of one variety are two rows with independent numbers, which is the whole reason inventory lives on the purchase rather than the plant.
**State is encoded in form as well as number**: a coloured chip that says *"low"* or *"empty"* in words, plus a proportional bar. A bare number doesn't survive being skimmed, and skimming is the actual use.
`over-planted` is a state of its own rather than clamped to zero — planting more than you recorded buying happens (a miscounted packet, a lot entered after the fact), and hiding it defeats the point of tracking.
### Standing over a bed
The plant picker shows remaining next to the plant you're about to place. A plant with **several** lots asks which packet it came out of; **one** lot auto-attributes and **zero** lots stays silent — forcing that question on every placement is how a nicety becomes an obstacle. The armed lot rides along with the armed plant, so repeat placement keeps attributing without asking again.
`… · 85 seeds left` only appears where lots exist. Showing "0 left" on every plant without a lot would be noise that trains you to ignore the number.
### Links are treated as untrusted
Re-checked client-side before rendering, and `rel="noopener noreferrer"` on every outbound anchor. The server already scheme-checks them (#50) — but a link is rendered from **whatever the client was handed**: an old row, a different server version, a tampered response. "The backend validated it" isn't a reason to hand `javascript:` to an anchor tag, and the check costs nothing.
The form pre-validates too, so pasting `johnnyseeds.com` says which field and why instead of coming back as a generic server error.
### Small things
- A new lot inherits the plant's vendor and source link — the usual case is buying the variety you already recorded from the place you recorded it.
- Retiring a lot says plainly that the plantings survive it, because *"will this wipe my garden"* is the reasonable fear.
- Quantities are `REAL` (grams are fractional) but a whole number of seeds renders as `100`, not `100.0`.
### Verification
12 new tests over the logic that carries the behaviour: the URL rejection table, every lot state including `over` and the no-quantity case, summarizing across lots (worst state wins; the unit is dropped rather than summing seeds and grams together), and cost formatting with extra precision where per-seed cost would otherwise round to `$0.00`.
`npm test` (76), `tsc --noEmit`, `npm run build`, `go test ./...` green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
Live status board. Findings are posted in each model's own comment. Advisory only — does not block merge.
<!-- gadfly-status-board -->
## 🪰 Gadfly — live review status
3/5 reviewers finished · updated 2026-07-21 06:02:03Z
#### `claude-code/sonnet` · claude-code — ✅ done
- ✅ **security** — No material issues found
- ✅ **correctness** — Minor issues
- ✅ **maintainability** — Minor issues
- ✅ **performance** — No material issues found
- ✅ **error-handling** — Minor issues
#### `glm-5.2:cloud` · ollama-cloud — ✅ done
- ✅ **security** — No material issues found
- ✅ **correctness** — No material issues found
- ✅ **maintainability** — Minor issues
- ✅ **performance** — No material issues found
- ✅ **error-handling** — Minor issues
#### `kimi-k2.6:cloud` · ollama-cloud — ⏳ 4/5 lenses
- ✅ **security** — No material issues found
- ✅ **correctness** — No material issues found
- ✅ **maintainability** — Blocking issues found
- ✅ **performance** — No material issues found
- 🔄 **error-handling** — running
#### `opencode/glm-5.2:cloud` · opencode — ✅ done
- ✅ **security** — No material issues found
- ✅ **correctness** — Minor issues
- ✅ **maintainability** — Minor issues
- ✅ **performance** — No material issues found
- ✅ **error-handling** — No material issues found
#### `opencode/kimi-k2.6:cloud` · opencode — ⏳ 4/5 lenses
- ✅ **security** — No material issues found
- 🔄 **correctness** — running
- ✅ **maintainability** — Minor issues
- ✅ **performance** — Minor issues
- ⚠️ **error-handling** — could not complete
<sub>Live status board. Findings are posted in each model's own comment. Advisory only — does not block merge.</sub>
Fixed in e83fbe4. The correctness finding is a real bug and the interesting one.
The Seed Tray never attributed a lot
armPlant from the Seed Tray bypassed the picker entirely, so it skipped the single-lot auto-attribution — not even in the unambiguous case. The tray is the quickest way to place a plant, which means the fastest path was the one that silently lost which packet the seed came from.
And it would have looked fine. Attribution is invisible until you go looking at a remaining count that's quietly wrong. armPlant now does the same auto-attribution the picker does; several lots stays unattributed rather than guessing, because that choice belongs in the picker.
A single exhausted lot was indistinguishable from no lot
The remaining > 0 filter dropped it before the count was taken, so attribution vanished with no signal. attributableLots now offers exhausted lots too, just not first — the count records what you wrote down, not a fact about the packet. Refusing to attribute a planting because the number says zero makes a wrong number harder to correct, not easier. That filter was also written twice; now it's one rule in one place.
The lot form had no 409 handling
Alone among the version-guarded forms here. A concurrent edit made you retype everything. It now rebases onto the current row and says so, matching the garden and plant forms.
Deduplication
The outbound-link anchor was duplicated verbatim between the plant card and the lot rows — including the security-relevant rel attribute, which is exactly the kind of thing that gets copied minus one attribute next time. Now one SourceLink that re-checks the URL and renders nothing when it isn't safe, so callers can't forget to guard.
remainingLabel had its own copy of the unit-agreement rule summarizeLots already owns; it calls it now. formatQuantity moved out of a component file into lib/seedLots.ts with the other formatters.
STATE_CLASS.over and .low were identical strings. Over-planted is a discrepancy to look at, not a shortage to act on, so it now reads as its own warning rather than sharing styling with low.
79 tests, tsc --noEmit, npm run build, go test ./... green.
Fixed in `e83fbe4`. The correctness finding is a real bug and the interesting one.
### The Seed Tray never attributed a lot
`armPlant` from the Seed Tray bypassed the picker entirely, so it skipped the single-lot auto-attribution — **not even in the unambiguous case.** The tray is the *quickest* way to place a plant, which means the fastest path was the one that silently lost which packet the seed came from.
And it would have looked fine. Attribution is invisible until you go looking at a remaining count that's quietly wrong. `armPlant` now does the same auto-attribution the picker does; several lots stays unattributed rather than guessing, because that choice belongs in the picker.
### A single exhausted lot was indistinguishable from no lot
The `remaining > 0` filter dropped it before the count was taken, so attribution vanished with no signal. `attributableLots` now offers exhausted lots too, just not first — **the count records what you wrote down, not a fact about the packet.** Refusing to attribute a planting because the number says zero makes a wrong number *harder* to correct, not easier. That filter was also written twice; now it's one rule in one place.
### The lot form had no 409 handling
Alone among the version-guarded forms here. A concurrent edit made you retype everything. It now rebases onto the current row and says so, matching the garden and plant forms.
### Deduplication
The outbound-link anchor was duplicated verbatim between the plant card and the lot rows — **including the security-relevant `rel` attribute**, which is exactly the kind of thing that gets copied minus one attribute next time. Now one `SourceLink` that re-checks the URL and renders nothing when it isn't safe, so callers can't forget to guard.
`remainingLabel` had its own copy of the unit-agreement rule `summarizeLots` already owns; it calls it now. `formatQuantity` moved out of a component file into `lib/seedLots.ts` with the other formatters.
`STATE_CLASS.over` and `.low` were identical strings. Over-planted is a *discrepancy to look at*, not a *shortage to act on*, so it now reads as its own warning rather than sharing styling with low.
79 tests, `tsc --noEmit`, `npm run build`, `go test ./...` green.
question gets asked twice — at the seed rack in January, and standing over a bed
in May — so the answer shows up in both places.
On the plant card: vendor and a source link, plus a seed count that sits with
the actions rather than in the body, because it's what you scan down a list of
twenty packets deciding what to order. Expanding shows each lot separately with
vendor, dates, germination, cost, and its own remaining count — two lots of one
variety are two rows with independent numbers, which is the whole reason
inventory lives on the purchase rather than on the plant.
State is encoded in form as well as number: a coloured chip that says "low" or
"empty" in words, and a proportional bar. A bare number doesn't survive being
skimmed, and skimming is the actual use. "Over-planted" is a state of its own
rather than being clamped to zero — planting more than you recorded buying
happens, and hiding it defeats the point of tracking.
In the plant picker, remaining shows next to the plant you're about to place,
and a plant with several lots asks which packet it came out of. One lot
auto-attributes and zero lots stays silent: forcing that question on every
placement is how a nicety becomes an obstacle. The armed lot rides along with
the armed plant, so repeat placement keeps attributing without asking again.
External URLs are re-checked client-side before being rendered as links, and
carry rel="noopener noreferrer". The server already scheme-checks them, but a
link is rendered from whatever the client was handed — an old row, a different
server version, a tampered response — and "the backend validated it" is not a
reason to hand javascript: to an anchor tag.
Retiring a lot says plainly that the plantings survive it, since "will this wipe
my garden" is the reasonable fear.
Closes#51
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
The correctness finding is a real bug and the interesting one: arming a plant
from the Seed Tray never attributed a seed lot, not even in the unambiguous
single-lot case. The tray is the QUICKEST way to place a plant, so the fastest
path was the one that silently lost which packet the seed came from — and it
would have looked fine, because attribution is invisible until you go looking at
a remaining count that's wrong. armPlant now does the same single-lot
auto-attribution the picker does. Several lots stays unattributed rather than
guessing; the picker is where that choice belongs.
A single exhausted lot was also indistinguishable from having no lot, because
the "available" filter dropped it before the count was taken. That silently
dropped attribution with no signal. attributableLots now offers exhausted lots
too, just not first: the count records what was written down, not a fact about
the packet, so refusing to attribute a planting because the number says zero
makes a wrong number harder to correct rather than easier. It's also now one
rule in one place instead of the same filter written twice.
The seed lot form had no 409 handling, alone among the version-guarded forms
here. A concurrent edit made you retype everything; it now rebases onto the
current row and says so, like the garden and plant forms do.
Deduplication: the outbound-link anchor was duplicated verbatim between the
plant card and the lot rows, including the security-relevant rel attribute — now
one SourceLink that re-checks the URL and renders nothing when it isn't safe, so
callers can't forget to guard. remainingLabel had its own copy of the
unit-agreement rule that summarizeLots already owns; it now calls it.
formatQuantity moved out of a component file into lib/seedLots.ts with the other
formatters.
STATE_CLASS.over and .low were identical. "Over-planted" is a discrepancy to
look at, not a shortage to act on, so it now reads as its own warning.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
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 #51. Completes Phase 1 of #58.
#50 stores provenance and lots; this is where you actually use them. That question gets asked twice — at the seed rack in January, and standing over a bed in May — so the answer shows up in both places.
At the seed rack
The plant card carries vendor and a source link, plus a seed count that sits with the actions rather than in the body: it's what you scan down a list of twenty packets deciding what to order, so it wants a fixed place on the card. Expanding shows each lot separately — vendor, purchase date, packed-for year, germination %, cost (and per-unit cost) — with its own remaining count. Two lots of one variety are two rows with independent numbers, which is the whole reason inventory lives on the purchase rather than the plant.
State is encoded in form as well as number: a coloured chip that says "low" or "empty" in words, plus a proportional bar. A bare number doesn't survive being skimmed, and skimming is the actual use.
over-plantedis a state of its own rather than clamped to zero — planting more than you recorded buying happens (a miscounted packet, a lot entered after the fact), and hiding it defeats the point of tracking.Standing over a bed
The plant picker shows remaining next to the plant you're about to place. A plant with several lots asks which packet it came out of; one lot auto-attributes and zero lots stays silent — forcing that question on every placement is how a nicety becomes an obstacle. The armed lot rides along with the armed plant, so repeat placement keeps attributing without asking again.
… · 85 seeds leftonly appears where lots exist. Showing "0 left" on every plant without a lot would be noise that trains you to ignore the number.Links are treated as untrusted
Re-checked client-side before rendering, and
rel="noopener noreferrer"on every outbound anchor. The server already scheme-checks them (#50) — but a link is rendered from whatever the client was handed: an old row, a different server version, a tampered response. "The backend validated it" isn't a reason to handjavascript:to an anchor tag, and the check costs nothing.The form pre-validates too, so pasting
johnnyseeds.comsays which field and why instead of coming back as a generic server error.Small things
REAL(grams are fractional) but a whole number of seeds renders as100, not100.0.Verification
12 new tests over the logic that carries the behaviour: the URL rejection table, every lot state including
overand the no-quantity case, summarizing across lots (worst state wins; the unit is dropped rather than summing seeds and grams together), and cost formatting with extra precision where per-seed cost would otherwise round to$0.00.npm test(76),tsc --noEmit,npm run build,go test ./...green.🤖 Generated with Claude Code
https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
🪰 Gadfly — live review status
3/5 reviewers finished · updated 2026-07-21 06:02:03Z
claude-code/sonnet· claude-code — ✅ doneglm-5.2:cloud· ollama-cloud — ✅ donekimi-k2.6:cloud· ollama-cloud — ⏳ 4/5 lensesopencode/glm-5.2:cloud· opencode — ✅ doneopencode/kimi-k2.6:cloud· opencode — ⏳ 4/5 lensesLive status board. Findings are posted in each model's own comment. Advisory only — does not block merge.
Fixed in
e83fbe4. The correctness finding is a real bug and the interesting one.The Seed Tray never attributed a lot
armPlantfrom the Seed Tray bypassed the picker entirely, so it skipped the single-lot auto-attribution — not even in the unambiguous case. The tray is the quickest way to place a plant, which means the fastest path was the one that silently lost which packet the seed came from.And it would have looked fine. Attribution is invisible until you go looking at a remaining count that's quietly wrong.
armPlantnow does the same auto-attribution the picker does; several lots stays unattributed rather than guessing, because that choice belongs in the picker.A single exhausted lot was indistinguishable from no lot
The
remaining > 0filter dropped it before the count was taken, so attribution vanished with no signal.attributableLotsnow offers exhausted lots too, just not first — the count records what you wrote down, not a fact about the packet. Refusing to attribute a planting because the number says zero makes a wrong number harder to correct, not easier. That filter was also written twice; now it's one rule in one place.The lot form had no 409 handling
Alone among the version-guarded forms here. A concurrent edit made you retype everything. It now rebases onto the current row and says so, matching the garden and plant forms.
Deduplication
The outbound-link anchor was duplicated verbatim between the plant card and the lot rows — including the security-relevant
relattribute, which is exactly the kind of thing that gets copied minus one attribute next time. Now oneSourceLinkthat re-checks the URL and renders nothing when it isn't safe, so callers can't forget to guard.remainingLabelhad its own copy of the unit-agreement rulesummarizeLotsalready owns; it calls it now.formatQuantitymoved out of a component file intolib/seedLots.tswith the other formatters.STATE_CLASS.overand.lowwere identical strings. Over-planted is a discrepancy to look at, not a shortage to act on, so it now reads as its own warning rather than sharing styling with low.79 tests,
tsc --noEmit,npm run build,go test ./...green.ffe32d58cfto4597978034