"Take notes through the season on each bed and plant."
There's already free-text notes on gardens, objects and plants — but it's a single mutable field, so writing "powdery mildew on the west bed" overwrites what you wrote in June. The distinction worth keeping: notes is what this thing IS; a journal entry is what HAPPENED, and when. Both stay.
garden_id is NOT NULL even for a bed-level entry
That's the whole trick. Permission checks reuse requireGardenRoleunchanged — no second ACL path is invented. object_id/planting_id narrow the target; the garden always anchors it.
Because the garden anchors permission, the target is checked to actually live in that garden. Without that, a valid object id from someone else's garden would be storable against this one and then leak through the list read. A plop-level entry that also names an object must name the right one, or the object and planting filters would disagree about what an entry is about.
observed_at ≠ created_at
You write up Saturday's observations on Sunday, and Saturday is the date that matters. Listing orders by observation, newest first, with id breaking ties inside a day so several notes from one afternoon still read in a sensible order.
Roles follow the shape that already exists
Editors write, viewers read, strangers get ErrNotFound. An author may edit their own entries. The garden owner may delete any entry in their garden but may not edit one — rewriting somebody else's observation under their name is a different act from removing it, and the owner-as-backstop rule only justifies the second.
Stays out of the revision history, on purpose
Per the decision recorded on #52: entries are already append-shaped and individually versioned, and undoing a note is just deleting it. Running them through change sets would add a layer that buys nothing.
There's a test asserting journal writes produce zero change sets, so that decision can't quietly reverse itself when someone later wires record() into a new service method out of habit.
8 new tests, one per acceptance criterion: all three target levels listing correctly under each, deleting a bed removing only its entries, backdated entries ordering by observation (and a date range filtering on it), observed_at defaulting to today, the full permission matrix including owner-can-delete-but-not-edit, foreign and mismatched targets refused, the version guard, paging, and the no-change-sets assertion.
Closes #52. Phase 1 of #58.
*"Take notes through the season on each bed and plant."*
There's already free-text `notes` on gardens, objects and plants — but it's a single mutable field, so writing *"powdery mildew on the west bed"* overwrites what you wrote in June. The distinction worth keeping: **`notes` is what this thing IS; a journal entry is what HAPPENED, and when.** Both stay.
### `garden_id` is NOT NULL even for a bed-level entry
That's the whole trick. Permission checks reuse `requireGardenRole` **unchanged** — no second ACL path is invented. `object_id`/`planting_id` narrow the target; the garden always anchors it.
Because the garden anchors permission, the target is checked to actually live in that garden. Without that, a valid object id from *someone else's* garden would be storable against this one and then leak through the list read. A plop-level entry that also names an object must name the *right* one, or the object and planting filters would disagree about what an entry is about.
### `observed_at` ≠ `created_at`
You write up Saturday's observations on Sunday, and Saturday is the date that matters. Listing orders by **observation**, newest first, with `id` breaking ties inside a day so several notes from one afternoon still read in a sensible order.
### Roles follow the shape that already exists
Editors write, viewers read, strangers get `ErrNotFound`. An author may edit their own entries. The garden owner may **delete** any entry in their garden but may **not edit** one — rewriting somebody else's observation under their name is a different act from removing it, and the owner-as-backstop rule only justifies the second.
### Stays out of the revision history, on purpose
Per the decision recorded on #52: entries are already append-shaped and individually versioned, and undoing a note is just deleting it. Running them through change sets would add a layer that buys nothing.
There's a test asserting journal writes produce **zero** change sets, so that decision can't quietly reverse itself when someone later wires `record()` into a new service method out of habit.
### API
```
GET,POST /gardens/:id/journal (?objectId= &plantingId= &from= &to= &limit= &offset=)
PATCH,DELETE /journal/:id
```
### Verification
8 new tests, one per acceptance criterion: all three target levels listing correctly under each, deleting a bed removing only its entries, backdated entries ordering by observation (and a date range filtering on it), `observed_at` defaulting to today, the full permission matrix including owner-can-delete-but-not-edit, foreign and mismatched targets refused, the version guard, paging, and the no-change-sets assertion.
`go test ./...`, `go vet ./...`, `gofmt` 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 05:36:23Z
#### `claude-code/sonnet` · claude-code — ✅ done
- ✅ **security** — No material issues found
- ✅ **correctness** — Blocking issues found
- ✅ **maintainability** — Minor issues
- ✅ **performance** — No material issues found
- ✅ **error-handling** — Blocking issues found
#### `glm-5.2:cloud` · ollama-cloud — ✅ done
- ✅ **security** — No material issues found
- ✅ **correctness** — Blocking issues found
- ✅ **maintainability** — Blocking issues found
- ✅ **performance** — No material issues found
- ✅ **error-handling** — Minor issues
#### `kimi-k2.6:cloud` · ollama-cloud — ✅ done
- ✅ **security** — No material issues found
- ✅ **correctness** — Blocking issues found
- ✅ **maintainability** — Blocking issues found
- ✅ **performance** — No material issues found
- ✅ **error-handling** — No material issues found
#### `opencode/glm-5.2:cloud` · opencode — ⏳ 2/5 lenses
- 🔄 **security** — running
- 🔄 **correctness** — running
- ⏸️ **maintainability** — queued
- ✅ **performance** — No material issues found
- ✅ **error-handling** — Blocking issues found
#### `opencode/kimi-k2.6:cloud` · opencode — ⏳ 2/5 lenses
- ✅ **security** — No material issues found
- 🔄 **correctness** — running
- 🔄 **maintainability** — running
- ✅ **performance** — No material issues found
- 🔄 **error-handling** — running
<sub>Live status board. Findings are posted in each model's own comment. Advisory only — does not block merge.</sub>
Fixed in 1e6e51e. The first finding is a genuinely embarrassing one and worth naming plainly.
PATCH and DELETE /journal/:id were never registered
Both handlers existed. Both were covered by service-level tests. Both were completely unreachable. My edit to the router anchored on a string that only exists on a different branch, so it silently did nothing — and because every journal test went through the service layer, nothing noticed.
Registered, and now covered by an API-level test that walks the whole lifecycle through the router: create → list → patch → stale-version 409 → delete → confirm gone. That's the only kind of test that could have caught this, so anything addressed by its own id gets one from here on. Flagged by 4 lenses across 2 models, which is exactly right — it's a correctness bug, dead code, and an untestable error path all at once.
A plop entry was invisible under its own bed
Naming only a plantingId left object_id null, so "notes about this bed" silently excluded every note written about something growing in the bed. The object filter and the planting filter disagreed about what an entry is about — which is precisely the question the reader is asking. The parent object is now derived from the planting, with a test.
The rest
checkJournalTarget flattened every store error to ErrInvalidInput, so a real database failure surfaced as a 400 telling the caller their request was malformed, and never reached the logs. Only "no such row" is a bad reference now; anything else passes through.
ListJournalEntries repeated the column scan order inline, one column different from scanJournalEntry — the classic way for a shared column list to drift out of step with its readers. Both now build from journalScanTargets, with the list appending the joined author name.
Also added API tests for the query filters, including that a malformed objectId or from is a 400 rather than a silently-ignored filter that widens the query back to the whole garden.
go test ./..., go vet ./..., gofmt green.
Fixed in `1e6e51e`. The first finding is a genuinely embarrassing one and worth naming plainly.
### PATCH and DELETE `/journal/:id` were never registered
Both handlers existed. Both were covered by service-level tests. Both were **completely unreachable.** My edit to the router anchored on a string that only exists on a different branch, so it silently did nothing — and because every journal test went through the service layer, nothing noticed.
Registered, and now covered by an **API-level test that walks the whole lifecycle through the router**: create → list → patch → stale-version 409 → delete → confirm gone. That's the only kind of test that could have caught this, so anything addressed by its own id gets one from here on. Flagged by 4 lenses across 2 models, which is exactly right — it's a correctness bug, dead code, and an untestable error path all at once.
### A plop entry was invisible under its own bed
Naming only a `plantingId` left `object_id` null, so *"notes about this bed"* silently excluded every note written about something growing **in** the bed. The object filter and the planting filter disagreed about what an entry is about — which is precisely the question the reader is asking. The parent object is now derived from the planting, with a test.
### The rest
- **`checkJournalTarget` flattened every store error to `ErrInvalidInput`**, so a real database failure surfaced as a 400 telling the caller their request was malformed, and never reached the logs. Only "no such row" is a bad reference now; anything else passes through.
- **`ListJournalEntries` repeated the column scan order inline**, one column different from `scanJournalEntry` — the classic way for a shared column list to drift out of step with its readers. Both now build from `journalScanTargets`, with the list appending the joined author name.
Also added API tests for the query filters, including that a malformed `objectId` or `from` is a 400 rather than a silently-ignored filter that widens the query back to the whole garden.
`go test ./...`, `go vet ./...`, `gofmt` green.
"Take notes through the season on each bed and plant." There is already
free-text notes on gardens, objects and plants, but it is a single mutable
field: writing "powdery mildew on the west bed" overwrites what you wrote in
June. The distinction worth keeping is that notes says what this thing IS, while
a journal entry says what HAPPENED, and when. Both stay.
garden_id is NOT NULL even when an entry is about a bed or a single plop, and
that is the whole trick: permission checks reuse requireGardenRole unchanged and
no second ACL path is invented. object_id/planting_id narrow the target; the
garden always anchors it. Because the garden anchors permission, the target is
checked to actually live in that garden — otherwise a valid object id from
someone else's garden would be storable here and then leak through the list
read. A plop-level entry that also names an object must name the right one, or
the two filters would disagree about what an entry is about.
observed_at is distinct from created_at: you write up Saturday's observations on
Sunday, and Saturday is the date that matters. Listing orders by observation,
newest first, with id breaking ties inside a day.
Roles follow the existing shape. Editors write, viewers read, strangers get
ErrNotFound. An author may edit their own entries; the garden owner may DELETE
any entry in their garden but may not edit one — rewriting somebody else's
observation under their name is a different act from removing it.
Deliberately not wired into the revision history, per the decision on #52:
entries are already append-shaped and individually versioned, and undoing a note
is just deleting it. There's a test asserting journal writes produce no change
sets, so that decision can't quietly reverse itself later.
Closes#52
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
PATCH and DELETE /journal/:id were never registered. Both handlers existed, were
covered by service-level tests, and were completely unreachable — my edit to the
router anchored on a string that only exists on another branch, so it silently
did nothing. Registered, and covered by an API-level test that walks the whole
lifecycle through the router, because that is the only kind of test that could
have caught it. Anything addressed by its own id now has one.
An entry about a plop was invisible under its bed's filter. Naming only a
planting left object_id null, so "notes about this bed" silently excluded every
note written about something growing IN the bed — the two filters disagreed
about what an entry is about, which is precisely the question the reader is
asking. The parent object is now derived from the planting.
checkJournalTarget flattened every store error to ErrInvalidInput, so a real
database failure surfaced as a 400 telling the caller their request was bad, and
never reached the logs. Only "no such row" is a bad reference now; anything else
passes through.
ListJournalEntries repeated the column scan order inline, one column different
from scanJournalEntry — the classic way for a shared column list to drift out of
step with its readers. Both now build from journalScanTargets, with the list
appending the joined author name.
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 #52. Phase 1 of #58.
"Take notes through the season on each bed and plant."
There's already free-text
noteson gardens, objects and plants — but it's a single mutable field, so writing "powdery mildew on the west bed" overwrites what you wrote in June. The distinction worth keeping:notesis what this thing IS; a journal entry is what HAPPENED, and when. Both stay.garden_idis NOT NULL even for a bed-level entryThat's the whole trick. Permission checks reuse
requireGardenRoleunchanged — no second ACL path is invented.object_id/planting_idnarrow the target; the garden always anchors it.Because the garden anchors permission, the target is checked to actually live in that garden. Without that, a valid object id from someone else's garden would be storable against this one and then leak through the list read. A plop-level entry that also names an object must name the right one, or the object and planting filters would disagree about what an entry is about.
observed_at≠created_atYou write up Saturday's observations on Sunday, and Saturday is the date that matters. Listing orders by observation, newest first, with
idbreaking ties inside a day so several notes from one afternoon still read in a sensible order.Roles follow the shape that already exists
Editors write, viewers read, strangers get
ErrNotFound. An author may edit their own entries. The garden owner may delete any entry in their garden but may not edit one — rewriting somebody else's observation under their name is a different act from removing it, and the owner-as-backstop rule only justifies the second.Stays out of the revision history, on purpose
Per the decision recorded on #52: entries are already append-shaped and individually versioned, and undoing a note is just deleting it. Running them through change sets would add a layer that buys nothing.
There's a test asserting journal writes produce zero change sets, so that decision can't quietly reverse itself when someone later wires
record()into a new service method out of habit.API
Verification
8 new tests, one per acceptance criterion: all three target levels listing correctly under each, deleting a bed removing only its entries, backdated entries ordering by observation (and a date range filtering on it),
observed_atdefaulting to today, the full permission matrix including owner-can-delete-but-not-edit, foreign and mismatched targets refused, the version guard, paging, and the no-change-sets assertion.go test ./...,go vet ./...,gofmtgreen.🤖 Generated with Claude Code
https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
🪰 Gadfly — live review status
3/5 reviewers finished · updated 2026-07-21 05:36:23Z
claude-code/sonnet· claude-code — ✅ doneglm-5.2:cloud· ollama-cloud — ✅ donekimi-k2.6:cloud· ollama-cloud — ✅ doneopencode/glm-5.2:cloud· opencode — ⏳ 2/5 lensesopencode/kimi-k2.6:cloud· opencode — ⏳ 2/5 lensesLive status board. Findings are posted in each model's own comment. Advisory only — does not block merge.
Fixed in
1e6e51e. The first finding is a genuinely embarrassing one and worth naming plainly.PATCH and DELETE
/journal/:idwere never registeredBoth handlers existed. Both were covered by service-level tests. Both were completely unreachable. My edit to the router anchored on a string that only exists on a different branch, so it silently did nothing — and because every journal test went through the service layer, nothing noticed.
Registered, and now covered by an API-level test that walks the whole lifecycle through the router: create → list → patch → stale-version 409 → delete → confirm gone. That's the only kind of test that could have caught this, so anything addressed by its own id gets one from here on. Flagged by 4 lenses across 2 models, which is exactly right — it's a correctness bug, dead code, and an untestable error path all at once.
A plop entry was invisible under its own bed
Naming only a
plantingIdleftobject_idnull, so "notes about this bed" silently excluded every note written about something growing in the bed. The object filter and the planting filter disagreed about what an entry is about — which is precisely the question the reader is asking. The parent object is now derived from the planting, with a test.The rest
checkJournalTargetflattened every store error toErrInvalidInput, so a real database failure surfaced as a 400 telling the caller their request was malformed, and never reached the logs. Only "no such row" is a bad reference now; anything else passes through.ListJournalEntriesrepeated the column scan order inline, one column different fromscanJournalEntry— the classic way for a shared column list to drift out of step with its readers. Both now build fromjournalScanTargets, with the list appending the joined author name.Also added API tests for the query filters, including that a malformed
objectIdorfromis a 400 rather than a silently-ignored filter that widens the query back to the whole garden.go test ./...,go vet ./...,gofmtgreen.fea30c267dto6638696b00