Files
pad/internal/server/collection_resolve.go
xarmian bc68b84848 fix(cli,mcp): send raw collection slug so an alias can't shadow a real collection (BUG-2630) (#1162)
* 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
2026-08-19 08:13:29 -04:00

134 lines
6.4 KiB
Go

package server
import (
"slices"
"strings"
"github.com/PerpetualSoftware/pad/internal/collections"
"github.com/PerpetualSoftware/pad/internal/models"
)
// resolveItemCollectionSlug resolves a user-supplied collection slug against
// the workspace's ACTUAL collections, so a regular singular/plural pair works
// for any collection rather than only the seven the client-side alias map
// happens to know (BUG-2578).
//
// "Regular" is the honest scope: only a trailing ASCII `s` is added or
// removed, so `spec`/`specs` resolves and `category`/`categories` does not.
// See collectionSlugCandidates for why that is a deliberate stopping point
// rather than a gap to close with an inflector.
//
// Why this lives on the server. The alias map (collections.NormalizeSlug) is a
// hardcoded switch over the DEFAULT templates' collection names, called from
// the CLI and from the MCP dispatcher — both CLIENT side. It has no view of
// the workspace, so a template-defined or user-created collection like `specs`
// gets no singular form, and `pad item create spec` fails with "Collection not
// found" for a collection that plainly exists in the caller's own bootstrap
// payload. The workspace's collection list only exists here, so resolving here
// fixes the CLI, the remote MCP transport, the web UI and any direct API
// consumer at once, instead of teaching each client the same trick.
//
// EXACT MATCH ALWAYS WINS. The fallbacks run only when the input names no
// collection at all, so this can never redirect a request that would otherwise
// have succeeded — which is the property that makes it safe to add underneath
// existing callers. (The client-side map does NOT have this property: it
// rewrites before the server sees the input, so it can shadow a real
// collection. That is BUG-2630, a separate defect this does not fix.)
//
// Scope is deliberate: item-facing entry points only — create, list, move and
// bulk move, the surfaces the bug is about. In particular this is NOT wired
// into store.GetCollectionBySlug, which has 23 call sites including
// authorization paths (authz_cross_workspace, handlers_grants,
// handlers_share_links). Fuzzy resolution inside a function used for
// permission checks is how a check and the action it guards come to disagree
// about which collection they mean.
//
// Returns (nil, nil) when nothing matches, exactly as GetCollectionBySlug
// does, so callers keep their existing not-found handling.
func (s *Server) resolveItemCollectionSlug(workspaceID, input string) (*models.Collection, error) {
// The raw input first, then each fallback, and for EVERY name the same two
// questions in the same order: is there a live collection with it, and
// does an archived one claim it?
//
// Per-name rather than only for the raw input, because the fallbacks are
// names too. With an archived `spec` and a live `specs`, `Spec` misses on
// the raw form, and its case-folded candidate `spec` then finds no LIVE
// row (GetCollectionBySlug skips soft-deleted) and walks on to `specs` —
// so guarding only the input lets the exact archived name be stepped over
// by a spelling of itself (codex round 8).
for _, name := range append([]string{input}, collectionSlugCandidates(input)...) {
coll, err := s.store.GetCollectionBySlug(workspaceID, name)
if err != nil {
return nil, err
}
if coll != nil {
return coll, nil
}
// An ARCHIVED collection still CLAIMS its name. Falling through to a
// different collection would quietly redirect writes the caller aimed
// at a name that does exist, just not in a writable state — the same
// trade this branch refuses on the client side in BUG-2630, and one
// that survives a later restore by stranding items where they were
// rerouted.
archived, err := s.store.ArchivedCollectionClaimsSlug(workspaceID, name)
if err != nil {
return nil, err
}
if archived {
return nil, nil
}
}
return nil, nil
}
// collectionSlugCandidates returns the alternative slugs to try when `input`
// matched nothing, in priority order and never including `input` itself.
//
// ASCII-`s` pluralization is tried first, then the fixed legacy alias map
// (t/i/p/d, phase/phases). Neither reaches for an inflector: collection slugs
// are generated by slugify and the plural convention is the house style, so
// `s` covers the structural cases, while irregular-plural guessing
// ("person" -> "people") would start inventing mappings a user never wrote.
// Anything an inflector would catch, an exact slug still resolves. The alias
// map is a CLOSED set carried over from the retired client-side normalizer, not
// an open-ended guesser.
func collectionSlugCandidates(input string) []string {
trimmed := strings.ToLower(strings.TrimSpace(input))
if trimmed == "" {
return nil
}
var out []string
// A case-only difference comes FIRST. `Spec` names the collection `spec`
// more closely than it names `specs`, so trying pluralization ahead of the
// folded form would resolve `Spec` to `specs` in a workspace holding both
// — the same misfiling the exact-match rule exists to prevent, reached by
// a different route (codex round 3 P1).
if trimmed != input {
out = append(out, trimmed)
}
// Singular -> plural: the reported case (`spec` -> `specs`).
out = append(out, trimmed+"s")
// Plural -> singular, for a collection whose slug is genuinely singular
// (`pad item create tasks` in a workspace whose collection is `task`).
if s := strings.TrimSuffix(trimmed, "s"); s != trimmed && s != "" {
out = append(out, s)
}
// The legacy SEMANTIC aliases the client-side map used to apply before
// resolution moved to the server (BUG-2630): t/i/p/d -> tasks/ideas/plans/
// docs and phase/phases -> plans. Folded in LAST so the server owns the full
// alias vocabulary and every client can send the raw slug — including the
// remote MCP transport, which no longer normalizes (BUG-2630 #2). It is only
// ever a last resort: the exact-match and the archived-claims refusal in
// resolveItemCollectionSlug run for `input` and every candidate BEFORE this
// one is reached, so a real (or archived-claimed) collection of the input
// name still wins, and this never shadows or redirects around it. Aliases
// that duplicate a structural candidate above (e.g. plan -> plans) are
// dropped so the resolve loop does not query the same slug twice.
if alias := collections.NormalizeSlug(trimmed); alias != trimmed && !slices.Contains(out, alias) {
out = append(out, alias)
}
return out
}