Six real bugs, three of which would have broken the feature's central promise.
The worst was that a failed operation threw away the history for changes that
had already committed. WithChangeSet buffers HISTORY, not data — the store
writes inside fn commit as they go — so discarding the buffer on error left real
mutations with no change set and no way to undo them. That is exactly the
situation undo exists for: an agent turn that did half a thing and then failed.
Now the buffer is written, the summary says the operation failed partway, and
the error is still returned. RevertChangeSet does the same for a revert that
fails mid-loop. The partial state is still partial, but it is visible and
undoable instead of orphaned.
versionGuard falsely conflicted whenever one change set held more than one
revision for the same entity — an agent turn that moves a bed and then renames
it. Each inverse bumps the row's version, so from the second one on the
snapshot's version no longer matched the live row and the revert flagged its own
work as somebody else's edit. RevertChangeSet now tracks what it has written and
the guard compares against that.
ListChangeSets found "was this reverted?" with a LEFT JOIN, which emits one
duplicate row per revert once a change set has been reverted more than once
(undo, redo, undo again). Now a scalar subquery.
Reverting an object creation cascade-deleted anything planted in that bed since,
quietly. The plops were snapshotted so it was recoverable, but deleting
someone's plants as a side effect of an unrelated undo should be reported. It
now conflicts and leaves the bed alone.
ClearObject cleared by predicate and snapshotted by a separate read, so a plop
created between the two was removed with no revision to undo it by. It now
clears exactly the ids it read. It also returned an error when only the
post-clear re-read failed, which told the caller a clear had failed after it had
already applied — inviting a retry of an applied operation. That path now logs
the history gap and reports success, matching how record() treats its own write
failures.
RestoreObject/RestorePlanting could lose the race between the existence check
and the insert and surface a raw constraint error. The revert now re-checks and
reports ConflictExists, which is what that condition means.
RevertChangeSet takes a source, so an agent undoing its own work is
distinguishable from a person clicking undo — the whole point of the badge.
Refactoring: the three revert bodies repeated a load/guard/unsnapshot/update
skeleton (flagged by 3 of 5 models). They are now one generic revertEntity over
a small per-type op table, so the ordering, guards and conflict reporting cannot
drift apart. The API's view structs duplicated domain types that already carried
every field, against the package's direct-JSON convention — dropped. intQuery
moved next to the other request helpers. planRevert's comment said three passes
where the code runs five. A revert where every revision is already a no-op now
answers 200 rather than 201 with a null change set.
Six new tests, one per bug.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
The undo substrate. The agent is going to act freely — "empty the garlic bed and
plant cucumbers" runs without a confirmation prompt — and that is only a
defensible default if the result is easy to roll back. So undo lands before the
agent write path, not after it.
The unit of undo is the OPERATION, not the row. A change set groups the
row-level revisions it produced; revert replays their inverses. Emptying a bed
and replanting it touches one object and many plantings, and undoing half of
that is worse than useless.
Two properties shape the rest:
Revert is itself a change set (reverts_id names its target), so history is
append-only and an undo can be undone. git revert, not git reset.
Revert is version-guarded per entity. Every snapshot carries the row's version;
if something edited that row after the change set being reverted, restoring the
old snapshot would silently discard the newer edit, so it is reported as a
conflict and left alone while the rest of the change set still reverts.
The auto-scope is what keeps this invasive but shallow. Service mutations call
record(), which joins the change set on the context if one is open and otherwise
opens a single-op one on the spot. REST handlers needed no edits at all and
every UI mutation lands in history for free; only the agent wraps a whole turn.
Multi-row operations (FillRegion, ClearObject) pass all their changes in one
record call, so a 12-plop fill is one change set with 12 revisions and one undo.
Revisions are buffered and written with their change set in a single
transaction, so an operation that fails partway leaves no half-recorded history.
Recording is best-effort after the fact: the row is already written, so failing
the caller there would report a failure that didn't happen and invite a
duplicate retry. A gap is logged loudly instead.
Two sharp edges handled explicitly rather than by luck:
Deleting an object cascades its plantings away at the FK level, where the
service never sees them. deleteObjectRecording snapshots them first, or the
delete would be listed in history and not actually be undoable.
Restoring deleted rows reuses their ids, and a restored plop needs its object
back first. planRevert runs three explicit passes — restore parents then
children, then updates, then delete created children before their parents —
instead of trusting reverse-seq ordering to happen to be right.
Garden deletion stays out of scope, as designed: the cascade is invisible to the
service and ON DELETE CASCADE would take the history with it. The history UI
will say so rather than pretend otherwise.
Closes#48
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
Closes#44. Two independent grids (garden + per-bed), each with a size and a snap toggle; snapping defaults off. See PR #45 for details.
Co-authored-by: Steve Dudenhoeffer <[email protected]>
Per-garden public read-only link: unauthenticated GET /api/v1/public/gardens/:token (no requireAuth, no OIDC), owner-only enable/rotate/disable, and a /g/$token page rendering GardenCanvas read-only. Review fixes: redact plant owner ids, Cache-Control: no-store, 400 on malformed body, shared resetTransient store action.
Closes#41.
Co-authored-by: Steve Dudenhoeffer <[email protected]>
Fixes from the PR #28 adversarial review (considered; not graded).
Correctness / API
- PATCH /objects/:id can now clear nullable color/props back to NULL: the
request takes them as json.RawMessage, and ObjectPatch carries an explicit
Set flag so an explicit `null` (clear) is distinguished from an absent
field (unchanged) — the strongest cross-model finding (6 hits). New test.
Maintainability
- store/plantings.go + plants.go use explicit qualified column lists
(qualifyColumns helper) instead of SELECT *, matching gardens/objects and
surviving a future column add.
- Consolidated objectKinds + plantableByDefault into one kind→traits map.
- objectForRole factors the fetch-then-authorize shared by UpdateObject and
DeleteObject; dropped the redundant kind check in CreateObject
(finalizeObject is the single validation point).
- Request→service mapping via toInput()/toPatch() methods (matches gardens).
- Renamed handler gardenFull → getGardenFull (verbNoun); test helper
decodeGarden → decodeMap; generic bind-error messages.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
Garden objects (beds, bags, containers, in-ground, trees, paths,
structures) in one polymorphic table, following the #7 service
conventions.
- store/objects.go: scanObject + Create (RETURNING), Get, ListForGarden
(z_index order), version-guarded Update (RETURNING, returns current row
on conflict), Delete (plantings cascade via FK).
- store/plantings.go + plants.go: the read side /full needs now —
ListActivePlantingsForGarden (removed_at IS NULL) and
ListReferencedPlants (distinct plants used by active plantings). Both
return empty until #14 adds plantings; #12/#14 extend these files.
- service/objects.go: Create/Update(partial patch)/Delete/GardenFull, all
(ctx, actorID, ...) through requireGardenRole(editor for mutations,
viewer for /full). finalizeObject validates kind + shape (rect/circle;
polygon reserved), finite dims in [1cm,100m], NaN/Inf rejection, loose
bbox-overlaps-garden placement (partial overhang OK), hex color, valid
JSON props, name/notes caps; normalizes rotation to [0,360). Plantable
defaults by kind, overridable.
- api/objects.go: POST /gardens/:id/objects, PATCH/DELETE /objects/:id,
GET /gardens/:id/full ({garden,objects,plantings,plants}). props is any
JSON value stored as text; PATCH is partial + required version, 409
returns the current row.
Tests: service (defaults, plantable-by-kind, rotation normalize,
validation rejects incl. off-field/polygon/bad-color/bad-props, partial
patch + version conflict, cross-user ErrNotFound, delete, /full shape) and
api (CRUD + /full, 409 envelope, cross-user 404, polygon/no-kind 400,
props round-trip).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
Fixes from the PR #26 adversarial review (graded 18 real / 0 false positive).
Correctness / security
- maxGardenCM fixed to 10_000 (100 m), matching its comment — it was
100_000 cm (1 km), 10x too lax (5 models flagged this).
- Dimension validation now rejects NaN/Inf (which slip past naive
comparisons) and subnormal-tiny positives, via a finite [1cm, 100m]
check. Name (200) and notes (10_000) are length-capped so untrusted
input can't balloon storage.
- Update version binding is `required,min=1`, so a negative/zero version
is a 400, not a 409.
Maintainability / performance
- One unified writeServiceError (new errors.go) maps every auth + resource
sentinel; writeResourceError removed. writeVersionConflict and
parseIDParam moved to errors.go (shared, not in the gardens feature file).
- Request structs share an embedded gardenFields (one toInput).
- CreateGarden uses INSERT ... RETURNING (one round-trip).
- ListGardensForOwner has a defensive LIMIT (pagination is post-v1).
Tests: name/notes length, NaN/Inf/subnormal dims, dimension-at-cap valid,
negative/zero version -> 400.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
Establishes the patterns every later backend issue copies: the actor
parameter, centralized role checks, and the version-guard/409 sync
protocol. The service layer is the seam both REST handlers and future
agent tools call, so permissions live here, not in handlers.
- service/gardens.go: Service methods take (ctx, actorID, args).
requireGardenRole(ctx, actor, gardenID, min) is THE authorization point
— owner is implicit via owner_id now; #16 extends it to consult
garden_shares. A user with no role gets ErrNotFound (existence masked),
not ErrForbidden. Create/Get/List/Update/Delete with input validation
(name required, 0 dims default to 10 m on create / rejected on update,
negatives always rejected, unit metric|imperial, 100 m cap).
- store/gardens.go: version-guarded UPDATE ... WHERE id=? AND version=?
RETURNING; a no-match re-reads to return (current row,
ErrVersionConflict) vs ErrNotFound. ListGardensForOwner returns a
non-nil slice.
- api/gardens.go: GET,POST /gardens and GET,PATCH,DELETE /gardens/:id
behind requireAuth. writeVersionConflict documents the 409 envelope
({error:{code,message}, current:{...}}) — the contract for every
mutable resource. writeResourceError maps ErrNotFound/Forbidden/
InvalidInput/VersionConflict; parseIDParam guards path ids.
Tests: service (defaults, validation, owned-only list, version
conflict returns current + retry, cross-user ErrNotFound, delete) and
api (full CRUD flow, 409 envelope shape, cross-user 404, auth required,
create validation). Verified against the running binary: create stores
imperial 122x244 cm and list returns it.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
Fixes from the PR #24 adversarial review (graded 23 real / 1 false positive):
Security / correctness
- LinkOIDC no longer overwrites a different stored identity: the UPDATE
matches only when the row has no identity yet or already carries this
exact one, so a second IdP asserting the same verified email can't
hijack or lock out an account (returns ErrOIDCIdentityConflict). Uses
a single UPDATE...RETURNING (also fixes the ignored-RowsAffected /
misleading-ErrNotFound path and the round-trip).
- Provisioning now requires a verified email for BOTH linking and JIT
creation (was: linking only), so an unverified-email identity can't
create an account — nor become the first admin on a fresh instance,
nor squat an email a real user later owns.
- OIDC-identity collisions surface as the dedicated ErrOIDCIdentityConflict
instead of the email-specific ErrEmailTaken.
Robustness
- readOIDCTxCookie requires a non-empty nonce (an empty one would make the
callback's nonce check pass vacuously).
- Callback token exchange + verify run under a 15s context timeout so a
slow IdP can't outlast the server write timeout.
- ensure() performs discovery outside the mutex, so concurrent cold-start
requests don't serialize behind one another's full timeout.
- setOIDCTxCookie returns its marshal error; oidcLogin aborts rather than
redirecting to the IdP with no tx cookie.
Maintainability
- redirectAuthError helper dedups the ~dozen callback redirects (and the
empty-code path now logs like the rest).
- Distinct login error codes (no_email / email_unverified / oidc_conflict)
for the UI; writeServiceError maps the OIDC sentinels; shared test issuer
const.
Tests: unverified email refused for both link and JIT; identity-overwrite
refused while the original identity keeps working.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
OIDC is pansy's primary login path (Authentik the target IdP); local
auth (#4) remains the fallback and both issue the same session cookie.
- deps: github.com/coreos/go-oidc/v3 + golang.org/x/oauth2 (both pure Go;
CGO stays off).
- api/oidc.go: lazy issuer discovery (retried per-request, never crashes
a server that also serves local auth), GET /auth/oidc/login builds an
authorization-code URL with PKCE S256 + random state + nonce stashed in
a short-lived HttpOnly cookie, GET /auth/oidc/callback verifies state
(constant-time), exchanges the code with the PKCE verifier, verifies the
ID token + nonce, and starts a pansy session. Failures redirect to
/login?error=... ; success to /gardens.
- service.LoginOIDC: (issuer,subject) match -> login; else *verified*
email match -> link onto the existing account; else JIT-create (the IdP
gates access, so PANSY_REGISTRATION doesn't apply). Unverified email
colliding with an existing account is refused (takeover guard); no email
is refused (email is the account key). Reuses the atomic CreateUser.
- store: GetUserByOIDC + LinkOIDC (unique-pair backstop).
- config: OIDCReady() (needs issuer+client+BaseURL for the redirect URI);
/auth/providers now reports oidc from it and defaults the button label
to "Sign in with Authentik". OIDC routes are only registered when ready,
so an unconfigured instance 404s them.
- PANSY_LOCAL_AUTH=false rejects/hides local auth but not OIDC.
Tests: service provisioning (JIT, repeat login, link, unverified-collision
refusal, no-email, name fallback, works with local auth off); api
(routes-absent-when-unconfigured, providers reporting, login redirect with
PKCE params + tx cookie via a fake discovery server, callback state/error
paths). Smoke-tested: unreachable issuer degrades to error=oidc_unavailable
with the server still up and local auth working.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
Fixes from the PR #23 adversarial review (graded 35 real / 1 false positive):
Security / correctness
- Race-free registration: is_admin and the registration gate are now
computed atomically inside a single INSERT...SELECT, so concurrent
first registrations can't both become admin or bypass closed
registration (fixed the whole TOCTOU cluster).
- Sliding session now reaches the browser: ResolveSession returns the
current expiry and requireAuth re-sets the cookie, so active users
aren't logged out 30 days after login regardless of activity.
- Login CSRF: csrfGuard rejects state-changing requests whose Origin
doesn't match PANSY_BASE_URL (no-op when unset, so the dev proxy is
unaffected). SameSite=Lax alone didn't cover this.
- argon2id tuned to RFC 9106's second recommended profile (t=3).
- Timing equalizer can't fail open: the dummy hash is derived
deterministically (fixed salt, no RNG) so it's always present.
- Password length (<=1024) enforced in the service for both register
and login, not just HTTP binding tags; login rejects over-long input
before spending argon2 work.
Error handling / robustness
- Login logs a malformed stored hash instead of silently treating it as
a wrong password.
- Best-effort session writes (Touch/Delete during renewal, expiry, and
corrupt-expiry cleanup) now log on failure.
- index sessions.expires_at via new migration 0002 (0001 is immutable).
Maintainability
- Extract startSessionAndRespond and abortUnauthenticated; make
writeServiceError a free function; consistent error handling in
decodeHash; doc/comment fixes.
Tests: over-long password, CSRF guard (cross-origin/same-origin/dev
no-op), and cookie refresh on authenticated requests; migration-version
assertions bumped to 2.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
Implements pansy's local (email + password) authentication and the
session layer that OIDC (#5) will also reuse.
- store: users.go (create/get-by-id/get-by-email/count) and sessions.go
(create/get/touch/delete/delete-expired), scanning the existing 0001
schema.
- service: the business-logic seam. auth.go (Register/Login/session
lifecycle/Providers) + password.go (argon2id, 64 MiB/1/4, PHC-encoded,
constant-time verify) + service.go (Service, clock injection, token
hashing). First user is admin; closed registration still allows the
bootstrap user; unknown-email and wrong-password are indistinguishable
(same error, same argon2 work via a dummy hash).
- api: POST /auth/register|login|logout, GET /auth/me|providers, plus a
requireAuth middleware that resolves the HttpOnly session cookie
(SameSite=Lax, Secure under https) to the actor. Handlers stay thin.
- main: wires the service and a periodic expired-session sweep; sessions
are also dropped lazily on access. Sliding 30-day expiry.
- tests: service (register/login/expiry/renewal/cleanup, password) and
api (cookie flow, middleware, validation, providers).
Verified end-to-end via curl: register -> me -> restart -> session
persists -> logout -> 401.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
Applied the fixes warranted by the adversarial review of PR #21 (all
findings graded in the gadfly store):
Store (highest impact):
- buildDSN always merges busy_timeout/journal_mode/foreign_keys pragmas
into the DSN, even for file: URIs or paths with a query string — the
prior passthrough silently disabled FK enforcement for those PANSY_DB
values. Also escape '#' in the path and tighten in-memory detection to
an exact ':memory:' token or mode=memory param (no substring misfire).
- migrate.go: detect duplicate migration versions with a clear error;
wrap appliedVersions' rows.Err() with the store: prefix.
API:
- SetTrustedProxies failure now falls back to trust-none instead of
leaving gin's trust-everyone default (X-Forwarded-For spoofing).
- SPA: 404 embedded directories (was a directory listing), 404 bare /api,
add X-Content-Type-Options: nosniff, cache index.html bytes once at
startup, single fs.Stat existence check, shared writeAPIError helper.
- Move gin.SetMode out of package init() into New().
Config: validate PANSY_PORT range (fall back to 8080 with a warning).
Web:
- api.ts: serialize the request body before the fetch try so a
JSON.stringify failure isn't misreported as a network error.
- AppShell: move state-specific/conflicting utilities into
active/inactiveProps so concatenated Tailwind classes don't collide.
Tests: added store DSN-pragma/memory-detection cases and SPA
directory-404 case. go build/vet/test clean (CGO off); web tsc + build
clean; re-verified in a browser (SPA, deep links, /assets 404, nav).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
Implements the Pansy v1 scaffold (epic #20, phase 0).
#1 Backend: Go module (pure-Go, builds with CGO_ENABLED=0), env config,
gin server with slog + recovery + /api/v1/healthz, modernc SQLite store
with WAL/busy_timeout/foreign_keys pragmas, embedded numbered-migration
runner, full initial schema (users, sessions, gardens, garden_shares,
garden_objects, plants, plantings), domain structs + sentinel errors,
graceful shutdown.
#2 Frontend: Vite + React 19 + TS (strict) + Tailwind 4 + TanStack
Router/Query, typed /api/v1 fetch wrapper (ApiError carries status +
body so later issues can read 409 conflict rows), dev proxy /api -> :8080,
responsive app shell with stub pages for all five routes.
#3 Single binary: //go:embed of the web build with a committed placeholder,
SPA fallback (deep links, immutable asset caching, JSON 404 for unmatched
/api and missing assets), Makefile (web/build/dev/test), README quickstart
+ env var table.
Verified: go build/vet/test clean (CGO off); binary migrates idempotently and
serves healthz; web tsc + build clean; integrated binary serves the SPA, deep
links, and correct cache headers; app mounts and navigates all five routes at
desktop and 375px widths.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi