mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
test(server): an archived item's context follows the single-item GET (PLAN-3598 slice (b))
Codex round 1 at d10663cf3 read an archived item served to a reader as a
P2, on the premise that ordinary reads exclude deleted items. They do not.
handleGetItem resolves with ResolveItemIncludeDeleted and serves an archived
item read-only to any caller who passes checkItemVisible; mutation handlers
then answer 409 archived. The context endpoint applies the same rule, so it
exposes nothing GET does not. This pins that: the owner gets the brief of an
archived item, and an ungranted caller gets the same 404 as for a missing
item. docs/api.md says so.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
This commit is contained in:
+1
-1
@@ -70,7 +70,7 @@ REST API at `/api/v1/`. Key endpoints:
|
||||
- `GET /workspaces/{ws}/items/{slug}/children` — child items linked to a parent
|
||||
- `GET /workspaces/{ws}/items/{slug}/progress` — child item completion progress
|
||||
- `GET/POST /workspaces/{ws}/items/{slug}/links` — item relationships (blocks/blocked-by, parent/child)
|
||||
- `GET /workspaces/{ws}/items/{slug}/context?intent=&role=&budget=` (PLAN-3598) — one deterministic brief for working an item: `item` (fields, body, seq), `parents` (the visible ancestor chain, nearest first, each with `acceptance_criteria` parsed by `internal/acceptance` and the nearest one's body), `conventions` (active ones whose trigger is `always` or the intent's, with bodies, sorted must → should → nice-to-have then ref), `playbooks` (metadata only), `deps` (`blocks` / `blocked_by` with statuses), `decisions` (each with `current`), `links` (other link types, ref/title/status, no bodies), `omitted`, and `conflicts`. Every part carries its ref and seq. **Intent** resolves against the conventions collection's declared trigger options: `on-<intent>` if declared, else `<intent>`; neither is 400 `unknown_intent` with `details.valid_intents`. No intent means `always` only. **Role** must be a role slug in the workspace (400 `unknown_role`); with one, a convention scoped to another role (by its agent-role column, the one bootstrap labels with) is left out, and without one every role's are included, as bootstrap does. **Budget** (characters of serialized parts, default 40000, capped at 400000) admits whole parts in a fixed order: parents, conventions, playbooks, deps, decisions, links. The first part that does not fit and every part after it are listed in `omitted` (`part`, `ref`, `seq`, `size`, `reason: "budget"`), so a later small part never slips in after a refused one. The item always ships, even past the budget. **Visibility:** the item uses the single-item read check (an unreadable item is the same 404 as a missing one); every ancestor, dependency and linked item uses `itemVisibleFor` plus the app token's collection ceiling and live rows only; conventions and playbooks use bootstrap's visibility sets. A part the caller cannot see is absent and NOT counted in `omitted`. **`conflicts`** is structural only (no semantic comparison of rules): `open_blocker`, `open_under_closed_parent`, `stale_decision`, and `role_and_global_convention` (a role-scoped and a global convention matching the same trigger under the same title)
|
||||
- `GET /workspaces/{ws}/items/{slug}/context?intent=&role=&budget=` (PLAN-3598) — one deterministic brief for working an item: `item` (fields, body, seq), `parents` (the visible ancestor chain, nearest first, each with `acceptance_criteria` parsed by `internal/acceptance` and the nearest one's body), `conventions` (active ones whose trigger is `always` or the intent's, with bodies, sorted must → should → nice-to-have then ref), `playbooks` (metadata only), `deps` (`blocks` / `blocked_by` with statuses), `decisions` (each with `current`), `links` (other link types, ref/title/status, no bodies), `omitted`, and `conflicts`. Every part carries its ref and seq. **Intent** resolves against the conventions collection's declared trigger options: `on-<intent>` if declared, else `<intent>`; neither is 400 `unknown_intent` with `details.valid_intents`. No intent means `always` only. **Role** must be a role slug in the workspace (400 `unknown_role`); with one, a convention scoped to another role (by its agent-role column, the one bootstrap labels with) is left out, and without one every role's are included, as bootstrap does. **Budget** (characters of serialized parts, default 40000, capped at 400000) admits whole parts in a fixed order: parents, conventions, playbooks, deps, decisions, links. The first part that does not fit and every part after it are listed in `omitted` (`part`, `ref`, `seq`, `size`, `reason: "budget"`), so a later small part never slips in after a refused one. The item always ships, even past the budget. **Visibility:** the item uses the single-item read check, so an archived item is served read-only to a caller who can read it, exactly as the single-item GET serves it, and an unreadable item is the same 404 as a missing one; every ancestor, dependency and linked item uses `itemVisibleFor` plus the app token's collection ceiling and live rows only; conventions and playbooks use bootstrap's visibility sets. A part the caller cannot see is absent and NOT counted in `omitted`. **`conflicts`** is structural only (no semantic comparison of rules): `open_blocker`, `open_under_closed_parent`, `stale_decision`, and `role_and_global_convention` (a role-scoped and a global convention matching the same trigger under the same title)
|
||||
- `GET /search?q=query&workspace=slug` — full-text search. A `collection` filter resolves like a collection in the item routes (BUG-2659): exact slug first, then the singular/alias fallback, and an archived collection still claims its name. It resolves once per workspace in scope, so `collection=task` means `task` where one exists and `tasks` where it does not. `GET /server/capabilities` advertises it as `search_collection_resolution`; the CLI sends the name as typed only to a server that does, and expands the shorthand itself otherwise. `include_archived=true` (TASK-2864) adds soft-deleted items on every path (full text, ref and number lookups, total, facets), each carrying `deleted_at`. The permission filter is unchanged, and an individual item grant names live items only, so a guest sees an archived item only through a collection grant. The web search box's `is:archived` token sends it; not on the CLI or MCP
|
||||
- `GET /api/v1/events?workspace=slug` — SSE real-time events (workspace-scoped)
|
||||
- `GET /api/v1/events/stream` — SSE watch/push notifications (USER-scoped, spans every workspace the caller belongs to; backs `pad watch --stream`). With `?access=true` (TASK-3272) the stream also carries `workspace_access_changed` addressed to the caller — `{workspace_id, change}` with change `gained` / `lost` / `deleted` / `restored` / `purged` — published after every door that changes whether an EXISTING account reaches a workspace (signup-time doors publish nothing: the new account holds no stream yet). Opt-in because an installed `pad watch` prints any kind it does not know as an item line into an agent session. It is a refetch hint: correctness lives on the read-filtered `GET /me/workspace-tabs`, and a duplicate or missed event under concurrent grant writes is benign. Such a stream counts against `PAD_SSE_MAX_PER_USER` like any other. A push event carries `push_id` (TASK-3585, omitted on every other kind): the same id on every publish of one keyed push, so a consumer drops one it has seen (`pad watch --stream --for-session` keeps ~256 ids for 10 minutes); a monitor that predates it is at-least-once
|
||||
|
||||
@@ -276,3 +276,28 @@ func TestPLAN3598_ItemContextGuestSeesOnlyGrantedItems(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// An archived item is served read-only, exactly as the single-item GET serves
|
||||
// it: to a caller who can read it, and as the same 404 as a missing item to
|
||||
// one who cannot.
|
||||
func TestPLAN3598_ItemContextArchivedFollowsGet(t *testing.T) {
|
||||
forEachDialect(t, func(t *testing.T, d store.DriverType) {
|
||||
f := newCtxFixture(t, d)
|
||||
if err := f.srv.store.DeleteItem(f.task.ID); err != nil {
|
||||
t.Fatalf("archive: %v", err)
|
||||
}
|
||||
get := f.do("GET", "/api/v1/workspaces/"+f.wsSlug+"/items/"+f.task.Ref, f.ownerTok, nil)
|
||||
f.must(get, http.StatusOK, "precondition: GET serves the archived item to its owner")
|
||||
if resp, code, body := f.context(f.ownerTok, "?intent=implement"); resp == nil || resp.Item.Ref != f.task.Ref {
|
||||
t.Errorf("owner, archived item: %d %s", code, body)
|
||||
}
|
||||
|
||||
stranger := mkUser(t, f.srv, "ctx-archived-stranger@example.com")
|
||||
tok := f.token(stranger)
|
||||
hidden := f.do("GET", "/api/v1/workspaces/"+f.wsSlug+"/items/"+f.task.Ref+"/context", tok, nil)
|
||||
missing := f.do("GET", "/api/v1/workspaces/"+f.wsSlug+"/items/TASK-999999/context", tok, nil)
|
||||
if hidden.Code != missing.Code || hidden.Body.String() != missing.Body.String() {
|
||||
t.Errorf("archived and hidden: %d %s vs missing %d %s", hidden.Code, hidden.Body.String(), missing.Code, missing.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user