mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
bc68b84848
* fix(cli,mcp): send raw collection slug so an alias can't shadow a real collection (BUG-2630)
The client-side alias map (collections.NormalizeSlug) rewrote seven hardcoded
singulars ("task", "plan", …) to their plurals BEFORE the request. In a
workspace whose collection slug IS one of those singulars, the user's exact
name was rewritten away and their create/list/move landed in a DIFFERENT
collection — silently, with a success message naming the wrong one.
Fix, per lead ruling on the BUG-2630 trail, split by transport:
CLI (real HTTP, may hit a pre-resolver server) — Option 2, one shared helper
cli.WithCollectionAliasFallback: send the RAW slug first (the server's
exact-match-first resolver from BUG-2578 wins, so an exact name is never
shadowed), and retry with the alias ONLY on a collection-not-found error, only
when the alias differs. Keying on collection-not-found is load-bearing: a
request to a collection that exists but fails for another reason is never
retried into the alias (that would recreate the bug). Both the schema fetch and
the create funnel through the helper so typed --field values parse against — and
the item lands in — one collection. On a genuine double-miss the error names the
RAW slug the user typed (collection "widget" not found), not the alias.
MCP remote transport (in-process ServeHTTP against the SAME binary, which always
carries the resolver — no version skew) — drop client-side normalization
entirely and send raw. Also removed the dormant expandPath collection
normalization: no routeSpec uses a {collection}/{target_collection} path
placeholder, so the branch was dead code in the area this fixes.
Search is deliberately out of scope (filed BUG-2659): its collection is a global
c.slug=? FILTER, not a path — a miss returns 200 + zero results, not
collection-not-found, so the retry can't key on it; and handleSearch is
cross-workspace, so the per-workspace resolver has no single workspace to run
against. Cross-workspace copy is excluded too (DR-13 forbids auto-retrying the
copy mutation).
Verified live against a real server: create/list/move into a singular collection
that collides with its plural now land in the named singular; shorthand still
resolves; genuine misses error naming the raw slug. New MCP integration test
reproduces the original shadow (item → PLANS-1) when normalization is restored.
Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
* fix(server,cli): own collection resolution server-side + capability-gate the CLI retry (BUG-2630 Codex r1)
Addresses all three Codex round-1 findings, via the lead's ruling that
dissolves the earlier "retry vs archived-protection" tension by making the
server the sole owner of resolution semantics.
Finding #2 (MCP lost the legacy abbreviations t/i/p/d and phase/phases -> plans,
which the server's ±s resolver did not cover): fold the legacy alias map into
collectionSlugCandidates as a LAST-resort candidate. Exact-match-first and the
archived-claims refusal run for the input and every structural candidate before
the alias is reached, so it never shadows or redirects around a real/archived
collection. Now every client can send the raw slug — including the MCP transport
that can't retry — and lose nothing.
Finding #1 (the client retry re-opened the archived/hidden redirect the server
deliberately refused, because not_found can't be told from absent): add a
collection_resolution capability flag to GET /server/capabilities and gate the
CLI retry on it. Happy path unchanged (raw slug, one request). On
collection-not-found ONLY, the client probes capabilities once (cached): if the
server advertises resolution, its not-found is authoritative — the slug is
absent, archived, or hidden — so the client does NOT retry. Only an older server
that lacks the flag (or 404s the endpoint) triggers the legacy alias retry,
which is non-regressive there since old servers never had the protection. The
probe fails safe toward retry. This makes the follow-up distinct-error-code bug
unnecessary.
Finding #3 (double-fail masked a substantive alias error as "collection not
found"): the helper now surfaces a substantive alias-attempt error verbatim, and
only collapses to the raw-named not-found when the alias ALSO 404s.
Verified live against a resolving server: create/list/move into a singular that
collides with its plural land in the named singular; the abbreviation `i`
resolves to `ideas`; and after archiving `plan`, `create plan` honestly fails
("collection \"plan\" not found") instead of being retried into a live `plans`.
Gates: make lint 0 issues; go test ./... green; make test-pg green.
Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
* fix(cli): fail-closed capability probe + always-retry the schema lookup (BUG-2630 Codex r2)
P1: the capability probe cached ANY failure as "no resolver", so a single
transient blip (timeout/5xx) permanently re-enabled the alias retry and could
bypass the archived/hidden protection on a resolving server. Now the probe
distinguishes a DEFINITIVE verdict (HTTP 200 with the flag, or a clean 404 =
legacy build) from an INDETERMINATE one (transport error / 5xx): only definitive
verdicts are cached, and an indeterminate probe fails CLOSED (trusts the
not-found, no retry) without caching, so the next call re-probes. A genuine old
server still returns a clean 404, so its retry is unaffected. Renamed the
predicate to CollectionNotFoundIsAuthoritative to name what it actually decides.
P2: the create schema lookup hits exact-match-only GetCollection, which does NOT
resolve slugs server-side, so capability-gating it made `create task
--field amount=3` 404 the schema fetch, skip the retry, and send amount as the
string "3". The schema lookup now always retries the alias (nil gate),
restoring typed-field parsing against an aliased collection's schema. Best-effort
as before: a genuine miss still degrades to string fields.
New client test covers the probe: definitive verdicts cache (one probe), and a
transient failure fails closed AND re-probes on the next call (mutation-verified).
Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
* docs(cli): note fail-closed-on-indeterminate as a deliberate safety asymmetry (BUG-2630)
Per lead review: make explicit in CollectionNotFoundIsAuthoritative's doc that
failing closed on an indeterminate capability probe is deliberate — a recoverable
alias-shorthand miss is the safer side of the trade vs a retry doing an
un-undoable wrong-write. Comment-only.
Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
153 lines
7.2 KiB
Go
153 lines
7.2 KiB
Go
package cli
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/collections"
|
|
)
|
|
|
|
// withCollectionAliasFallback runs op against the RAW collection slug the user
|
|
// typed, and — only if that fails specifically because the collection was not
|
|
// found — retries once with the legacy singular→plural alias
|
|
// (collections.NormalizeSlug). The result of whichever attempt succeeds is
|
|
// returned; if both fail, the RAW attempt's error is returned.
|
|
//
|
|
// Why send raw first (BUG-2630). The old client behaviour normalized the slug
|
|
// BEFORE the request, so a workspace whose collection slug IS one of the seven
|
|
// hardcoded singulars ("plan", "task", …) had the user's exact name rewritten
|
|
// away and their write landed in a DIFFERENT collection, silently, with a
|
|
// success message naming the wrong one. Sending raw lets the server's
|
|
// exact-match-first resolution (BUG-2578) win, so an exact collection name can
|
|
// never be shadowed by its own alias.
|
|
//
|
|
// Why keep the alias at all. A server that predates the server-side resolver
|
|
// (BUG-2578) matches collections by exact slug only, so a raw "task" would 404
|
|
// there for a workspace whose collection is "tasks". The retry preserves
|
|
// today's shorthand UX against those older servers, at the cost of one extra
|
|
// round trip on a path that was already going to fail.
|
|
//
|
|
// Why the retry is keyed on collection-not-found specifically (see
|
|
// isCollectionNotFound). If it fired on ANY failure, a request aimed at a
|
|
// collection that really exists ("plan") but failed for another reason (a
|
|
// validation error, an auth hiccup) would be retried against the alias
|
|
// ("plans") and could silently succeed there — recreating BUG-2630 in a new
|
|
// costume. Keying on the not-found error means a collection that exists is
|
|
// never retried away from.
|
|
//
|
|
// REMOVAL CONDITION: once the minimum supported server is guaranteed to carry
|
|
// the resolver (BUG-2578), the raw slug always resolves on the first attempt
|
|
// and this fallback is dead weight — drop it and call op(rawSlug) directly.
|
|
// That is the Option 1 cleanup tracked on BUG-2630's trail, gated on a
|
|
// min-server-version floor that includes the resolver.
|
|
//
|
|
// notFoundIsAuthoritative reports whether a collection-not-found should be
|
|
// TRUSTED — when it returns true the alias retry is skipped, because the server
|
|
// resolves slugs itself (exact-match-first + alias fallback + archived/hidden
|
|
// refusal) and has therefore already ruled the slug absent. It returns false
|
|
// only for a server definitively without the resolver, where the legacy retry
|
|
// is the right non-regressive fallback. Pass nil to FORCE the always-retry
|
|
// behaviour — the right choice for an endpoint that does NOT resolve slugs
|
|
// server-side (the CLI schema lookup hits exact-match-only GetCollection, so its
|
|
// not-found is never authoritative about an alias). See
|
|
// Client.CollectionNotFoundIsAuthoritative.
|
|
//
|
|
// Exported so the cmd/pad item commands can funnel their create / list / move
|
|
// calls through this ONE implementation instead of each re-deriving the
|
|
// send-raw-then-retry dance (BUG-2630 lead ruling: one shared helper, not
|
|
// copied at the call sites).
|
|
func WithCollectionAliasFallback[T any](rawSlug string, notFoundIsAuthoritative func() bool, op func(slug string) (T, error)) (T, error) {
|
|
result, err := op(rawSlug)
|
|
if err == nil {
|
|
return result, nil
|
|
}
|
|
// The failure was not a missing collection (auth, validation, a 5xx): return
|
|
// it untouched. Retrying such an error against the alias could silently
|
|
// succeed on a DIFFERENT collection that does exist — BUG-2630 in a new
|
|
// costume — so a collection that exists is never retried away from.
|
|
if !isCollectionNotFound(err) {
|
|
return result, err
|
|
}
|
|
normalized := collections.NormalizeSlug(rawSlug)
|
|
if normalized == rawSlug {
|
|
// No alias to try (the input is not one of the aliased singulars): the
|
|
// slug genuinely names no collection. Name it in the error the user
|
|
// sees, since it is their own word.
|
|
return result, wrapCollectionNotFound(err, rawSlug)
|
|
}
|
|
// When the server's not-found is authoritative — it resolves collections
|
|
// itself (BUG-2578/2630), so it already tried the alias and enforced
|
|
// exact-match-first + the archived/hidden refusal — retrying the alias would
|
|
// only defeat that protection (BUG-2630 #1). Only fall back to the
|
|
// client-side retry for an OLDER server that never had the protection.
|
|
if notFoundIsAuthoritative != nil && notFoundIsAuthoritative() {
|
|
return result, wrapCollectionNotFound(err, rawSlug)
|
|
}
|
|
result2, err2 := op(normalized)
|
|
if err2 != nil {
|
|
if isCollectionNotFound(err2) {
|
|
// Neither the raw slug nor its alias names a collection. Surface an
|
|
// error naming the RAW slug the user typed, not the alias the
|
|
// client tried on their behalf — their own words are the ones they
|
|
// can act on.
|
|
return result, wrapCollectionNotFound(err, rawSlug)
|
|
}
|
|
// The alias DOES name a real collection, but the operation failed there
|
|
// for a substantive reason (plan limit, open children, validation,
|
|
// forbidden). That error is about a real collection and is far more
|
|
// useful than a misleading "collection not found" — surface it verbatim.
|
|
return result2, err2
|
|
}
|
|
return result2, nil
|
|
}
|
|
|
|
// wrapCollectionNotFound rewrites the server's context-free "Collection not
|
|
// found" into one that names the slug the user actually typed, so a failed
|
|
// `pad item create widget …` reads `collection "widget" not found` instead of a
|
|
// bare "Collection not found". It returns a fresh *APIError with the same Code
|
|
// (so downstream type/Code inspection — plan-limit, open-children — keeps
|
|
// working) and an enriched Message. Non-collection errors pass through
|
|
// untouched. Mirrors wrapItemNotFound's shape.
|
|
func wrapCollectionNotFound(err error, rawSlug string) error {
|
|
var apiErr *APIError
|
|
if errors.As(err, &apiErr) && isCollectionNotFound(err) {
|
|
return &APIError{
|
|
Code: apiErr.Code,
|
|
Message: fmt.Sprintf("collection %q not found", rawSlug),
|
|
Details: apiErr.Details,
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
// isCollectionNotFound reports whether err is the server's specific
|
|
// "collection not found" response for an item create / list / move request. It
|
|
// is deliberately narrow: any other failure (auth, validation, a 5xx, an
|
|
// item-not-found) must NOT trigger the alias retry, or a request aimed at a
|
|
// collection that really exists could be silently rerouted to a different one
|
|
// (BUG-2630).
|
|
//
|
|
// The wire contract it matches is stable across the BUG-2578 server change —
|
|
// both codes predate it, so the predicate works against old and new servers:
|
|
// - create / list: HTTP 404, code "not_found", message "Collection not found"
|
|
// - move: HTTP 400, code "invalid_collection"
|
|
//
|
|
// The message check on the "not_found" branch distinguishes a missing
|
|
// COLLECTION from a missing ITEM, which shares the "not_found" code on other
|
|
// endpoints — though the create/list endpoints this helper wraps only ever
|
|
// emit "not_found" for the collection, the message guard keeps the predicate
|
|
// honest if it is ever reused on a broader path.
|
|
func isCollectionNotFound(err error) bool {
|
|
var apiErr *APIError
|
|
if !errors.As(err, &apiErr) {
|
|
return false
|
|
}
|
|
switch apiErr.Code {
|
|
case "invalid_collection":
|
|
return true
|
|
case "not_found":
|
|
return apiErr.Message == "Collection not found"
|
|
}
|
|
return false
|
|
}
|