Gadfly caught me making the same mistake one level up. This PR claimed the
history write is "detached from cancellation, always" — and record()'s
auto-scope path still called store.WriteChangeSet directly with the caller's
context, so every plain REST mutation kept the orphan-history window the PR was
written to close. That is the path virtually every change takes; the agent is
the exception.
It goes through commitScope now, which is where the rule lives, so no caller can
be the one that forgets. Tested the same way as the agent path: cancel right
after the mutation returns, assert the change set exists and reverts.
Also: stopBeat is deferred so a panic in the run can't leak the ticker goroutine
(and is idempotent, since the handler stops it explicitly on the normal path);
the keep-alive interval is a named constant rather than a literal duplicated
between comment and call site; and commitScope's doc states the rule and why it
lives there, instead of narrating the incident that produced it — that belongs
in a commit message, which is where it now is.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
Found by using it: the agent planted 18 beans, the client's stream dropped, and
those 18 plantings ended up in the garden with no change set behind them — real
changes with no way to undo them, which is the one guarantee this whole design
rests on.
The earlier fix detached the FAILURE path and reasoned that a cancelled context
is why fn failed. That missed the commoner case. An agent turn whose client
disconnects can still COMPLETE — the model finishes, the tools have already
written — and then the success path committed with a dead context, the write
failed, WithChangeSet returned an error, and the work was orphaned.
commitScope now detaches from cancellation itself, so every caller gets it. That
is the right home for the rule: by the time a commit runs, the data it describes
has already been written, so cancelling it cannot undo anything — it can only
lose the record of what happened. There is no path on which that is the
behaviour anyone wants.
Also added an SSE keep-alive, since the dropped connection is worth not having
in the first place: a model thinking between tool calls sends nothing for a
while, and an idle proxy will cut a quiet stream. A comment frame every 20s
keeps it open, and SSE ignores comment frames so the client is unaffected.
That introduced a second goroutine writing the response, so the event stream now
serializes writes behind a mutex — step events come from the agent's run
goroutine while the ticker writes from its own, and two goroutines writing a
ResponseWriter concurrently corrupts frames long before it crashes anything.
Verified under -race.
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