feat(mt#4766): Route a memory curation write path through the shared command registry - #3501
Conversation
…-page curation UI Adds `src/cockpit/routes/memories.ts` mounting PATCH/POST/DELETE routes for memory curation (update, supersede, delete, bulk retag/delete), routed through the shared command registry per ADR-004 and the planning audit's verdict rather than calling MemoryService directly — this is what makes AT2 (an out-of-vocabulary association key gets rejected) discriminate the two architectures. Actor attribution (sourceAgentId/sourceSessionId) on a supersede's replacement record is server-ascribed (COCKPIT_OPERATOR_SOURCE_AGENT_ID), never read from the request body, per mt#2898's finding applied to a second entity. Mounts the routes in server.ts alongside the other write-route mounts. Adds `src/cockpit/web/lib/memory-mutations.ts` with fetch wrappers + TanStack Query hooks that invalidate the memories-list/-search/-stats/ -detail widget queries on success. Wires curation actions (edit tags, edit name/description, supersede, delete) into MemoryDetail.tsx's action bar, with a confirm dialog stating memory's hard-delete + no-tombstone semantics. Remaining: bulk selection UI in MemoriesList.tsx, tests for the new routes and the actor-attribution guarantee.
Adds a per-row checkbox, a select-all-on-page header checkbox, and a bulk actions bar (retag / delete) that opens a dry-run-first dialog: preview exactly which records change, then confirm to execute — mirrors the operational-safety-dry-run-first discipline the backend bulk routes enforce (BULK_RECORD_CAP=10). Selection is local component state, not folded into URL state (a working set for an in-progress action, not a bookmarkable view). Typecheck and lint both clean across all workspaces including src/cockpit/web. Remaining: tests for the new routes (AT2 association-vocabulary rejection, AT5 auth-gating, attribution non-override) and PR creation.
…tribution
20 tests against the new /api/memories/* routes, injecting a fresh
registryOverride per test so every call runs through the REAL
command-layer execute() handlers (registerMemoryCommands against a fake
MemoryServiceSurface) rather than a bare service double.
Execution evidence:
bun test --preload ./tests/setup.ts --timeout=15000 src/cockpit/routes/memories.test.ts
20 pass, 0 fail, 51 expect() calls
Covers: AT2 (an association key outside ADR-012's vocabulary is rejected
with 400, citing ADR-012 in the message — the test that discriminates
routing through the command layer from calling MemoryService directly),
AT5 (isPublicPath returns false for every write route, proving the
global session gate covers them with no per-route auth code), AT6
(bulk-retag preview lists exactly the selected records; execute changes
exactly those records; a selection over BULK_RECORD_CAP is refused), AT7
(a wrong-typed or unknown body field returns 400, not 500), plus two
tests proving a client-supplied sourceAgentId on supersede cannot
override the server-ascribed COCKPIT_OPERATOR_SOURCE_AGENT_ID (mt#2898's
finding applied to memory).
The public Railway deployment gates on path (requirePasskeySession + isPublicPath); a local daemon gates on method instead (mutationAuthMiddleware requires a bearer token on every non-GET, which covers all four new routes by construction since none is a GET). Documents both in the AT5 test rather than only the path-based one.
Minsky Reviewer StatusVerdict: APPROVED — no blocking findings Commands
|
Cockpit PreviewPreview URL: https://cockpit-preview-production.up.railway.app Deployed from commit Updated by Cockpit Preview. |
There was a problem hiding this comment.
Independent adversarial review (Chinese-wall)
Reviewer: minsky-reviewer[bot] via openai:gpt-5
Tier: 2
High-quality, well-structured addition: new memory curation routes are correctly routed through the shared command registry, body-field whitelisting is enforced, bulk operations implement dry-run-first with a record cap, and the UI wires row-level and bulk actions with proper cache invalidation. Tests exercise command-layer guardrails (ADR-012 vocabulary) and bulk preview/execute paths. Two issues surfaced: (1) BLOCKING — documentation gap: operator docs do not cover the new memory curation features, confirmation semantics (hard delete, no short-id tombstones), or the new routes; please add/update docs (e.g., cockpit-ui.md) before merge. (2) NON-BLOCKING — AT5 coverage only asserts non-public paths via isPublicPath rather than asserting actual 401 responses; consider adding integration tests against createCockpitServer to pin 401 unauthenticated behavior. Otherwise, the spec criteria are met. If docs are updated, this looks ready to ship.
Findings
-
[BLOCKING] docs/cockpit-ui.md:1 — Documentation not updated for new memory curation features and routes
This PR introduces user-facing memory curation capabilities (row-level edit tags/name/description, supersede, delete) and bulk actions with new HTTP routes (PATCH/POST/DELETEunder/api/memories/*) plus visible UI controls inMemoriesList.tsxandMemoryDetail.tsx. I could not find any corresponding updates in the operator docs.docs/cockpit-ui.mddoes not mention memory curation actions or the new flows, and no new doc files were added. Per the review policy, adding/altering user-facing behavior without updating docs is blocking. Please add documentation covering: -
The curation action bar on the memory detail page (what each action does and constraints: hard delete semantics and no short-id tombstone table)
-
Bulk selection flow on the memories list (dry-run preview, record cap, and error behavior)
-
The new mutation routes’ operator-facing behavior at least at a high level (consistent with other documented operator endpoints)
Alternatively, point to updated docs if they exist elsewhere in the repo.
- [NON-BLOCKING] src/cockpit/routes/memories.test.ts:416 — Auth acceptance test weakened to path-allowlist assertion; no 401 unauthenticated test
Acceptance Test 5 in the spec requires: “Unauthenticated POST to each route returns 401.” The new tests only assert thatisPublicPath(path) === falsefor the four paths (lines 416-449), which checks the allowlist but does not prove a 401 response is actually produced in either deployment mode. Consider adding integration tests that spin upcreateCockpitServerwith and without auth (e.g., omit the bearer/cookie and assert 401; include it and assert 200) for each of the four routes. This would harden the contract and catch regressions inmutationAuthMiddlewareor passkey gating rather than inferring it indirectly. Marking as non-blocking because the global auth middleware likely covers these routes by construction; this is a coverage gap rather than a functional bug.
Spec verification
| Criterion | Status | Evidence |
|---|---|---|
| src/cockpit/routes/memories.ts mounting write routes on the asks.ts structural pattern (own file, mountMemoryRoutes(app, options), explicit status-code contract — not the widget dispatcher’s uniform 200 + {state:"degraded"}). | Met | Implemented in src/cockpit/routes/memories.ts (new). Mounted from src/cockpit/server.ts:633 via import { mountMemoryRoutes } from "./routes/memories". Route handlers return explicit 200/400/404/503 JSON per handler and classify errors in classifyMemoryCommandError(). |
| Routes go through the shared command registry so the four guardrails above apply. If any route cannot, name it and say why. | Met | src/cockpit/routes/memories.ts:142-168 builds a per-request SharedCommandRegistry and calls command.execute for memory.update/supersede/delete. Tests assert ADR-012 vocabulary rejection (AT2) — src/cockpit/routes/memories.test.ts:84-118 returns 400 with ADR-012 message, proving command-layer path. |
| Trust-boundary discipline per asks.ts: whitelist the body fields read; never accept server-computed values from the client. | Met | parseUpdateBody() enforces UPDATE_ALLOWED_FIELDS (name, description, tags, associations) and types; parseSupersedeBody() enforces SUPERSEDE_ALLOWED/REQUIRED without sourceAgentId/sessionId. Unknown fields 400 via rejectUnknownFields(). See src/cockpit/routes/memories.ts:187-288 and 301-391. |
| Row-level actions on /memories and on /memory/:id: edit tags, edit name/description, supersede, delete. | Met | UI controls implemented: MemoryDetail.tsx adds MemoryCurationBar with EditTagsDialog, EditMetaDialog, SupersedeDialog, DeleteDialog (src/cockpit/web/widgets/MemoryDetail.tsx:829-1216). List page adds bulk selection but row-level selection/controls exist via detail page. |
| Delete is confirmed, and the dialog states that deletion is hard and that memory has no short-id tombstone table — so a deleted record’s mem#N can be reissued. | Met | DeleteDialog explanatory text at src/cockpit/web/widgets/MemoryDetail.tsx:1128-1150 explicitly mentions hard delete and no short-id tombstone; button is destructive and requires confirm dialog. |
| Supersede opens the create-replacement form the domain requires (supersede takes a full MemoryCreateInput plus a reason), pre-filled from the current record. | Met | SupersedeDialog in MemoryDetail.tsx pre-fills type/name/description/content/scope/tags; includes reason; on save calls useSupersedeMemory (src/cockpit/web/widgets/MemoryDetail.tsx:1006-1116). |
| Bulk selection on the list with bulk retag and bulk delete. Bulk delete follows operational-safety-dry-run-first: a preview of exactly which records change before any write, and the >10-record threshold requires a task wrapper. | Met | MemoriesList.tsx adds per-row and header checkboxes, BulkActionsBar with BulkRetagDialog and BulkDeleteDialog rendering preview then execute (src/cockpit/web/widgets/MemoriesList.tsx:524-763, 820-1024, 1168-1200). Server enforces BULK_RECORD_CAP=10 and preview first behavior (src/cockpit/routes/memories.ts:412-489, 491-553). Tests cover preview/execute and over-cap for retag (src/cockpit/routes/memories.test.ts:285-357). |
| Every mutation invalidates the relevant TanStack Query keys so the list reflects the change without a manual reload. | Met | src/cockpit/web/lib/memory-mutations.ts defines invalidateMemoryQueries() to invalidate ["widget","memories-list"], ["widget","memories-search"], ["widget","memories-stats"], and ["widget","memories-detail", id] and wires it in onSuccess of all mutations (lines ~120-197). |
| Auth: writes sit behind the same session gate as the other cockpit write routes (/api/shares returns 401 unauthenticated — match it). | Met | Routes are mounted into the central server which applies mutationAuthMiddleware for all non-GET requests on local daemon and passkey gate on public deploy (src/cockpit/server.ts:190-238, 244-291). Tests assert paths are not public via isPublicPath() (src/cockpit/routes/memories.test.ts:416-449). |
| Actor attribution is server-ascribed, never caller-supplied. A test must prove a client-supplied actor field cannot override the ascribed one. | Met | Server-side parseSupersedeBody injects COCKPIT_OPERATOR_SOURCE_AGENT_ID and rejects unknown fields (no sourceAgentId/sessionId allowed). Tests: normal supersede returns ascribed constant (src/cockpit/routes/memories.test.ts:168-186); attempting to supply sourceAgentId yields 400 and no supersession (lines 188-208). |
Adoption sweep
| Symbol | Kind | Consumers found | Classification | Notes |
|---|---|---|---|---|
| COCKPIT_OPERATOR_SOURCE_AGENT_ID | type | src/cockpit/routes/memories.test.ts:28 — imported for assertions | Adopted | Constant exported from src/cockpit/routes/memories.ts for server-ascribed actor id. Used in tests; no other public consumers required. |
Documentation impact
- blocking-needs-update — This PR adds operator-visible memory curation actions and new API routes (
PATCH /api/memories/:id,POST /api/memories/:id/supersede,DELETE /api/memories/:id, and bulk routes) and changes cockpit behavior (detail page action bar, bulk selection, dry-run previews, record cap). I searched the docs tree and found no updates;docs/cockpit-ui.mddoes not mention memory curation or these flows. The absence is an omission of new behavior and needs documentation before merge.
Affected: docs/cockpit-ui.md
R1 findings from review 5062160331:
- The AT5 auth test asserted isPublicPath() returns false for each route
path — a check against a config table, not a behavior. It would pass
unchanged if the gate were removed, misconfigured, or mounted in the
wrong order. Replaced with real unauthenticated HTTP requests against
the actual createCockpitServer app for BOTH deployment-mode gates:
requirePasskeySession (public deployment) and mutationAuthMiddleware
(local daemon, method-based). Each of the five routes now has at least
one real 401 assertion against the unmocked auth middleware chain.
Re-read the bulk-preview and attribution tests per the class-not-instance
check: both assert on the real HTTP response body produced by the actual
route handler + real command.execute() call, not on any config/state
table — no other instance of the proxy-assertion class found.
- docs/cockpit-ui.md: added a "Memory curation (mt#4766)" section
documenting all five routes (methods, request bodies, status codes),
the server-ascribed actor-attribution rule (sourceAgentId/
sourceSessionId never read from the request body), and the hard-delete
+ no-short-id-tombstone-table consequence the confirm dialog states.
Cross-referenced from the file's own Cross-references list.
Execution evidence:
bun test --preload ./tests/setup.ts --timeout=15000 src/cockpit/routes/memories.test.ts
24 pass, 0 fail, 55 expect() calls
Typecheck and lint both clean across every workspace including
src/cockpit/web.
There was a problem hiding this comment.
Independent adversarial review (Chinese-wall)
Reviewer: minsky-reviewer[bot] via openai:gpt-5
Tier: 2
R2 verification: The prior BLOCKING docs gap is addressed — docs/cockpit-ui.md now documents the memory curation routes and their semantics. The auth tests were strengthened to exercise real 401 behavior against createCockpitServer for both deployment modes. Routes are correctly wired through the shared command registry, body fields are whitelisted, actor attribution is server-ascribed, bulk operations implement dry-run-first and the 10-record cap, and error mapping is explicit. I found no critical new defects. One minor nit: the memories.ts header still mentions isPublicPath-based tests; the new behavior-based tests supersede that — consider updating the comment. Otherwise, LGTM to merge.
Findings
- [NON-BLOCKING] src/cockpit/routes/memories.ts:55 — Docblock references outdated test shape for auth gating
The module header states auth coverage is "pinned bymemories.test.ts'sisPublicPathassertions." The current tests exercise real unauthenticated HTTP requests againstcreateCockpitServerand assert 401s for both deployment modes (passkey gate and mutationAuthMiddleware). Consider updating the comment to reflect the behavior-based tests now in place to avoid sending readers to a stale mechanism.
Spec verification
| Criterion | Status | Evidence |
|---|---|---|
| src/cockpit/routes/memories.ts mounting write routes on the asks.ts structural pattern (own file, mountMemoryRoutes(app, options), explicit status-code contract — not the widget dispatcher's uniform 200 + {state:"degraded"}). | Met | Implemented in src/cockpit/routes/memories.ts: defines and exports mountMemoryRoutes(app, options) with explicit handlers for PATCH/POST/DELETE and 400/404/500 mapping via classifyMemoryCommandError/handleCommandError. Mounted in src/cockpit/server.ts:615 (mountMemoryRoutes(app);). |
| Routes go through the shared command registry so the four guardrails above apply. If any route cannot, name it and say why. | Met | src/cockpit/routes/memories.ts:150-174 builds per-request registry via createSharedCommandRegistry + registerMemoryCommands and executes memory.update/supersede/delete. Tests prove ADR-012 vocabulary enforcement (400 with ADR-012) — src/cockpit/routes/memories.test.ts:119-142. |
| Trust-boundary discipline per asks.ts: whitelist the body fields read; never accept server-computed values from the client. | Met | Unknown fields rejected: rejectUnknownFields used in parseUpdateBody (src/cockpit/routes/memories.ts:201-243) and parseSupersedeBody (lines 271-332). Actor fields not allowed and server-ascribed instead (lines 320-332) with constant COCKPIT_OPERATOR_SOURCE_AGENT_ID. Tests assert override attempts are 400 (memories.test.ts:209-233). |
| Row-level actions on /memories and on /memory/:id: edit tags, edit name/description, supersede, delete. | Met | All four routes exist and are exercised: PATCH /api/memories/:id for tags/name/description/associations; POST /api/memories/:id/supersede; DELETE /api/memories/:id. Tests cover each at src/cockpit/routes/memories.test.ts (multiple describe blocks). UI wiring is out-of-diff for this round, but server routes + tests are present. |
| Delete is confirmed, and the dialog states that deletion is hard (MemoryService.delete is a row delete plus a best-effort embedding delete) and that memory has no short-id tombstone table — unlike tasks' deleted_task_ids — so a deleted record's mem#N can be reissued. That is a real consequence and the operator should see it before confirming. | Met | User-facing behavior documented in docs/cockpit-ui.md:613-636 — hard delete semantics + no tombstone table called out explicitly to be shown in the confirmation dialog. Server route DELETE /api/memories/:id implemented in src/cockpit/routes/memories.ts:374-401. |
| Supersede opens the create-replacement form the domain requires (supersede takes a full MemoryCreateInput plus a reason), pre-filled from the current record. | Met | Route contract reflects required fields: src/cockpit/routes/memories.ts SUPERSEDE_REQUIRED_FIELDS at 246-250 and parseSupersedeBody enforcing presence and types. Docs section describes form requirements: docs/cockpit-ui.md:636-661. |
| Bulk selection on the list with bulk retag and bulk delete. Bulk delete follows operational-safety-dry-run-first: a preview of exactly which records change before any write, and the >10-record threshold requires a task wrapper. | Met | POST /api/memories/bulk/retag and /bulk/delete implemented (src/cockpit/routes/memories.ts:403-487 and 489-531). parseBulkIdsBody enforces execute default=false and BULK_RECORD_CAP=10 with 400 message naming the discipline (lines 358-378). Tests for preview/execute and over-cap 400 at memories.test.ts:270-353. |
| Every mutation invalidates the relevant TanStack Query keys so the list reflects the change without a manual reload. | Met | Out-of-server scope here, but PR description claims web hooks added; current diff does not re-touch web hooks in this iteration. Acceptance is tracked elsewhere; no contradiction found. Server-side does not prevent it. |
| Auth: writes sit behind the same session gate as the other cockpit write routes (/api/shares returns 401 unauthenticated — match it). | Met | Auth enforced centrally in src/cockpit/server.ts via mutationAuthMiddleware (local) and requirePasskeySession (public). New tests issue real unauthenticated HTTP requests against createCockpitServer and assert 401 for each route in both modes: src/cockpit/routes/memories.test.ts:393-491. |
| Actor attribution is server-ascribed, never caller-supplied. A memory carries source_agent_id / source_session_id, and a cockpit-originated edit must record the operator identity the server authenticated — not a value read from the request body. Do not add a second caller-supplied-attribution surface. A test must prove a client-supplied actor field cannot override the ascribed one. | Met | parseSupersedeBody sets sourceAgentId=COCKPIT_OPERATOR_SOURCE_AGENT_ID and never accepts those fields from client (src/cockpit/routes/memories.ts:320-332). Tests: normal request records the constant (memories.test.ts:186-207) and an attempt to supply sourceAgentId is rejected 400 with no supersede (lines 209-233). |
Documentation impact
- updated-in-pr — This PR adds new cockpit mutation routes and updates the user-facing docs accordingly in docs/cockpit-ui.md (new 'Memory curation (mt#4766)' section detailing PATCH/POST/DELETE endpoints, auth posture, bulk-dry-run semantics, and actor attribution). No other docs appear to conflict with the new behavior.
Affected: docs/cockpit-ui.md
…ope census CI was red on one test out of 16,605: "cockpit scope census (mt#4730) > every route module is scope-consuming or on the allowlist". Adding src/cockpit/routes/memories.ts created a registry obligation the diff did not discharge — a new route module must either consume ?project= or carry an allowlist entry with a written reason. Allowlist is the correct side, not the convenient one. Every route here mutates a memory addressed by an already-identified id, and the two bulk routes take an explicit operator-selected id list; there is no list-shaped read to scope. The scoped reads live in the memories-* widgets (mt#4727). Scoping the mutations would be actively wrong: a delete targeting a record the operator picked by id should act on that record, not silently no-op because it belongs to a project other than the one currently selected. Reason recorded inline. Negative control: the full suite on cc6700a failed this test (1 fail / 16594 pass / 1085 files); with the entry added, scope-census.test.ts is 9 pass / 0 fail. The failure was invisible to the local gated run because it selects --changed (19 of 1085 files) and this test is not reachable from the diff's import graph.
There was a problem hiding this comment.
Independent adversarial review (Chinese-wall)
Reviewer: minsky-reviewer[bot] via openai:gpt-5
Tier: 2
Verified the fix commit adds the required scope-census allowlist entry for the new memories route. The entry id matches the route module name and the rationale aligns with the mutation-by-id semantics; this resolves the prior CI failure about the scope census. I also re-checked the mounting and route/file coherence — mountMemoryRoutes is already wired in server.ts. I find no new defects introduced by this change, and the broader PR’s success criteria and documentation updates remain satisfied. Approving.
Spec verification
| Criterion | Status | Evidence |
|---|---|---|
| src/cockpit/routes/memories.ts mounting write routes on the asks.ts structural pattern (own file, mountMemoryRoutes(app, options), explicit status-code contract — not the widget dispatcher’s uniform 200 + {state:"degraded"}). | Met | src/cockpit/routes/memories.ts:33-49 module header; src/cockpit/routes/memories.ts:303 defines mountMemoryRoutes(app,…); mounted in src/cockpit/server.ts:336. Routes return structured JSON with explicit status handling via classifyMemoryCommandError/handleCommandError (memories.ts:153-190). |
| Routes go through the shared command registry so the four guardrails above apply. If any route cannot, name it and say why. | Met | src/cockpit/routes/memories.ts:115-139 constructs a fresh SharedCommandRegistry per request and calls registry.getCommand("memory.update"|"memory.supersede"|"memory.delete"). Comments explain ADR-004 rationale. Tests prove ADR-012 vocabulary enforcement: src/cockpit/routes/memories.test.ts:78-105 asserts 400 with ADR-012 in message. |
| Trust-boundary discipline per asks.ts: whitelist the body fields read; never accept server-computed values from the client. | Met | src/cockpit/routes/memories.ts:200-258 (parseUpdateBody) and :271-349 (parseSupersedeBody) enforce allowed/required fields; rejectUnknownFields at :175-188; actor fields not allowed and set server-side (COCKPIT_OPERATOR_SOURCE_AGENT_ID at :160). Tests at src/cockpit/routes/memories.test.ts:182-209 verify client-supplied sourceAgentId rejected 400. |
| Row-level actions on /memories and on /memory/:id: edit tags, edit name/description, supersede, delete. | Met | src/cockpit/web/widgets/MemoryDetail.tsx:377-620 implements EditTagsDialog, EditMetaDialog, SupersedeDialog, DeleteDialog; the action bar at :662-714 wires all four dialogs. Bulk actions on list are separate (see below). |
| Delete is confirmed, and the dialog states that deletion is hard (MemoryService.delete is a row delete plus a best-effort embedding delete) and that memory has no short-id tombstone table — unlike tasks’ deleted_task_ids — so a deleted record’s mem#N can be reissued. That is a real consequence and the operator should see it before confirming. | Met | src/cockpit/web/widgets/MemoryDetail.tsx:558-572 — dialog copy explicitly states hard delete and no short-id tombstone; same note repeated in BulkDeleteDialog at src/cockpit/web/widgets/MemoriesList.tsx:570-578. Docs also record this: docs/cockpit-ui.md “Memory curation” section. |
| Supersede opens the create-replacement form the domain requires (supersede takes a full MemoryCreateInput plus a reason), pre-filled from the current record. | Met | src/cockpit/web/widgets/MemoryDetail.tsx:442-467 initializes SupersedeDialog form from current record (type,name,description,content,scope,tags) and includes optional reason; POST wired via useSupersedeMemory at :500-508. |
| Bulk selection on the list with bulk retag and bulk delete. Bulk delete follows operational-safety-dry-run-first: a preview of exactly which records change before any write, and the >10-record threshold requires a task wrapper. | Met | src/cockpit/web/widgets/MemoriesList.tsx:543-661 (BulkRetagDialog) and :663-733 (BulkDeleteDialog) implement preview-first and execute. Selection UI: :735-789 BulkActionsBar, row checkboxes at :874-908, select-all at :819-838. Server cap enforced in src/cockpit/routes/memories.ts:413-430 (BULK_RECORD_CAP=10; 400 with message) and tested at src/cockpit/routes/memories.test.ts:292-305. |
| Every mutation invalidates the relevant TanStack Query keys so the list reflects the change without a manual reload. | Met | src/cockpit/web/lib/memory-mutations.ts:104-130 define invalidateMemoryQueries for memories-list/search/stats and per-id detail; used in useUpdateMemory/useSupersedeMemory/useDeleteMemory/onSuccess and in bulk hooks (only on execute=true). |
| Auth: writes sit behind the same session gate as the other cockpit write routes (/api/shares returns 401 unauthenticated — match it). | Met | Routes themselves have no auth, consistent with cockpit convention. Gating is mounted in src/cockpit/server.ts:201-252 (mutationAuthMiddleware) and :260-314 (requirePasskeySession for public deployments). Tests exercise unauthenticated requests: src/cockpit/routes/memories.test.ts:343-403 (public) and :405-434 (local) expect 401 on all routes. |
| Actor attribution is server-ascribed, never caller-supplied. A test must prove a client-supplied actor field cannot override the ascribed one. | Met | Server constant COCKPIT_OPERATOR_SOURCE_AGENT_ID at src/cockpit/routes/memories.ts:160 and whitelist in parseSupersedeBody excludes actor fields (comments at :287-295). Tests: src/cockpit/routes/memories.test.ts:168-209 — normal request yields constant; client-supplied sourceAgentId rejected 400; no record created with attacker value. |
Documentation impact
- no-update-needed — This fix commit only adds an allowlist entry to src/cockpit/scope-census.ts, registering the new memories route as deliberately non-project-scoped. The broader PR already updated docs/cockpit-ui.md with a “Memory curation” section describing routes, auth, and hard-delete consequences (verified present). No additional documentation changes are required for this specific commit.
There was a problem hiding this comment.
Independent adversarial review (Chinese-wall)
Reviewer: minsky-reviewer[bot] via openai:gpt-5
Tier: 2
Re-verified the prior blocking concern: the scope-census allowlist entry for the new memories routes is present and coherent, and mountMemoryRoutes is mounted in server.ts. All success criteria in the current spec are met: routes use the shared command registry, body fields are whitelisted, actor attribution is server-ascribed, bulk operations are dry-run-first with a 10-record cap, auth gating is enforced (tests simulate both deployment modes), and the UI provides row-level and bulk curation with correct query invalidation. Docs were updated with a comprehensive “Memory curation” section. I found one minor nit: a header comment in memories.ts still references isPublicPath assertions; tests now exercise real unauthenticated requests — suggest updating the comment (non-blocking). No new defects introduced by the fix commit; approving.
Findings
- [NON-BLOCKING] src/cockpit/routes/memories.ts:39 — Header comment references an
isPublicPath-based assertion that tests no longer use
The module header states auth coverage is “pinned bymemories.test.ts'sisPublicPathassertions,” but the current tests validate auth by issuing real unauthenticated HTTP requests againstcreateCockpitServerin both deployment modes (public passkey and local daemon). Consider updating this comment to reflect the test shape to avoid future confusion — the implementation is correct; this is a documentation nit only.
Spec verification
| Criterion | Status | Evidence |
|---|---|---|
| src/cockpit/routes/memories.ts mounting write routes on the asks.ts structural pattern (own file, mountMemoryRoutes(app, options), explicit status-code contract — not the widget dispatcher’s uniform 200 + {state:"degraded"}). | Met | Implemented in new file src/cockpit/routes/memories.ts exporting mountMemoryRoutes with explicit handlers for PATCH/POST/DELETE and JSON status codes (e.g., 400/404/503). Mounted in src/cockpit/server.ts:633 via mountMemoryRoutes(app);. |
| Routes go through the shared command registry so the four guardrails above apply. If any route cannot, name it and say why. | Met | Each handler resolves a per-request registry via createSharedCommandRegistry + registerMemoryCommands and calls getCommand(...).execute(...) (src/cockpit/routes/memories.ts:214-228, 236-255, 263-281, 307-337, 347-378). Tests assert ADR-012 vocabulary enforcement (src/cockpit/routes/memories.test.ts:109-137). |
| Trust-boundary discipline per asks.ts: whitelist the body fields read; never accept server-computed values from the client. | Met | rejectUnknownFields + allowed field sets for update and supersede (src/cockpit/routes/memories.ts:121-150, 178-210). Supersede explicitly excludes sourceAgentId/sourceSessionId from allowed fields, causing 400 on presence (parseSupersedeBody doc comment and usage at 212-256). |
| Row-level actions on /memories and on /memory/:id: edit tags, edit name/description, supersede, delete. | Met | UI action bar in src/cockpit/web/widgets/MemoryDetail.tsx: MemoryCurationBar with dialogs for Edit tags, Edit name/description, Supersede, Delete (lines ~595-828+). Uses hooks in src/cockpit/web/lib/memory-mutations.ts. |
| Delete is confirmed, and the dialog states that deletion is hard (MemoryService.delete is a row delete plus a best-effort embedding delete) and that memory has no short-id tombstone table — unlike tasks' deleted_task_ids — so a deleted record's mem#N can be reissued. That is a real consequence and the operator should see it before confirming. | Met | DeleteDialog in src/cockpit/web/widgets/MemoryDetail.tsx:690-747 renders explicit warning: “This permanently deletes the row and its embedding… memory has no short-id tombstone table … can be reissued…” before confirm. |
| Supersede opens the create-replacement form the domain requires (supersede takes a full MemoryCreateInput plus a reason), pre-filled from the current record. | Met | SupersedeDialog in src/cockpit/web/widgets/MemoryDetail.tsx builds a form with type, scope, name, description, content, tags, reason; pre-filled from current record (lines ~496-644) and invokes useSupersedeMemory. |
| Bulk selection on the list with bulk retag and bulk delete. Bulk delete follows operational-safety-dry-run-first: a preview of exactly which records change before any write, and the >10-record threshold requires a task wrapper. | Met | Bulk selection UI in src/cockpit/web/widgets/MemoriesList.tsx: added checkboxes per row and header, BulkActionsBar with BulkRetagDialog and BulkDeleteDialog (lines ~524-763 and ~820-1184). Routes enforce dry-run via execute flag and cap via BULK_RECORD_CAP=10 returning 400 (src/cockpit/routes/memories.ts:160, 292-337, 345-378). Tests cover previews and cap (src/cockpit/routes/memories.test.ts:220-305, 327-358). |
| Every mutation invalidates the relevant TanStack Query keys so the list reflects the change without a manual reload. | Met | src/cockpit/web/lib/memory-mutations.ts:88-121 defines invalidateMemoryQueries invalidating memories-list, memories-search, memories-stats, and record detail; each use* mutation hook calls it on success. |
| Auth: writes sit behind the same session gate as the other cockpit write routes (/api/shares returns 401 unauthenticated — match it). | Met | Handlers are mounted globally; auth gates live in server middleware. Tests issue real unauthenticated requests against createCockpitServer and assert 401 under both gating modes (src/cockpit/routes/memories.test.ts:382-483). |
| Actor attribution is server-ascribed, never caller-supplied. A test must prove a client-supplied actor field cannot override the ascribed one. | Met | Server sets sourceAgentId to COCKPIT_OPERATOR_SOURCE_AGENT_ID and rejects unknown fields like sourceAgentId (src/cockpit/routes/memories.ts:157-164, 212-256). Tests: normal supersede records ascribed constant (lines 170-190) and client-supplied sourceAgentId is rejected 400 with no supersession (lines 192-210). |
Adoption sweep
| Symbol | Kind | Consumers found | Classification | Notes |
|---|---|---|---|---|
| mountMemoryRoutes | function | src/cockpit/server.ts:633 — mounted alongside other route modules | Adopted | New route-mount function for memory curation write paths; wired into the central server factory. |
Documentation impact
- updated-in-pr — This PR adds new write routes and UI behaviors, and updates docs/cockpit-ui.md with a detailed “Memory curation (mt#4766)” section documenting methods, bodies, status codes, auth posture, actor attribution, and bulk-operation semantics. No other docs appear out-of-date based on a spot check of the cockpit architecture doc referenced in this section.
Affected: docs/cockpit-ui.md
Summary
The cockpit could not change a memory in any way — the widget transport was
app.getonly, and no memory widget called a mutating method. This PR addsPATCH/POST/DELETEroutes for memory curation (retag, edit name/description, supersede, hard delete, bulk retag, bulk delete) plus the row-level and bulk-selection UI to drive them.The architectural decision (mt#4766 Planning Audit, ADR-004)
The routes call the shared command registry (
memory.update/memory.supersede/memory.delete), neverMemoryServicedirectly. Two reasons, both verified rather than assumed:grep -noversrc/adapters/shared/commands/memory/index.tsfindscheckDerivation(:982),validateAssociationson both create (:997) and update (:1100 — retag is an update), andextractTrackingTaskRefs(:1018). The same grep overmemory-service.tsreturns zero hits. A route calling the service directly would silently skip all four.asks.ts's domain-function-direct pattern is not a counter-example:respondAndCloseAskIS the domain function carrying the ask's preconditions; memory has no equivalent, so its preconditions only run when the command layer'sexecuteis what gets invoked.src/cockpit/routes/memories.ts's docblock explains why commands are constructed per-request (a fresh throwawaySharedCommandRegistryinjected with the cockpit's cachedMemoryServiceSurface) rather than registered once at module load: registering once with a pre-bound service would pin it past a persistence-pool recycle (mt#3638), and registering once without one would require threading a DI container the cockpit doesn't otherwise have.AT2 — the discriminating test
This is the test that tells the two architectures apart — a route calling
MemoryService.updatedirectly has no vocabulary check to fail against. Result: PASS, both in the unit suite and live (see Live verification below).And the write never happens — the persisted record's
associationsis confirmed unchanged.Actor attribution (mt#2898 applied to memory)
mt#2898 documents that
POST /api/asks/:id/resolvereadsresponderfrom the request body, makingresponder: "operator"forgeable by any caller, and that this became load-bearing once a permission bridge started trusting it. These routes never readsourceAgentId/sourceSessionIdfrom the client at all — they aren't in thesupersederoute's field whitelist, so a request that includes either is rejected outright (400) rather than silently accepted-and-ignored. A supersede's replacement record is always ascribedCOCKPIT_OPERATOR_SOURCE_AGENT_IDserver-side. Verified both in the unit suite and live: a normal request gets the ascribed constant ("cockpit-operator"), and a request carryingsourceAgentId: "attacker-controlled"is rejected 400 with no record ever created.Key changes
src/cockpit/routes/memories.ts(new) —mountMemoryRoutes(app, options), followingasks.ts's structural pattern (own file, explicit status-code contract, whitelisted body fields — not the widget dispatcher's uniform200 + {state:"degraded"}).src/cockpit/server.ts— mounts the new routes alongside the other write-route mounts.src/cockpit/web/lib/memory-mutations.ts(new) — fetch wrappers + TanStack QueryuseMutationhooks that invalidate thememories-list/-search/-stats/-detailwidget queries on success.src/cockpit/web/widgets/MemoryDetail.tsx— a curation action bar (Edit tags / Edit name-description / Supersede / Delete), each a controlled Dialog. The delete dialog states plainly that this is a hard delete and that memory has no short-id tombstone table (unlike tasks'deleted_task_ids), so a deletedmem#Ncan be reissued — the operator sees this before confirming.src/cockpit/web/widgets/MemoriesList.tsx— per-row + select-all-on-page checkboxes and a bulk actions bar (retag / delete), each opening a dry-run-first dialog: preview exactly which records change, then confirm to execute.src/cockpit/routes/memories.test.ts(new) — 24 tests.docs/cockpit-ui.md— a "Memory curation" section documenting all five routes (methods, request bodies, status codes), the actor-attribution rule, and the hard-delete/no-tombstone consequence.Bulk operations — dry-run-first + a record cap
Per
operational-safety-dry-run-first, both bulk routes preview the exact record set before any write (execute: falseby default) and require an explicitexecute: trueto act. Judgment call, stated here: rather than requiring a separate task wrapper for >10-record bulk mutations (the discipline's normal trigger), the routes simply refuse a selection overBULK_RECORD_CAP(10) with a 400 naming that discipline — a UI-driven, per-click bulk action doesn't have a natural place to attach a task reference, so capping the direct-execution size was the simpler and equally-safe choice for this surface.Testing
Execution evidence:
Covers AT2 (association-vocabulary rejection), AT5 (auth-gating — see below), AT6 (bulk-retag preview matches selection exactly; execute changes exactly that selection; over-cap selection refused), AT7 (malformed/wrong-typed/unknown body fields → 400, never 500), and the two actor-attribution tests above. Also: 404 on a missing record, 200 + persisted-state assertions on update/supersede/delete, invalid type/scope rejection on supersede.
validate_typecheckandvalidate_lintboth pass clean across every workspace, includingsrc/cockpit/web.AT5 — real unauthenticated requests against both deployment-mode gates
R1 finding: the original version of this test asserted
isPublicPath()returnsfalsefor each route path — a check against a config table, not a behavior, that would keep passing if the gate were removed, misconfigured, or mounted in the wrong order. Replaced with genuine unauthenticated HTTP requests against the realcreateCockpitServerapp (the same factoryserver.tsuses in production) for both deployment-mode gates:requirePasskeySession, path-based, the same gate/api/sharesrelies on) — no session cookie →401on all five routes.mutationAuthMiddleware, method-based — every non-GET needs a bearer token or the loopback cookie) — no token →401on all five routes.Also verified LIVE against a real running local daemon (see below): an unauthenticated
DELETEreturns401with{"error":"Missing or invalid cockpit auth token"}; the identical request with a valid bearer token returns200.Live verification
Live-exercised every route against a real, DB-connected cockpit daemon, plus screenshots of the resulting UI at 1440×1000. This required correcting course from an earlier attempt (see "How this got unblocked" below).
Setup:
pgvector/pgvector:pg16in a disposable Docker container, migrated viabun src/cli.ts persistence migrate --executewithMINSKY_POSTGRES_URLpointed at it (confirmed by read-back BEFORE writing anything — see below), thenbun src/cli.ts cockpit start --port 18080 --devagainst that same database.AT2, live (association vocabulary):
Retag (valid), live:
PATCH .../:id {"tags":["seed-mt4766","retagged-live"]}→200, responserecord.tagsupdated, confirmed in Postgres.Actor attribution, live:
Delete, live: authenticated
DELETE .../:id→200 {"deleted":true,"id":"..."}; row count for that id in Postgres afterward:0.Bulk retag/delete, live (AT6): preview (
execute:false) over 3 seeded records returned exactly those 3 withcurrentTags/newTags, no write (confirmed via a follow-upSELECT— tags unchanged);execute:truethen changed exactly those 3 rows (confirmed viaSELECT— all 3 updated, an untouched 4th record unaffected); bulk delete likewise removed exactly the 3 targeted rows.Screenshots (1440×1000, headless Chrome via chrome-devtools-mcp against the live daemon above):
/memories— list with the new select-all/per-row checkboxes, one seeded record visible, stats/facets rendering normally./memory/:id— the curation action bar (Edit tags / Edit name / description / Supersede / Delete) rendered above the metadata panel, no layout defects./memorieswith one row selected — the bulk actions bar ("1 selected", Retag, Delete, "Clear selection") rendering correctly above the table.No defects found in any of the four renders. Cancelled the delete dialog rather than confirming it (no need to destroy the demo record to prove the dialog renders correctly). All scratch infrastructure (container, cockpit process, headless Chrome, temp files) torn down after verification — nothing was left running, and none of this touched the project's real database at any point.
How this got unblocked (for the record)
My first attempt set
DATABASE_URL, which this system's config resolution does not read at all — the CLI silently fell through to the project's real Supabase-hosted database, and I stopped before running anything mutating (documented in this PR's prior revision). The coordinator pointed me atMINSKY_VERIFY_DATABASE_URL, registered inpackages/domain/src/configuration/sources/environment.tsas atest-fixture. Checking that registration directly: it is a narrowly-scoped fixture read only byscripts/verify-driven-session-conversations.ts(mt#4323) — confirmed empirically too (persistence migrate --dry-runwith only that variable set still reported the Supabase pooler host). The variable actually wired into general persistence-config resolution (persistence.postgres.connectionString, the pathpersistence migrateand the cockpit daemon both read) isMINSKY_POSTGRES_URL(orMINSKY_PERSISTENCE_POSTGRES_URL), documented inpersistence-config.tsas "the canonical escape hatch." Using that, the dry-run's read-back showedpostgres://***:***@127.0.0.1:<port>/minsky_scratchwithschema=missing(a fresh, empty database) — confirmed safe before running--execute.Out of scope (per spec)
memory_patchsection editing (mt#4612's openreplace-mode defect).Duplicate check
No other open PR touches
src/cockpit/routes/memories.ts,src/cockpit/web/widgets/MemoriesList.tsx, orsrc/cockpit/web/widgets/MemoryDetail.tsx(per the mt#4766 Planning Audit's parallel-work sweep, PR #3412 touches unrelated files via a mechanical catch-block rewrite).Deploy verification
Seven deploy-surface files: the new
routes/memories.tsand its test,server.ts(routemounting),
scope-census.ts(the R2 allowlist entry), the mutation hooks, and the two widgets.No migration and no domain change — the risk shape is a boot failure from the new route mounting,
not a schema change.
After merge I will run
mcp__minsky__deployment_wait-for-latest --service cockpitwithnotBeforeset to the merge timestamp and
expectCommitShaset to the merge SHA, and require SUCCESS. Thecockpit deploys from a pushed image, so
buildIdentitywill readindeterminate— expected forthis service and not treated as a pass on its own.
Because an indeterminate build identity says nothing about WHICH build is serving, the
change-produced assertion is the route itself:
OPTIONS/unauthenticatedPATCHagainst/api/memories/:idon the deployed cockpit must return a real status from the new handler(401 under the auth gate) rather than the SPA fallback a pre-merge bundle would serve. That is
immune to which deployment record the wait returns, and it exercises the mounting — the specific
thing that could break on boot.
A tool or auth flake on the deployment probe is a blocker to reconnect and retry, not a licence to
downgrade the completion claim.