fix(memory): harden project scope authorization and consistency

This commit is contained in:
Patrick Buckley
2026-08-11 20:25:35 -07:00
parent d2a6c2852e
commit cc84f9d176
37 changed files with 2919 additions and 1083 deletions
+26 -16
View File
@@ -1758,16 +1758,19 @@ Status code: `403`
### `GET /v1/api/memories` ### `GET /v1/api/memories`
List structured memories with optional filters. Requires `read` scope. List structured memories with optional filters. Requires `read` scope. Without
`scope`, returns only `global` plus the authenticated caller's `user`
namespace. The public endpoint accepts `global`, `workstream`, and `user`;
explicit workstream access is owner-bound.
**Query parameters:** **Query parameters:**
| Parameter | Type | Required | Default | Description | | Parameter | Type | Required | Default | Description |
|------------|--------|----------|---------|------------------------------| |------------|--------|----------|---------|------------------------------|
| `type` | string | no | `""` | Filter by memory type (user, project, feedback, reference) | | `type` | string | no | `""` | Filter by memory type (user, general, feedback, reference) |
| `scope` | string | no | `""` | Filter by scope (global, workstream, user) | | `scope` | string | no | `""` | Filter by scope (global, workstream, user) |
| `scope_id` | string | no | `""` | Scope qualifier. Auto-resolved for `scope=user` when auth is active. | | `scope_id` | string | no | `""` | Scope qualifier. Auto-resolved for `scope=user` when auth is active. |
| `limit` | int | no | `100` | Max results (capped at 200) | | `limit` | int | no | `100` | Max results (1-200) |
**Response:** **Response:**
@@ -1778,7 +1781,7 @@ List structured memories with optional filters. Requires `read` scope.
"memory_id": "a1b2c3d4-e5f6-...", "memory_id": "a1b2c3d4-e5f6-...",
"name": "project_architecture", "name": "project_architecture",
"description": "Core architecture patterns", "description": "Core architecture patterns",
"type": "project", "type": "general",
"scope": "global", "scope": "global",
"scope_id": "", "scope_id": "",
"content": "The project uses a hexagonal architecture...", "content": "The project uses a hexagonal architecture...",
@@ -1795,7 +1798,8 @@ List structured memories with optional filters. Requires `read` scope.
### `POST /v1/api/memories` ### `POST /v1/api/memories`
Save or upsert a structured memory. Requires `write` scope. Returns `201` on Save or upsert a structured memory. Requires `write` scope. Returns `201` on
create, `200` on update. create, `200` on update. Every write must include a non-empty, non-whitespace
`description`; content-only updates are rejected.
**Request body:** **Request body:**
@@ -1804,7 +1808,7 @@ create, `200` on update.
"name": "deployment_process", "name": "deployment_process",
"content": "Deploy via GitHub Actions. Staging auto-deploys on push to main.", "content": "Deploy via GitHub Actions. Staging auto-deploys on push to main.",
"description": "CI/CD deployment workflow", "description": "CI/CD deployment workflow",
"type": "project", "type": "general",
"scope": "global", "scope": "global",
"scope_id": "" "scope_id": ""
} }
@@ -1814,8 +1818,8 @@ create, `200` on update.
|--------------|--------|----------|-------------|--------------------------------------| |--------------|--------|----------|-------------|--------------------------------------|
| `name` | string | yes | -- | Memory name (max 256 chars) | | `name` | string | yes | -- | Memory name (max 256 chars) |
| `content` | string | yes | -- | Memory content (max 65536 chars) | | `content` | string | yes | -- | Memory content (max 65536 chars) |
| `description`| string | no | `""` | Short description for search ranking | | `description`| string | yes | -- | Non-empty relevance summary, required on create and update |
| `type` | string | no | `"project"` | One of: user, project, feedback, reference | | `type` | string | no | unset | user, general, feedback, or reference |
| `scope` | string | no | `"global"` | One of: global, workstream, user | | `scope` | string | no | `"global"` | One of: global, workstream, user |
| `scope_id` | string | no | `""` | Scope qualifier (auto-resolved for user scope) | | `scope_id` | string | no | `""` | Scope qualifier (auto-resolved for user scope) |
@@ -1826,7 +1830,7 @@ create, `200` on update.
"memory_id": "a1b2c3d4-e5f6-...", "memory_id": "a1b2c3d4-e5f6-...",
"name": "deployment_process", "name": "deployment_process",
"description": "CI/CD deployment workflow", "description": "CI/CD deployment workflow",
"type": "project", "type": "general",
"scope": "global", "scope": "global",
"scope_id": "", "scope_id": "",
"content": "Deploy via GitHub Actions...", "content": "Deploy via GitHub Actions...",
@@ -1839,21 +1843,25 @@ create, `200` on update.
| Status | Condition | | Status | Condition |
|--------|--------------------------------------------------------| |--------|--------------------------------------------------------|
| 400 | Missing name, empty content, invalid type/scope, name too long, content too long | | 400 | Invalid input, public scope, scope ID, or limit |
| 403 | Cross-user or non-owner workstream access |
| 404 | Explicit workstream does not exist |
| 500 | Storage mutation failed |
--- ---
### `POST /v1/api/memories/search` ### `POST /v1/api/memories/search`
Search memories by query. Uses POST for the request body but is non-mutating Search memories by query. Uses POST for the request body but is non-mutating
(requires only `read` scope). (requires only `read` scope). An omitted scope searches only `global` plus the
authenticated caller's `user` namespace.
**Request body:** **Request body:**
```json ```json
{ {
"query": "authentication", "query": "authentication",
"type": "project", "type": "general",
"scope": "", "scope": "",
"limit": 20 "limit": 20
} }
@@ -1865,7 +1873,7 @@ Search memories by query. Uses POST for the request body but is non-mutating
| `type` | string | no | `""` | Filter by type | | `type` | string | no | `""` | Filter by type |
| `scope` | string | no | `""` | Filter by scope | | `scope` | string | no | `""` | Filter by scope |
| `scope_id` | string | no | `""` | Filter by scope ID | | `scope_id` | string | no | `""` | Filter by scope ID |
| `limit` | int | no | `20` | Max results (capped at 50) | | `limit` | int | no | `20` | Max results (1-50) |
**Response:** **Response:**
@@ -1876,7 +1884,7 @@ Search memories by query. Uses POST for the request body but is non-mutating
"memory_id": "a1b2c3d4-e5f6-...", "memory_id": "a1b2c3d4-e5f6-...",
"name": "auth_patterns", "name": "auth_patterns",
"description": "Authentication architecture", "description": "Authentication architecture",
"type": "project", "type": "general",
"scope": "global", "scope": "global",
"scope_id": "", "scope_id": "",
"content": "JWT tokens with HS256...", "content": "JWT tokens with HS256...",
@@ -1894,7 +1902,9 @@ Search memories by query. Uses POST for the request body but is non-mutating
### `DELETE /v1/api/memories/{name}` ### `DELETE /v1/api/memories/{name}`
Delete a memory by name and scope. Requires `write` scope. Delete a memory by name and scope. Requires `write` scope. The delete returns
success only for the row atomically removed and records the authenticated
actor in the audit log.
**Path parameters:** **Path parameters:**
@@ -1978,7 +1988,7 @@ Get a single memory by ID. Requires `admin.memories` permission.
"memory_id": "a1b2c3d4-e5f6-...", "memory_id": "a1b2c3d4-e5f6-...",
"name": "project_architecture", "name": "project_architecture",
"description": "Core architecture patterns", "description": "Core architecture patterns",
"type": "project", "type": "general",
"scope": "global", "scope": "global",
"scope_id": "", "scope_id": "",
"content": "The project uses...", "content": "The project uses...",
+1 -1
View File
@@ -77,7 +77,7 @@ or MCP config can do adds to it. Current members:
| `delete_workstream` | wind-down | Hard-delete one child. Requires approval. | | `delete_workstream` | wind-down | Hard-delete one child. Requires approval. |
| `list_nodes` | discover | Enumerate live cluster nodes + capabilities. | | `list_nodes` | discover | Enumerate live cluster nodes + capabilities. |
| `skills` (action=find) | discover | Browse the skill catalog; opt-in `kind` filter narrows by audience. | | `skills` (action=find) | discover | Browse the skill catalog; opt-in `kind` filter narrows by audience. |
| `memory` | persist | Durable orchestration memory (`coordinator` scope, per-user — survives across coordinator sessions). | | `memory` | persist | Durable acting-user orchestration memory (`coordinator`), plus shared memory when attached to a project. |
| `notify` | broadcast | Post a status update to a human channel at a narrative beat. | | `notify` | broadcast | Post a status update to a human channel at a narrative beat. |
| `tasks` | plan | Orchestrator-only scratchpad. Children don't see it. | | `tasks` | plan | Orchestrator-only scratchpad. Children don't see it. |
+44 -38
View File
@@ -20,58 +20,61 @@ participant "SDK Client\n(sdk/)" as SDK <<sdk>>
== Phase 1: Tool Path (session.send) == == Phase 1: Tool Path (session.send) ==
Session -> Session : _prepare_tool_calls()\nparse memory(action=...) Session -> Session : pin acting principal\nparse memory(action=...)
note right note right
Tool schema: 4 actions Tool schema: 5 actions
save, search, delete, list save, get, search, delete, list
Auto-approved (no approval needed) Auto-approved (no approval needed)
end note end note
Session -> Session : resolve live project access\nselect exact/inherited scope
Session -> Session : _exec_memory(item) Session -> Session : _exec_memory(item)
alt action = save alt action = save
Session -> Facade : save_structured_memory(\nname, content, description,\nmem_type, scope, scope_id) Session -> Session : require non-empty description
Session -> Facade : save_structured_memory_strict(\n..., require_active_project)
Facade -> Facade : normalize_key(name) Facade -> Facade : normalize_key(name)
Facade -> Storage : create_structured_memory() Facade -> Storage : guarded atomic upsert\nON CONFLICT ... RETURNING
alt unique constraint violation Storage --> Facade : (saved row, was_update)
Storage --> Facade : IntegrityError Facade --> Session : saved row
Facade -> Storage : get_structured_memory_by_name() Session -> Session : invalidate prefix/cache\naudit acting principal
Storage --> Facade : existing row end
Facade -> Storage : update_structured_memory()
end alt action = get
Storage --> Facade : memory_id Session -> Facade : get_structured_memory_by_name_strict()
Facade --> Session : (memory_id, old_content) Facade -> Storage : exact scoped-name lookup
Session -> Session : _init_system_messages()\nrefresh BM25 context Storage --> Session : full row / not found
end end
alt action = search alt action = search
Session -> Facade : search_structured_memories(\nquery, mem_type, scope,\nscope_id, limit) Session -> Storage : search exact scope or\nactor-visible scope union
Facade -> Storage : search_structured_memories()
Storage --> Session : matched rows Storage --> Session : matched rows
end end
alt action = delete alt action = delete
Session -> Facade : delete_structured_memory(\nname, scope, scope_id) Session -> Facade : delete_structured_memory_returning_strict()
Facade -> Storage : delete_structured_memory() Facade -> Storage : DELETE ... RETURNING
Storage --> Session : bool (existed) Storage --> Session : deleted row / not found
Session -> Session : _init_system_messages()\nrefresh BM25 context Session -> Session : invalidate + audit\nmark prefix dirty
end end
== Phase 2: BM25 Relevance Injection == == Phase 2: BM25 Relevance Injection ==
Session -> Session : _init_system_messages()\nevery conversation turn Session -> Session : _init_system_messages()\nevery conversation turn
Session -> Session : resolve acting principal\nand live project ACL
Session -> Session : _list_visible_memories(\nlimit=fetch_limit) Session -> Session : _list_visible_memories(\nlimit=fetch_limit)
note right note right
**Scope resolution:** **Scope resolution:**
1. global scope (always) Interactive: global + workstream
2. workstream scope (ws_id) + acting user + readable project
3. user scope (user_id, if auth) Coordinator: acting user's coordinator
Combined and deduplicated. + readable project
end note end note
Session -> Facade : list_structured_memories()\nper scope Session -> Facade : list_visible_structured_memories()
Facade -> Storage : list_structured_memories() Facade -> Storage : one visibility-union query
Storage --> Session : up to fetch_limit rows Storage --> Session : up to fetch_limit rows
Session -> Relevance : extract_recent_context(\nmessages, max_messages=3) Session -> Relevance : extract_recent_context(\nmessages, max_messages=3)
@@ -103,28 +106,31 @@ Session -> Session : inject into\nsystem message
== Phase 3: Server API Path == == Phase 3: Server API Path ==
SDK -> API : GET /v1/api/memories\n?type=project&limit=20 SDK -> API : GET /v1/api/memories\n?type=general&limit=20
API -> Facade : list_structured_memories() API -> API : bind scope to caller\ndefault global + caller user
Facade -> Storage : list_structured_memories() API -> Storage : list visible rows
Storage --> API : rows Storage --> API : rows
API --> SDK : {"memories": [...], "total": N} API --> SDK : {"memories": [...], "total": N}
SDK -> API : POST /v1/api/memories\n{name, content, ...} SDK -> API : POST /v1/api/memories\n{name, content, description, ...}
API -> API : validate type, scope,\nname length, content length API -> API : validate type, scope,\nname/content/description
API -> Facade : save_structured_memory() API -> API : reject internal scopes\nowner-bind workstream scope
Facade -> Storage : create / update API -> Facade : save_structured_memory_strict()
Facade -> Storage : atomic upsert
Storage --> API : memory row Storage --> API : memory row
API -> API : record_audit(actor)
API --> SDK : 201 (created) / 200 (updated) API --> SDK : 201 (created) / 200 (updated)
SDK -> API : POST /v1/api/memories/search\n{query, type, ...} SDK -> API : POST /v1/api/memories/search\n{query, type, ...}
API -> Facade : search_structured_memories() API -> API : bind scope to caller
Facade -> Storage : search_structured_memories() API -> Storage : search visible rows
Storage --> API : matched rows Storage --> API : matched rows
API --> SDK : {"memories": [...], "total": N} API --> SDK : {"memories": [...], "total": N}
SDK -> API : DELETE /v1/api/memories/{name}\n?scope=global SDK -> API : DELETE /v1/api/memories/{name}\n?scope=global
API -> Facade : delete_structured_memory() API -> Facade : delete_structured_memory_returning_strict()
Facade -> Storage : delete row Facade -> Storage : DELETE ... RETURNING
API -> API : record_audit(actor)
API --> SDK : {"status": "ok"} API --> SDK : {"status": "ok"}
== Phase 4: Console Admin Path == == Phase 4: Console Admin Path ==
@@ -141,7 +147,7 @@ Storage --> Admin : memory row
Admin --> SDK : memory JSON Admin --> SDK : memory JSON
SDK -> Admin : DELETE /v1/api/admin/memories/{id} SDK -> Admin : DELETE /v1/api/admin/memories/{id}
Admin -> Storage : delete_structured_memory_by_id() Admin -> Storage : delete_structured_memory_by_id_returning()
Admin -> Admin : record_audit(\n"memory.delete") Admin -> Admin : record_audit(\n"memory.delete")
Admin --> SDK : {"status": "ok"} Admin --> SDK : {"status": "ok"}
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:bd7fe8bf5c2b56b075453a316e54d61214b0ae912517d9cad6c1e88785aac722 oid sha256:137d6c91a34695c820d8b0a33fd753e79165604aa92bf2ac8480d3744b2ef844
size 300010 size 305199
+94 -42
View File
@@ -20,7 +20,7 @@ Each memory has three dimensions:
| Type | Purpose | | Type | Purpose |
|-------------|------------------------------------------------------------| |-------------|------------------------------------------------------------|
| `user` | User preferences, conventions, working style | | `user` | User preferences, conventions, working style |
| `project` | Project-specific knowledge, architecture, patterns | | `general` | General knowledge, architecture, patterns |
| `feedback` | Corrections, lessons learned, things to avoid | | `feedback` | Corrections, lessons learned, things to avoid |
| `reference` | Reference material, documentation, specifications | | `reference` | Reference material, documentation, specifications |
@@ -31,25 +31,37 @@ Each memory has three dimensions:
| `global` | Visible to all workstreams and users | | `global` | Visible to all workstreams and users |
| `workstream` | Visible only within the originating workstream | | `workstream` | Visible only within the originating workstream |
| `user` | Follows the authenticated user across workstreams | | `user` | Follows the authenticated user across workstreams |
| `coordinator` | Coordinator sessions only; follows the user across coordinators | | `coordinator` | Coordinator sessions only; follows the acting user |
| `project` | Shared by workstreams attached to one active project |
A memory's identity is the tuple `(name, scope, scope_id)`. Saving a memory A memory's identity is the tuple `(name, scope, scope_id)`. Saving a memory
with the same identity upserts -- updating content while preserving the ID. with the same identity upserts -- updating content while preserving the ID.
### Coordinator scope ### Inherited target and coordinator scope
Coordinator sessions are isolated to a single scope: `coordinator`, keyed by Name-based operations use one inherited target when `scope` is omitted:
the coordinator's creator `user_id`. It is durable -- every coordinator
session the same user runs (including concurrent ones) shares one - An attached active project selects `project` for `save`, `get`, and
orchestration namespace, so procedures and lessons survive close/reopen. `delete`.
- Read-only project access permits `get`, but `save` and `delete` fail. They do
not fall back to a broader namespace.
- Without a project, interactive sessions select `global`; coordinator
sessions select `coordinator`.
A valid explicit scope selects exactly that scope. `search` and `list` are the
only actions that span every visible scope when `scope` is omitted.
Each coordinator's private `coordinator` namespace is keyed by the acting
user's `user_id`. It is durable -- every coordinator session that user runs
(including concurrent ones) shares one orchestration namespace, so procedures
and lessons survive close/reopen.
Isolation is bidirectional and enforced by session kind, not by secrecy of Isolation is bidirectional and enforced by session kind, not by secrecy of
the scope id: the scope id:
- A coordinator session can read and write **only** `coordinator`-scope rows. - A coordinator session sees its acting user's `coordinator` scope and, when
It never sees `global`/`workstream`/`user` memories, so content written by attached, the shared `project` scope. It never sees
interactive sessions (which routinely ingest untrusted MCP/attachment `global`/`workstream`/`user` memories.
output) cannot reach a coordinator's system message.
- Interactive sessions -- including a coordinator's own children, which share - Interactive sessions -- including a coordinator's own children, which share
its `user_id` -- are rejected from the `coordinator` scope on every memory its `user_id` -- are rejected from the `coordinator` scope on every memory
action. Children cannot plant rows the parent coordinator would read. action. Children cannot plant rows the parent coordinator would read.
@@ -64,12 +76,13 @@ coordinator cannot be constructed, so the scope id is always a real user.
On every conversation turn, the system: On every conversation turn, the system:
1. Fetches up to `fetch_limit` memories visible in the current scope 1. Resolves the acting principal and their live project access
2. Extracts context from the last 3 user messages 2. Fetches up to `fetch_limit` memories across that visibility envelope
3. Scores memories against that context using a BM25 index 3. Extracts context from the last 3 user messages
4. Injects the top `relevance_k` memories into the system message as 4. Scores memories against that context using a BM25 index
5. Injects the top `relevance_k` memories into the system message as
`<memories>` XML tags `<memories>` XML tags
5. Appends a hint telling the model how many memories are in scope 6. Appends a hint telling the model how many memories are in scope
This means the model always has its most relevant memories available without This means the model always has its most relevant memories available without
explicit recall -- but can still use `memory(action='search')` for deeper explicit recall -- but can still use `memory(action='search')` for deeper
@@ -107,19 +120,23 @@ All fields are optional. Defaults are shown above.
## Tool Usage ## Tool Usage
The `memory` tool supports four actions: The `memory` tool supports five actions:
### save ### save
Store or update a memory. Store or update a memory.
Every save is a complete write for the relevance summary: `description` must
be supplied and contain non-whitespace text on both creation and update.
Content-only updates are rejected.
```json ```json
{ {
"action": "save", "action": "save",
"name": "project_architecture", "name": "project_architecture",
"content": "The project uses a hexagonal architecture with...", "content": "The project uses a hexagonal architecture with...",
"description": "Core architecture patterns", "description": "Core architecture patterns",
"type": "project", "type": "general",
"scope": "global" "scope": "global"
} }
``` ```
@@ -128,9 +145,26 @@ Store or update a memory.
|---------------|----------|-------------|------------------------------------------| |---------------|----------|-------------|------------------------------------------|
| `name` | yes | -- | Snake_case identifier (max 256 chars) | | `name` | yes | -- | Snake_case identifier (max 256 chars) |
| `content` | yes | -- | Memory content (max `max_content` chars) | | `content` | yes | -- | Memory content (max `max_content` chars) |
| `description` | no | `""` | Short description for relevance matching | | `description` | yes | -- | Non-empty relevance summary, required on create and update |
| `type` | no | `"project"` | One of: user, project, feedback, reference | | `type` | no | `"general"` | One of: user, general, feedback, reference |
| `scope` | no | `"global"` | One of: global, workstream, user | | `scope` | no | inherited | Kind-valid scope; see inherited target above |
### get
Retrieve the full content of one memory by name.
```json
{
"action": "get",
"name": "project_architecture",
"scope": "project"
}
```
| Parameter | Required | Default | Description |
|-----------|----------|-----------|----------------------------|
| `name` | yes | -- | Memory name to retrieve |
| `scope` | no | inherited | Exact scope to query |
### search ### search
@@ -140,7 +174,7 @@ Find memories by query (BM25 full-text search).
{ {
"action": "search", "action": "search",
"query": "authentication patterns", "query": "authentication patterns",
"type": "project", "type": "general",
"limit": 10 "limit": 10
} }
``` ```
@@ -167,7 +201,7 @@ Remove a memory by name.
| Parameter | Required | Default | Description | | Parameter | Required | Default | Description |
|------------|----------|------------|--------------------------| |------------|----------|------------|--------------------------|
| `name` | yes | -- | Memory name to delete | | `name` | yes | -- | Memory name to delete |
| `scope` | no | `"global"` | Scope of the memory | | `scope` | no | inherited | Exact scope to delete |
### list ### list
@@ -197,6 +231,12 @@ Four endpoints on the server for programmatic memory access.
List memories with optional filters. List memories with optional filters.
Without `scope`, the response is restricted to `global` plus the authenticated
caller's `user` namespace. The public API accepts only `global`, `user`, and
`workstream`; internal `project` and `coordinator` namespaces remain available
through the session tool and admin API. Explicit `workstream` access requires
its persisted owner (or a service token).
**Query parameters:** **Query parameters:**
| Parameter | Type | Required | Default | Description | | Parameter | Type | Required | Default | Description |
@@ -204,10 +244,10 @@ List memories with optional filters.
| `type` | string | no | `""` | Filter by memory type | | `type` | string | no | `""` | Filter by memory type |
| `scope` | string | no | `""` | Filter by scope | | `scope` | string | no | `""` | Filter by scope |
| `scope_id` | string | no | `""` | Filter by scope ID | | `scope_id` | string | no | `""` | Filter by scope ID |
| `limit` | int | no | `100` | Max results (capped at 200) | | `limit` | int | no | `100` | Max results (1-200) |
When `scope=user` and `scope_id` is omitted, the authenticated user's ID is When `scope=user`, the authenticated user's ID is used automatically and a
used automatically. different supplied ID is rejected. `scope=workstream` requires `scope_id`.
**Response:** `200` **Response:** `200`
@@ -218,7 +258,7 @@ used automatically.
"memory_id": "a1b2c3d4-e5f6-...", "memory_id": "a1b2c3d4-e5f6-...",
"name": "project_architecture", "name": "project_architecture",
"description": "Core architecture patterns", "description": "Core architecture patterns",
"type": "project", "type": "general",
"scope": "global", "scope": "global",
"scope_id": "", "scope_id": "",
"content": "The project uses a hexagonal architecture...", "content": "The project uses a hexagonal architecture...",
@@ -236,6 +276,9 @@ used automatically.
Save or upsert a structured memory. Save or upsert a structured memory.
`description` is mandatory for both creates and updates and must contain
non-whitespace text. The API rejects content-only updates.
**Request body:** **Request body:**
```json ```json
@@ -243,7 +286,7 @@ Save or upsert a structured memory.
"name": "deployment_process", "name": "deployment_process",
"content": "Deploy via GitHub Actions. Staging auto-deploys on push to main.", "content": "Deploy via GitHub Actions. Staging auto-deploys on push to main.",
"description": "CI/CD deployment workflow", "description": "CI/CD deployment workflow",
"type": "project", "type": "general",
"scope": "global", "scope": "global",
"scope_id": "" "scope_id": ""
} }
@@ -253,8 +296,8 @@ Save or upsert a structured memory.
|--------------|--------|----------|-------------|--------------------------------------| |--------------|--------|----------|-------------|--------------------------------------|
| `name` | string | yes | -- | Memory name (max 256 chars) | | `name` | string | yes | -- | Memory name (max 256 chars) |
| `content` | string | yes | -- | Memory content (max 65536 chars) | | `content` | string | yes | -- | Memory content (max 65536 chars) |
| `description`| string | no | `""` | Short description for search ranking | | `description`| string | yes | -- | Non-empty relevance summary, required on create and update |
| `type` | string | no | `"project"` | One of: user, project, feedback, reference | | `type` | string | no | unset | user, general, feedback, or reference |
| `scope` | string | no | `"global"` | One of: global, workstream, user | | `scope` | string | no | `"global"` | One of: global, workstream, user |
| `scope_id` | string | no | `""` | Scope qualifier (auto-resolved for user scope) | | `scope_id` | string | no | `""` | Scope qualifier (auto-resolved for user scope) |
@@ -265,7 +308,7 @@ Save or upsert a structured memory.
"memory_id": "a1b2c3d4-e5f6-...", "memory_id": "a1b2c3d4-e5f6-...",
"name": "deployment_process", "name": "deployment_process",
"description": "CI/CD deployment workflow", "description": "CI/CD deployment workflow",
"type": "project", "type": "general",
"scope": "global", "scope": "global",
"scope_id": "", "scope_id": "",
"content": "Deploy via GitHub Actions...", "content": "Deploy via GitHub Actions...",
@@ -281,7 +324,10 @@ same `(name, scope, scope_id)` already existed.
| Status | Condition | | Status | Condition |
|--------|------------------------------------| |--------|------------------------------------|
| 400 | Missing name, empty content, invalid type/scope, content too long | | 400 | Invalid input, scope, scope ID, or limit |
| 403 | Cross-user or non-owner workstream access |
| 404 | Explicit workstream does not exist |
| 500 | Storage mutation failed |
--- ---
@@ -290,12 +336,15 @@ same `(name, scope, scope_id)` already existed.
Search memories by query. Uses POST for the request body but is non-mutating Search memories by query. Uses POST for the request body but is non-mutating
(requires only `read` scope). (requires only `read` scope).
An omitted scope searches the same caller-bound `global` + `user` envelope as
the list endpoint. It never means every row in the table.
**Request body:** **Request body:**
```json ```json
{ {
"query": "authentication", "query": "authentication",
"type": "project", "type": "general",
"scope": "", "scope": "",
"scope_id": "", "scope_id": "",
"limit": 20 "limit": 20
@@ -308,7 +357,7 @@ Search memories by query. Uses POST for the request body but is non-mutating
| `type` | string | no | `""` | Filter by type | | `type` | string | no | `""` | Filter by type |
| `scope` | string | no | `""` | Filter by scope | | `scope` | string | no | `""` | Filter by scope |
| `scope_id` | string | no | `""` | Filter by scope ID | | `scope_id` | string | no | `""` | Filter by scope ID |
| `limit` | int | no | `20` | Max results (capped at 50) | | `limit` | int | no | `20` | Max results (1-50) |
**Response:** `200` **Response:** `200`
@@ -319,7 +368,7 @@ Search memories by query. Uses POST for the request body but is non-mutating
"memory_id": "a1b2c3d4-e5f6-...", "memory_id": "a1b2c3d4-e5f6-...",
"name": "auth_patterns", "name": "auth_patterns",
"description": "Authentication architecture", "description": "Authentication architecture",
"type": "project", "type": "general",
"scope": "global", "scope": "global",
"scope_id": "", "scope_id": "",
"content": "JWT tokens with HS256...", "content": "JWT tokens with HS256...",
@@ -337,6 +386,9 @@ Search memories by query. Uses POST for the request body but is non-mutating
Delete a memory by name and scope. Delete a memory by name and scope.
Deletes are atomic: the row used for the success result and audit event is the
row actually removed. A storage failure returns `500`, not a false `404`.
**Path parameters:** **Path parameters:**
| Parameter | Type | Description | | Parameter | Type | Description |
@@ -391,7 +443,7 @@ List memories across all scopes (no automatic scope resolution).
"memory_id": "a1b2c3d4-e5f6-...", "memory_id": "a1b2c3d4-e5f6-...",
"name": "project_architecture", "name": "project_architecture",
"description": "Core architecture patterns", "description": "Core architecture patterns",
"type": "project", "type": "general",
"scope": "global", "scope": "global",
"scope_id": "", "scope_id": "",
"content": "The project uses...", "content": "The project uses...",
@@ -440,7 +492,7 @@ Get a single memory by ID.
"memory_id": "a1b2c3d4-e5f6-...", "memory_id": "a1b2c3d4-e5f6-...",
"name": "project_architecture", "name": "project_architecture",
"description": "Core architecture patterns", "description": "Core architecture patterns",
"type": "project", "type": "general",
"scope": "global", "scope": "global",
"scope_id": "", "scope_id": "",
"content": "The project uses...", "content": "The project uses...",
@@ -497,13 +549,13 @@ with TurnstoneServer("http://localhost:8080", token="tok_xxx") as client:
"api_conventions", "api_conventions",
"All endpoints use /v1/ prefix. JSON responses.", "All endpoints use /v1/ prefix. JSON responses.",
description="API design patterns", description="API design patterns",
mem_type="project", mem_type="general",
scope="global", scope="global",
) )
print(mem.memory_id) print(mem.memory_id)
# Search memories # Search memories
results = client.search_memories("authentication", mem_type="project", limit=10) results = client.search_memories("authentication", mem_type="general", limit=10)
for m in results.memories: for m in results.memories:
print(f"{m['name']}: {m['description']}") print(f"{m['name']}: {m['description']}")
@@ -524,7 +576,7 @@ with TurnstoneConsole("http://localhost:9090", token="tok_xxx") as admin:
result = admin.list_memories(scope="global", limit=100) result = admin.list_memories(scope="global", limit=100)
# Search # Search
result = admin.search_memories("architecture", mem_type="project") result = admin.search_memories("architecture", mem_type="general")
# Get by ID # Get by ID
mem = admin.get_memory("a1b2c3d4-e5f6-...") mem = admin.get_memory("a1b2c3d4-e5f6-...")
@@ -548,14 +600,14 @@ const mem = await client.saveMemory({
name: "api_conventions", name: "api_conventions",
content: "All endpoints use /v1/ prefix. JSON responses.", content: "All endpoints use /v1/ prefix. JSON responses.",
description: "API design patterns", description: "API design patterns",
type: "project", type: "general",
scope: "global", scope: "global",
}); });
// Search memories // Search memories
const results = await client.searchMemories({ const results = await client.searchMemories({
query: "authentication", query: "authentication",
type: "project", type: "general",
limit: 10, limit: 10,
}); });
+18 -10
View File
@@ -152,7 +152,7 @@ into its successor's trajectory.
**Auto-approved** (no user confirmation needed at runtime): **Auto-approved** (no user confirmation needed at runtime):
- `read_file` -- reads files, no side effects - `read_file` -- reads files, no side effects
- `search` -- grep-style search, no side effects - `search` -- grep-style search, no side effects
- `memory` -- structured persistent memory (save/search/delete/list) - `memory` -- structured persistent memory (save/get/search/delete/list)
- `recall` -- searches conversation history - `recall` -- searches conversation history
- `notify` -- sends notifications to linked channels (time-sensitive, auto-approved for urgency) - `notify` -- sends notifications to linked channels (time-sensitive, auto-approved for urgency)
@@ -433,7 +433,7 @@ Delegate a general-purpose task to an autonomous sub-agent.
|-----------|--------|----------|-------------| |-----------|--------|----------|-------------|
| `prompt` | string | yes | Complete task description for the sub-agent. | | `prompt` | string | yes | Complete task description for the sub-agent. |
- **What it does**: Spawns a sub-agent that inherits the `TASK_AGENT_TOOLS` set (read, write, edit, search, bash, web tools, memory tools). The sub-agent runs autonomously to completion. Use for work that requires file modifications or command execution. - **What it does**: Spawns a sub-agent that inherits the `TASK_AGENT_TOOLS` set (read, write, edit, search, bash, and web tools). The sub-agent runs autonomously to completion. Use for work that requires file modifications or command execution.
- **Auto-approve**: No -- requires user confirmation. - **Auto-approve**: No -- requires user confirmation.
- **Agent availability**: Top-level only. - **Agent availability**: Top-level only.
@@ -447,18 +447,26 @@ Structured persistent memory across sessions with typed, scoped entries.
| Parameter | Type | Required | Description | | Parameter | Type | Required | Description |
|---------------|---------|----------|-------------| |---------------|---------|----------|-------------|
| `action` | string | yes | `save`, `search`, `delete`, or `list`. | | `action` | string | yes | `save`, `get`, `search`, `delete`, or `list`. |
| `name` | string | save/delete | Short snake_case identifier for the memory. | | `name` | string | save/get/delete | Short snake_case identifier for the memory. |
| `content` | string | save | Memory content to store. | | `content` | string | save | Memory content to store. |
| `description` | string | no | Short description for relevance matching (recommended for `save`). | | `description` | string | save | Non-empty description for relevance matching; required on create and update. |
| `type` | string | no | Memory type: `user`, `project`, `feedback`, or `reference`. Default: `project`. | | `type` | string | no | Memory type: `user`, `general`, `feedback`, or `reference`. Default: `general`. |
| `scope` | string | no | Memory scope: `global`, `workstream`, or `user`. Default: `global`. | | `scope` | string | no | Memory scope: `global`, `workstream`, `user`, `coordinator`, or `project`. See defaults below. |
| `query` | string | search | Search query for finding memories. | | `query` | string | search | Search query for finding memories. |
| `limit` | integer | no | Max results for `search` or `list`. Default: 20. | | `limit` | integer | no | Max results for `search` or `list`. Default: 20. |
- **What it does**: Manages structured persistent memories in the database. Memories persist across sessions, have a type classification (user preferences, project knowledge, feedback, reference material) and a scope (global across all workstreams, private to a workstream, or following a user). Relevant memories are included in the system prompt on startup. - **What it does**: Manages structured persistent memories in the database.
Memories persist across sessions, have a type classification, and live in a
role-specific visible scope. Unscoped `save`/`get`/`delete` resolve to one
target: the attached active project, otherwise `global` for an interactive
session or `coordinator` for a coordinator. Read-only project access permits
`get` but makes `save`/`delete` fail without falling back. A valid explicit
scope selects exactly that scope. Unscoped `search`/`list` cover all visible
scopes; use the displayed scope when following a result with `get` or
`delete`.
- **Auto-approve**: Yes. - **Auto-approve**: Yes.
- **Agent availability**: Not available to sub-agents (top-level only). - **Agent availability**: Not available to task agents.
--- ---
@@ -473,7 +481,7 @@ Search conversation history for past messages and tool results.
- **What it does**: Searches conversation history across sessions using FTS5 full-text search. Returns matching messages, tool calls, and tool results with timestamps and workstream context. - **What it does**: Searches conversation history across sessions using FTS5 full-text search. Returns matching messages, tool calls, and tool results with timestamps and workstream context.
- **Auto-approve**: Yes. - **Auto-approve**: Yes.
- **Agent availability**: Not available to sub-agents (top-level only). - **Agent availability**: Not available to task agents.
--- ---
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
"title": "turnstone Console API", "title": "turnstone Console API",
"version": "1.8.0a6", "version": "1.8.0a7",
"description": "Cluster-wide visibility and control across all turnstone nodes." "description": "Cluster-wide visibility and control across all turnstone nodes."
}, },
"paths": { "paths": {
+162 -10
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
"title": "turnstone Server API", "title": "turnstone Server API",
"version": "1.8.0a6", "version": "1.8.0a7",
"description": "Single-node workstream management, chat interaction, and real-time streaming." "description": "Single-node workstream management, chat interaction, and real-time streaming."
}, },
"paths": { "paths": {
@@ -1869,7 +1869,7 @@
}, },
"/v1/api/memories": { "/v1/api/memories": {
"get": { "get": {
"summary": "List structured memories", "summary": "List structured memories. Without a scope, returns global plus the authenticated user's memories; workstream scope is owner-bound.",
"operationId": "v1_api_memories_get", "operationId": "v1_api_memories_get",
"tags": [ "tags": [
"Memories" "Memories"
@@ -1891,7 +1891,7 @@
"schema": { "schema": {
"type": "string" "type": "string"
}, },
"description": "Filter by scope" "description": "Filter by public scope: global, workstream, or user"
}, },
{ {
"name": "scope_id", "name": "scope_id",
@@ -1923,6 +1923,46 @@
} }
} }
} }
},
"400": {
"description": "Error 400",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"403": {
"description": "Error 403",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Error 500",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
} }
} }
}, },
@@ -1962,13 +2002,43 @@
} }
} }
} }
},
"403": {
"description": "Error 403",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Error 500",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
} }
} }
} }
}, },
"/v1/api/memories/search": { "/v1/api/memories/search": {
"post": { "post": {
"summary": "Search structured memories by query", "summary": "Search structured memories by query. Without a scope, searches global plus the authenticated user's memories.",
"operationId": "v1_api_memories_search_post", "operationId": "v1_api_memories_search_post",
"tags": [ "tags": [
"Memories" "Memories"
@@ -1993,6 +2063,46 @@
} }
} }
} }
},
"400": {
"description": "Error 400",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"403": {
"description": "Error 403",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Error 500",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
} }
} }
} }
@@ -2043,6 +2153,26 @@
} }
} }
}, },
"400": {
"description": "Error 400",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"403": {
"description": "Error 403",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"404": { "404": {
"description": "Error 404", "description": "Error 404",
"content": { "content": {
@@ -2052,6 +2182,16 @@
} }
} }
} }
},
"500": {
"description": "Error 500",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
} }
} }
} }
@@ -3854,33 +3994,43 @@
"properties": { "properties": {
"name": { "name": {
"description": "Memory identifier (normalized to snake_case)", "description": "Memory identifier (normalized to snake_case)",
"maxLength": 256,
"minLength": 1,
"title": "Name", "title": "Name",
"type": "string" "type": "string"
}, },
"content": { "content": {
"description": "Memory content", "description": "Memory content",
"maxLength": 65536, "maxLength": 65536,
"minLength": 1,
"title": "Content", "title": "Content",
"type": "string" "type": "string"
}, },
"description": { "description": {
"default": "", "description": "Required non-empty description used for relevance matching",
"description": "Short description for relevance matching", "minLength": 1,
"title": "Description", "title": "Description",
"type": "string" "type": "string"
}, },
"type": { "type": {
"default": "general", "anyOf": [
"description": "Memory type", {
"enum": [ "enum": [
"user", "user",
"general", "general",
"feedback", "feedback",
"reference" "reference"
], ],
"title": "Type",
"type": "string" "type": "string"
}, },
{
"type": "null"
}
],
"default": null,
"description": "Memory type; omission preserves it on update and defaults on insert",
"title": "Type"
},
"scope": { "scope": {
"default": "global", "default": "global",
"description": "Memory scope", "description": "Memory scope",
@@ -3901,7 +4051,8 @@
}, },
"required": [ "required": [
"name", "name",
"content" "content",
"description"
], ],
"title": "SaveMemoryRequest", "title": "SaveMemoryRequest",
"type": "object" "type": "object"
@@ -3995,6 +4146,7 @@
"properties": { "properties": {
"query": { "query": {
"description": "Search query text", "description": "Search query text",
"minLength": 1,
"title": "Query", "title": "Query",
"type": "string" "type": "string"
}, },
+8 -1
View File
@@ -394,7 +394,14 @@ export class TurnstoneServer extends BaseClient {
} }
async saveMemory(opts: SaveMemoryRequest): Promise<MemoryInfo> { async saveMemory(opts: SaveMemoryRequest): Promise<MemoryInfo> {
return this.request("POST", "/v1/api/memories", { json: opts }); if (typeof opts.description !== "string" || !opts.description.trim()) {
throw new TypeError(
"memory description is required and must be non-empty",
);
}
return this.request("POST", "/v1/api/memories", {
json: { ...opts, description: opts.description.trim() },
});
} }
async searchMemories( async searchMemories(
+1 -1
View File
@@ -894,7 +894,7 @@ export interface WorkstreamsOptions {
export interface SaveMemoryRequest { export interface SaveMemoryRequest {
name: string; name: string;
content: string; content: string;
description?: string; description: string;
type?: "user" | "general" | "feedback" | "reference"; type?: "user" | "general" | "feedback" | "reference";
scope?: "global" | "workstream" | "user"; scope?: "global" | "workstream" | "user";
scope_id?: string; scope_id?: string;
+37
View File
@@ -75,6 +75,43 @@ describe("TurnstoneServer", () => {
}); });
}); });
it("saveMemory requires and normalizes the description", async () => {
const fetchFn = mockFetch({
memory_id: "m1",
name: "deployment_process",
description: "Production deployment workflow",
type: "general",
scope: "global",
scope_id: "",
content: "Deploy from main",
created: "2026-08-11T00:00:00",
updated: "2026-08-11T00:00:00",
});
const client = new TurnstoneServer({
baseUrl: "http://test",
fetch: fetchFn,
});
await client.saveMemory({
name: "deployment_process",
content: "Deploy from main",
description: " Production deployment workflow ",
});
const [, init] = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0];
expect(JSON.parse(init.body)).toMatchObject({
description: "Production deployment workflow",
});
await expect(
client.saveMemory({
name: "deployment_process",
content: "Deploy from main",
description: " ",
}),
).rejects.toThrow("description is required");
expect(fetchFn).toHaveBeenCalledTimes(1);
});
it("send posts correct payload", async () => { it("send posts correct payload", async () => {
const fetchFn = mockFetch({ status: "ok" }); const fetchFn = mockFetch({ status: "ok" });
const client = new TurnstoneServer({ const client = new TurnstoneServer({
+1
View File
@@ -2261,6 +2261,7 @@ def test_prepare_and_write_path_refuse_in_the_same_words(coord_session):
item = sess._prepare_tool(_tc("tasks", args)) item = sess._prepare_tool(_tc("tasks", args))
assert "error" in item, args assert "error" in item, args
expected = sess._coord_tool_error("call-1", "tasks", f"{action}: {authoritative['error']}") expected = sess._coord_tool_error("call-1", "tasks", f"{action}: {authoritative['error']}")
expected["_principal_id"] = sess._tool_prepare_principal_id()
assert item == expected, (args, item["error"]) assert item == expected, (args, item["error"])
+1
View File
@@ -256,6 +256,7 @@ class TestWorldSeeding:
"memory": [ "memory": [
{ {
"name": "proj-context", "name": "proj-context",
"description": "Project deployment context",
"content": "acme-api: staging tracks main.", "content": "acme-api: staging tracks main.",
"type": "reference", "type": "reference",
} }
+133 -18
View File
@@ -121,7 +121,7 @@ def _seed_memory(storage, name="test_key", content="test content", **kw):
storage.create_structured_memory( storage.create_structured_memory(
mid, mid,
name, name,
kw.get("description", ""), kw.get("description", "Seeded memory"),
kw.get("mem_type", "general"), kw.get("mem_type", "general"),
kw.get("scope", "global"), kw.get("scope", "global"),
kw.get("scope_id", ""), kw.get("scope_id", ""),
@@ -130,6 +130,20 @@ def _seed_memory(storage, name="test_key", content="test content", **kw):
return mid return mid
def _seed_workstream(storage, ws_id: str = "ws1", user_id: str = "test-user") -> None:
storage.register_workstream(ws_id, user_id=user_id)
def _save_body(name: str, content: str, **overrides: Any) -> dict[str, Any]:
body: dict[str, Any] = {
"name": name,
"content": content,
"description": f"Description for {name}",
}
body.update(overrides)
return body
# =========================================================================== # ===========================================================================
# Server endpoint tests # Server endpoint tests
# =========================================================================== # ===========================================================================
@@ -158,6 +172,7 @@ class TestServerListMemories:
assert r.json()["memories"][0]["name"] == "a" assert r.json()["memories"][0]["name"] == "a"
def test_filter_by_scope(self, server_client, storage): def test_filter_by_scope(self, server_client, storage):
_seed_workstream(storage)
_seed_memory(storage, "a", "x", scope="global") _seed_memory(storage, "a", "x", scope="global")
_seed_memory(storage, "b", "y", scope="workstream", scope_id="ws1") _seed_memory(storage, "b", "y", scope="workstream", scope_id="ws1")
r = server_client.get("/v1/api/memories?scope=workstream&scope_id=ws1") r = server_client.get("/v1/api/memories?scope=workstream&scope_id=ws1")
@@ -174,12 +189,38 @@ class TestServerListMemories:
r = server_client.get("/v1/api/memories?limit=abc") r = server_client.get("/v1/api/memories?limit=abc")
assert r.status_code == 400 assert r.status_code == 400
def test_unscoped_list_is_caller_bound(self, server_client, storage):
_seed_memory(storage, "global_visible", "g")
_seed_memory(storage, "own_visible", "u", scope="user", scope_id="test-user")
_seed_memory(storage, "victim_user", "secret", scope="user", scope_id="victim")
_seed_memory(storage, "victim_coord", "secret", scope="coordinator", scope_id="victim")
_seed_memory(storage, "private_project", "secret", scope="project", scope_id="p1")
r = server_client.get("/v1/api/memories")
assert r.status_code == 200
assert {row["name"] for row in r.json()["memories"]} == {
"global_visible",
"own_visible",
}
def test_internal_scopes_are_rejected(self, server_client):
for scope in ("coordinator", "project", "bogus"):
r = server_client.get(f"/v1/api/memories?scope={scope}&scope_id=victim")
assert r.status_code == 400
def test_workstream_scope_is_owner_bound(self, server_client, storage):
_seed_workstream(storage, "victim-ws", "victim")
_seed_memory(storage, "secret", "x", scope="workstream", scope_id="victim-ws")
r = server_client.get("/v1/api/memories?scope=workstream&scope_id=victim-ws")
assert r.status_code == 403
class TestServerSaveMemory: class TestServerSaveMemory:
def test_create(self, server_client): def test_create(self, server_client):
r = server_client.post( r = server_client.post(
"/v1/api/memories", "/v1/api/memories",
json={"name": "my_key", "content": "my content"}, json=_save_body("my_key", "my content"),
) )
assert r.status_code == 201 assert r.status_code == 201
data = r.json() data = r.json()
@@ -191,21 +232,23 @@ class TestServerSaveMemory:
def test_upsert(self, server_client): def test_upsert(self, server_client):
server_client.post( server_client.post(
"/v1/api/memories", "/v1/api/memories",
json={"name": "key", "content": "v1"}, json=_save_body("key", "v1"),
) )
r = server_client.post( r = server_client.post(
"/v1/api/memories", "/v1/api/memories",
json={"name": "key", "content": "v2"}, json=_save_body("key", "v2", description="Updated key description"),
) )
assert r.status_code == 200 assert r.status_code == 200
assert r.json()["content"] == "v2" assert r.json()["content"] == "v2"
def test_with_type_and_scope(self, server_client): def test_with_type_and_scope(self, server_client, storage):
_seed_workstream(storage)
r = server_client.post( r = server_client.post(
"/v1/api/memories", "/v1/api/memories",
json={ json={
"name": "feedback_key", "name": "feedback_key",
"content": "data", "content": "data",
"description": "Feedback memory",
"type": "feedback", "type": "feedback",
"scope": "workstream", "scope": "workstream",
"scope_id": "ws1", "scope_id": "ws1",
@@ -216,17 +259,30 @@ class TestServerSaveMemory:
assert r.json()["scope"] == "workstream" assert r.json()["scope"] == "workstream"
def test_missing_name(self, server_client): def test_missing_name(self, server_client):
r = server_client.post("/v1/api/memories", json={"content": "data"}) r = server_client.post(
"/v1/api/memories", json={"content": "data", "description": "Missing name"}
)
assert r.status_code == 400 assert r.status_code == 400
def test_missing_content(self, server_client): def test_missing_content(self, server_client):
r = server_client.post("/v1/api/memories", json={"name": "k"}) r = server_client.post(
"/v1/api/memories", json={"name": "k", "description": "Missing content"}
)
assert r.status_code == 400 assert r.status_code == 400
@pytest.mark.parametrize("description", [None, "", " "])
def test_missing_or_empty_description(self, server_client, description):
r = server_client.post(
"/v1/api/memories",
json={"name": "k", "content": "c", "description": description},
)
assert r.status_code == 400
assert "description is required" in r.json()["error"]
def test_invalid_type(self, server_client): def test_invalid_type(self, server_client):
r = server_client.post( r = server_client.post(
"/v1/api/memories", "/v1/api/memories",
json={"name": "k", "content": "c", "type": "bogus"}, json=_save_body("k", "c", type="bogus"),
) )
assert r.status_code == 400 assert r.status_code == 400
assert "invalid type" in r.json()["error"] assert "invalid type" in r.json()["error"]
@@ -234,7 +290,7 @@ class TestServerSaveMemory:
def test_invalid_scope(self, server_client): def test_invalid_scope(self, server_client):
r = server_client.post( r = server_client.post(
"/v1/api/memories", "/v1/api/memories",
json={"name": "k", "content": "c", "scope": "bogus"}, json=_save_body("k", "c", scope="bogus"),
) )
assert r.status_code == 400 assert r.status_code == 400
assert "invalid scope" in r.json()["error"] assert "invalid scope" in r.json()["error"]
@@ -242,7 +298,7 @@ class TestServerSaveMemory:
def test_content_too_large(self, server_client): def test_content_too_large(self, server_client):
r = server_client.post( r = server_client.post(
"/v1/api/memories", "/v1/api/memories",
json={"name": "k", "content": "x" * 70000}, json=_save_body("k", "x" * 70000),
) )
assert r.status_code == 400 assert r.status_code == 400
assert "limit" in r.json()["error"] assert "limit" in r.json()["error"]
@@ -250,18 +306,32 @@ class TestServerSaveMemory:
def test_name_normalisation(self, server_client): def test_name_normalisation(self, server_client):
r = server_client.post( r = server_client.post(
"/v1/api/memories", "/v1/api/memories",
json={"name": "My-Key Name", "content": "data"}, json=_save_body("My-Key Name", "data"),
) )
assert r.status_code == 201 assert r.status_code == 201
assert r.json()["name"] == "my_key_name" assert r.json()["name"] == "my_key_name"
def test_create_and_update_are_audited(self, server_client, storage):
first = server_client.post(
"/v1/api/memories",
json=_save_body("audit_me", "v1"),
)
second = server_client.post(
"/v1/api/memories",
json=_save_body("audit_me", "v2", description="Updated audit memory"),
)
assert first.status_code == 201
assert second.status_code == 200
assert len(storage.list_audit_events(action="memory.save", user_id="test-user")) == 1
assert len(storage.list_audit_events(action="memory.update", user_id="test-user")) == 1
class TestServerUserScopeSecurity: class TestServerUserScopeSecurity:
def test_user_scope_binds_to_auth(self, server_client): def test_user_scope_binds_to_auth(self, server_client):
"""User scope auto-resolves scope_id from authenticated user.""" """User scope auto-resolves scope_id from authenticated user."""
r = server_client.post( r = server_client.post(
"/v1/api/memories", "/v1/api/memories",
json={"name": "priv", "content": "secret", "scope": "user"}, json=_save_body("priv", "secret", scope="user"),
) )
assert r.status_code == 201 assert r.status_code == 201
assert r.json()["scope_id"] == "test-user" assert r.json()["scope_id"] == "test-user"
@@ -270,7 +340,7 @@ class TestServerUserScopeSecurity:
"""Cannot access another user's memories via scope_id.""" """Cannot access another user's memories via scope_id."""
r = server_client.post( r = server_client.post(
"/v1/api/memories", "/v1/api/memories",
json={"name": "x", "content": "y", "scope": "user", "scope_id": "other-user"}, json=_save_body("x", "y", scope="user", scope_id="other-user"),
) )
assert r.status_code == 403 assert r.status_code == 403
@@ -278,7 +348,7 @@ class TestServerUserScopeSecurity:
"""Passing own user_id as scope_id is allowed.""" """Passing own user_id as scope_id is allowed."""
r = server_client.post( r = server_client.post(
"/v1/api/memories", "/v1/api/memories",
json={"name": "x", "content": "y", "scope": "user", "scope_id": "test-user"}, json=_save_body("x", "y", scope="user", scope_id="test-user"),
) )
assert r.status_code == 201 assert r.status_code == 201
@@ -298,7 +368,7 @@ class TestServerScopeScopeIdValidation:
def test_save_global_with_scope_id_rejected(self, server_client): def test_save_global_with_scope_id_rejected(self, server_client):
r = server_client.post( r = server_client.post(
"/v1/api/memories", "/v1/api/memories",
json={"name": "k", "content": "c", "scope": "global", "scope_id": "ws1"}, json=_save_body("k", "c", scope="global", scope_id="ws1"),
) )
assert r.status_code == 400 assert r.status_code == 400
assert "scope_id" in r.json()["error"] assert "scope_id" in r.json()["error"]
@@ -306,15 +376,16 @@ class TestServerScopeScopeIdValidation:
def test_save_workstream_without_scope_id_rejected(self, server_client): def test_save_workstream_without_scope_id_rejected(self, server_client):
r = server_client.post( r = server_client.post(
"/v1/api/memories", "/v1/api/memories",
json={"name": "k", "content": "c", "scope": "workstream"}, json=_save_body("k", "c", scope="workstream"),
) )
assert r.status_code == 400 assert r.status_code == 400
assert "scope_id is required" in r.json()["error"] assert "scope_id is required" in r.json()["error"]
def test_save_workstream_with_scope_id_ok(self, server_client): def test_save_workstream_with_scope_id_ok(self, server_client, storage):
_seed_workstream(storage)
r = server_client.post( r = server_client.post(
"/v1/api/memories", "/v1/api/memories",
json={"name": "k", "content": "c", "scope": "workstream", "scope_id": "ws1"}, json=_save_body("k", "c", scope="workstream", scope_id="ws1"),
) )
assert r.status_code == 201 assert r.status_code == 201
@@ -380,6 +451,21 @@ class TestServerSearchMemories:
r = server_client.post("/v1/api/memories/search", json={}) r = server_client.post("/v1/api/memories/search", json={})
assert r.status_code == 400 assert r.status_code == 400
def test_unscoped_search_is_caller_bound(self, server_client, storage):
_seed_memory(storage, "own", "needle", scope="user", scope_id="test-user")
_seed_memory(storage, "victim", "needle", scope="user", scope_id="victim")
_seed_memory(storage, "project", "needle", scope="project", scope_id="p1")
r = server_client.post("/v1/api/memories/search", json={"query": "needle"})
assert r.status_code == 200
assert {row["name"] for row in r.json()["memories"]} == {"own"}
def test_internal_scope_is_rejected(self, server_client):
r = server_client.post(
"/v1/api/memories/search",
json={"query": "x", "scope": "project", "scope_id": "p1"},
)
assert r.status_code == 400
class TestServerDeleteMemory: class TestServerDeleteMemory:
def test_delete(self, server_client, storage): def test_delete(self, server_client, storage):
@@ -393,6 +479,7 @@ class TestServerDeleteMemory:
assert r.status_code == 404 assert r.status_code == 404
def test_delete_scoped(self, server_client, storage): def test_delete_scoped(self, server_client, storage):
_seed_workstream(storage)
_seed_memory(storage, "k", "data", scope="workstream", scope_id="ws1") _seed_memory(storage, "k", "data", scope="workstream", scope_id="ws1")
# Wrong scope → not found # Wrong scope → not found
r = server_client.delete("/v1/api/memories/k") r = server_client.delete("/v1/api/memories/k")
@@ -405,6 +492,14 @@ class TestServerDeleteMemory:
r = server_client.delete("/v1/api/memories/k?scope=bogus") r = server_client.delete("/v1/api/memories/k?scope=bogus")
assert r.status_code == 400 assert r.status_code == 400
def test_delete_is_audited(self, server_client, storage):
mid = _seed_memory(storage, "audited")
r = server_client.delete("/v1/api/memories/audited")
assert r.status_code == 200
events = storage.list_audit_events(action="memory.delete", user_id="test-user")
assert len(events) == 1
assert events[0]["resource_id"] == mid
# =========================================================================== # ===========================================================================
# Console admin endpoint tests # Console admin endpoint tests
@@ -492,6 +587,26 @@ class TestAdminDeleteMemory:
r = admin_client.delete("/v1/api/admin/memories/nonexistent-id") r = admin_client.delete("/v1/api/admin/memories/nonexistent-id")
assert r.status_code == 404 assert r.status_code == 404
def test_no_audit_or_success_when_atomic_delete_misses(
self, admin_client, storage, monkeypatch
):
mid = _seed_memory(storage, "still_here")
monkeypatch.setattr(storage, "delete_structured_memory_by_id_returning", lambda _mid: None)
r = admin_client.delete(f"/v1/api/admin/memories/{mid}")
assert r.status_code == 404
assert storage.get_structured_memory(mid) is not None
assert storage.list_audit_events(action="memory.delete") == []
def test_storage_failure_is_500(self, admin_client, storage, monkeypatch):
def _raise(_memory_id):
raise RuntimeError("db down")
monkeypatch.setattr(storage, "delete_structured_memory_by_id_returning", _raise)
r = admin_client.delete("/v1/api/admin/memories/m1")
assert r.status_code == 500
# =========================================================================== # ===========================================================================
# Storage: delete_structured_memory_by_id # Storage: delete_structured_memory_by_id
+42 -6
View File
@@ -1,7 +1,9 @@
"""Tests for turnstone.core.memory_relevance — scoring, formatting, context extraction.""" """Tests for turnstone.core.memory_relevance — scoring, formatting, context extraction."""
from typing import Any
from unittest.mock import patch from unittest.mock import patch
from turnstone.core import auth
from turnstone.core.memory_relevance import ( from turnstone.core.memory_relevance import (
MemoryConfig, MemoryConfig,
build_memory_context, build_memory_context,
@@ -298,6 +300,11 @@ def _make_session(fetch_limit: int = 5, relevance_k: int = 3, **kwargs: object):
) )
def _execute_prepared_tool(session: Any, item: dict[str, Any]) -> tuple[str, str]:
item.setdefault("_principal_id", session._tool_prepare_principal_id())
return item["execute"](item)
class TestCompositionCandidateSelection: class TestCompositionCandidateSelection:
"""Verify the query-aware candidate set in _init_system_messages.""" """Verify the query-aware candidate set in _init_system_messages."""
@@ -520,9 +527,11 @@ class TestMemorySearchToolExecution:
"""Multi-word query returns rows where ANY term matches — not all.""" """Multi-word query returns rows where ANY term matches — not all."""
from turnstone.core.memory import save_structured_memory from turnstone.core.memory import save_structured_memory
save_structured_memory("postgres_notes", "host=localhost port=5432") save_structured_memory(
save_structured_memory("redis_notes", "host=redis port=6379") "postgres_notes", "host=localhost port=5432", description="Postgres notes"
save_structured_memory("unrelated", "completely different") )
save_structured_memory("redis_notes", "host=redis port=6379", description="Redis notes")
save_structured_memory("unrelated", "completely different", description="Unrelated notes")
session = _make_session() session = _make_session()
item = session._prepare_memory( item = session._prepare_memory(
@@ -532,12 +541,39 @@ class TestMemorySearchToolExecution:
# Sanity: prepare returned a search-ready dispatch (not an error item) # Sanity: prepare returned a search-ready dispatch (not an error item)
assert item.get("action") == "search" assert item.get("action") == "search"
call_id, msg = session._exec_memory(item) call_id, msg = _execute_prepared_tool(session, item)
assert call_id == "call-1" assert call_id == "call-1"
assert "postgres_notes" in msg assert "postgres_notes" in msg
# Other memories don't match any query term # Other memories don't match any query term
assert "unrelated" not in msg assert "unrelated" not in msg
def test_search_and_list_guidance_carries_the_displayed_scope(self, tmp_db, monkeypatch):
"""Follow-up guidance must not drop a project result's scope."""
from turnstone.core.memory import save_structured_memory
save_structured_memory(
"july_digest",
"project day digest",
description="July project digest",
scope="project",
scope_id="p1",
)
monkeypatch.setattr(
auth,
"resolve_project_access",
lambda *_a, **_k: auth.ProjectAccess(True, True, "P", "active"),
)
session = _make_session(user_id="u1", project_id="p1")
for args in (
{"action": "search", "query": "digest"},
{"action": "list"},
):
item = session._prepare_memory("call-1", args)
_, msg = _execute_prepared_tool(session, item)
assert "[general:project] july_digest" in msg
assert "call memory(action='get') with the displayed name and scope" in msg
class TestPerTurnSearchCache: class TestPerTurnSearchCache:
"""The per-turn cache spares redundant SQL across mid-turn rebuilds.""" """The per-turn cache spares redundant SQL across mid-turn rebuilds."""
@@ -545,7 +581,7 @@ class TestPerTurnSearchCache:
def test_repeated_search_in_same_turn_hits_cache(self, tmp_db): def test_repeated_search_in_same_turn_hits_cache(self, tmp_db):
from turnstone.core.memory import save_structured_memory from turnstone.core.memory import save_structured_memory
save_structured_memory("hello_mem", "alpha beta gamma") save_structured_memory("hello_mem", "alpha beta gamma", description="Greeting memory")
session = _make_session() session = _make_session()
with patch( with patch(
"turnstone.core.session.search_visible_structured_memories", "turnstone.core.session.search_visible_structured_memories",
@@ -560,7 +596,7 @@ class TestPerTurnSearchCache:
def test_user_turn_invalidates_cache(self, tmp_db): def test_user_turn_invalidates_cache(self, tmp_db):
from turnstone.core.memory import save_structured_memory from turnstone.core.memory import save_structured_memory
save_structured_memory("hello_mem", "alpha") save_structured_memory("hello_mem", "alpha", description="Greeting memory")
session = _make_session() session = _make_session()
with patch( with patch(
"turnstone.core.session.search_visible_structured_memories", "turnstone.core.session.search_visible_structured_memories",
+357 -81
View File
@@ -1,11 +1,4 @@
"""Phase 4: the ``project`` memory scope. """Actor-scoped, live ``project`` memory authorization."""
Covers construction-time access resolution (``_project_id`` / ``_project_writable``)
and its effect on recall ``_visible_scopes`` / ``_resolve_scope_id`` /
``_validate_scope`` for both interactive and coordinator sessions. The ACL is
monkeypatched (it is unit-tested in ``test_project_storage.py``); here we assert
the session wiring around it.
"""
from __future__ import annotations from __future__ import annotations
@@ -35,10 +28,32 @@ def _session(**kwargs: Any) -> ChatSession:
return ChatSession(**defaults) return ChatSession(**defaults)
class TestConstructionResolvesProjectAccess: def _execute_prepared_tool(
"""Construction resolves the attached project through a single session: ChatSession,
``resolve_project_access`` call; recall is gated on read access AND a item: dict[str, Any],
non-archived project.""" ) -> tuple[str, str | list[dict[str, Any]]]:
item.setdefault("_principal_id", session._tool_prepare_principal_id())
return item["execute"](item)
def _project_session(
monkeypatch: pytest.MonkeyPatch,
*,
writable: bool = True,
kind: WorkstreamKind = WorkstreamKind.INTERACTIVE,
user_id: str = "u1",
) -> ChatSession:
"""Construct an attached session whose live ACL stays controllable."""
monkeypatch.setattr(
auth,
"resolve_project_access",
lambda *_a, **_k: auth.ProjectAccess(True, writable, "P", "active"),
)
return _session(user_id=user_id, ws_id="ws1", kind=kind, project_id="p1")
class TestLiveProjectAccess:
"""Each access snapshot resolves the attachment for the current actor."""
def _access(self, can_read: bool, can_write: bool, state: str = "active") -> object: def _access(self, can_read: bool, can_write: bool, state: str = "active") -> object:
return auth.ProjectAccess(can_read, can_write, "P", state) return auth.ProjectAccess(can_read, can_write, "P", state)
@@ -48,9 +63,10 @@ class TestConstructionResolvesProjectAccess:
auth, "resolve_project_access", lambda *a, **k: self._access(True, True) auth, "resolve_project_access", lambda *a, **k: self._access(True, True)
) )
s = _session(user_id="u1", project_id="p1") s = _session(user_id="u1", project_id="p1")
assert s._project_id == "p1" access = s._memory_access()
assert s._project_writable is True assert access.project_id == "p1"
assert s._project_name == "P" assert access.project_writable is True
assert access.project_name == "P"
def test_read_only_member(self, monkeypatch: pytest.MonkeyPatch) -> None: def test_read_only_member(self, monkeypatch: pytest.MonkeyPatch) -> None:
# Read access but no write (e.g. a non-member reading a public project). # Read access but no write (e.g. a non-member reading a public project).
@@ -58,16 +74,19 @@ class TestConstructionResolvesProjectAccess:
auth, "resolve_project_access", lambda *a, **k: self._access(True, False) auth, "resolve_project_access", lambda *a, **k: self._access(True, False)
) )
s = _session(user_id="u1", project_id="p1") s = _session(user_id="u1", project_id="p1")
assert s._project_id == "p1" access = s._memory_access()
assert s._project_writable is False assert access.project_id == "p1"
assert access.project_writable is False
def test_denied_without_access(self, monkeypatch: pytest.MonkeyPatch) -> None: def test_denied_without_access(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr( monkeypatch.setattr(
auth, "resolve_project_access", lambda *a, **k: self._access(False, False) auth, "resolve_project_access", lambda *a, **k: self._access(False, False)
) )
s = _session(user_id="u1", project_id="p1") s = _session(user_id="u1", project_id="p1")
assert s._project_id == "" access = s._memory_access()
assert s._project_writable is False assert access.attached_project_id == "p1"
assert access.project_id == ""
assert access.project_writable is False
def test_archived_project_not_recalled(self, monkeypatch: pytest.MonkeyPatch) -> None: def test_archived_project_not_recalled(self, monkeypatch: pytest.MonkeyPatch) -> None:
# Full access but archived → not recalled (the owner still reaches it via # Full access but archived → not recalled (the owner still reaches it via
@@ -76,13 +95,17 @@ class TestConstructionResolvesProjectAccess:
auth, "resolve_project_access", lambda *a, **k: self._access(True, True, "archived") auth, "resolve_project_access", lambda *a, **k: self._access(True, True, "archived")
) )
s = _session(user_id="u1", project_id="p1") s = _session(user_id="u1", project_id="p1")
assert s._project_id == "" access = s._memory_access()
assert s._project_writable is False assert access.attached_project_id == "p1"
assert access.project_id == ""
assert access.project_writable is False
def test_no_project_id_is_inert(self) -> None: def test_no_project_id_is_inert(self) -> None:
s = _session(user_id="u1") s = _session(user_id="u1")
assert s._project_id == "" access = s._memory_access()
assert s._project_writable is False assert access.attached_project_id == ""
assert access.project_id == ""
assert access.project_writable is False
def test_unauthenticated_never_resolves(self, monkeypatch: pytest.MonkeyPatch) -> None: def test_unauthenticated_never_resolves(self, monkeypatch: pytest.MonkeyPatch) -> None:
# Even if the ACL would allow it, an empty user_id short-circuits before # Even if the ACL would allow it, an empty user_id short-circuits before
@@ -91,13 +114,16 @@ class TestConstructionResolvesProjectAccess:
auth, "resolve_project_access", lambda *a, **k: self._access(True, True) auth, "resolve_project_access", lambda *a, **k: self._access(True, True)
) )
s = _session(user_id="", project_id="p1") s = _session(user_id="", project_id="p1")
assert s._project_id == "" access = s._memory_access()
assert access.attached_project_id == "p1"
assert access.project_id == ""
class TestProjectRecall: class TestProjectRecall:
def test_interactive_visible_scopes_includes_project(self) -> None: def test_interactive_visible_scopes_includes_project(
s = _session(user_id="u1", ws_id="ws1") self, monkeypatch: pytest.MonkeyPatch
s._project_id = "p1" ) -> None:
s = _project_session(monkeypatch)
scopes = s._visible_scopes() scopes = s._visible_scopes()
assert ("project", "p1") in scopes assert ("project", "p1") in scopes
assert ("global", "") in scopes assert ("global", "") in scopes
@@ -107,9 +133,10 @@ class TestProjectRecall:
s = _session(user_id="u1", ws_id="ws1") s = _session(user_id="u1", ws_id="ws1")
assert all(scope != "project" for scope, _ in s._visible_scopes()) assert all(scope != "project" for scope, _ in s._visible_scopes())
def test_coordinator_adds_project_keeps_isolation(self) -> None: def test_coordinator_adds_project_keeps_isolation(
s = _session(user_id="u1", kind=WorkstreamKind.COORDINATOR) self, monkeypatch: pytest.MonkeyPatch
s._project_id = "p1" ) -> None:
s = _project_session(monkeypatch, kind=WorkstreamKind.COORDINATOR)
scopes = s._visible_scopes() scopes = s._visible_scopes()
assert ("coordinator", "u1") in scopes assert ("coordinator", "u1") in scopes
assert ("project", "p1") in scopes assert ("project", "p1") in scopes
@@ -118,25 +145,24 @@ class TestProjectRecall:
def test_visible_scopes_omits_empty_project(self) -> None: def test_visible_scopes_omits_empty_project(self) -> None:
s = _session(user_id="u1", ws_id="ws1") s = _session(user_id="u1", ws_id="ws1")
s._project_id = ""
assert all(scope != "project" for scope, _ in s._visible_scopes()) assert all(scope != "project" for scope, _ in s._visible_scopes())
class TestProjectScopeResolutionAndValidation: class TestProjectScopeResolutionAndValidation:
def test_resolve_scope_id_project(self) -> None: def test_resolve_scope_id_project(self, monkeypatch: pytest.MonkeyPatch) -> None:
s = _session(user_id="u1") s = _project_session(monkeypatch)
s._project_id = "p1"
assert s._resolve_scope_id("project") == "p1" assert s._resolve_scope_id("project") == "p1"
def test_validate_requires_attachment(self) -> None: def test_validate_requires_attachment(self, monkeypatch: pytest.MonkeyPatch) -> None:
s = _session(user_id="u1") s = _session(user_id="u1")
assert s._validate_scope("project", "cid") is not None # not attached → rejected assert s._validate_scope("project", "cid") is not None # not attached → rejected
s._project_id = "p1" attached = _project_session(monkeypatch)
assert s._validate_scope("project", "cid") is None assert attached._validate_scope("project", "cid") is None
def test_coordinator_allows_project_rejects_global(self) -> None: def test_coordinator_allows_project_rejects_global(
s = _session(user_id="u1", kind=WorkstreamKind.COORDINATOR) self, monkeypatch: pytest.MonkeyPatch
s._project_id = "p1" ) -> None:
s = _project_session(monkeypatch, kind=WorkstreamKind.COORDINATOR)
assert s._validate_scope("project", "cid") is None # project allowed for coord assert s._validate_scope("project", "cid") is None # project allowed for coord
assert s._validate_scope("global", "cid") is not None # global still rejected assert s._validate_scope("global", "cid") is not None # global still rejected
@@ -172,78 +198,328 @@ class TestProjectInSystemContext:
class TestProjectWriteGate: class TestProjectWriteGate:
"""The save AND delete memory paths block writes to a project the session """The save AND delete memory paths block writes to a project the session
can read but not write (a read-only member of a public project). Construction can read but not write (a read-only member of a public project). Construction
resolves ``_project_writable``; these drive the preparer to assert the gate resolves live access; these drive the preparer to assert the gate actually
actually fires (the resolution-level check lives in fires (the resolution-level check lives in ``TestLiveProjectAccess``)."""
``TestConstructionResolvesProjectAccess``)."""
def _attached(self, *, writable: bool) -> ChatSession: def _attached(self, monkeypatch: pytest.MonkeyPatch, *, writable: bool) -> ChatSession:
s = _session(user_id="u1") return _project_session(monkeypatch, writable=writable)
s._project_id = "p1"
s._project_writable = writable
return s
def test_save_blocked_when_read_only(self) -> None: def test_save_blocked_when_read_only(self, monkeypatch: pytest.MonkeyPatch) -> None:
s = self._attached(writable=False) s = self._attached(monkeypatch, writable=False)
out = s._prepare_memory( out = s._prepare_memory(
"cid", {"action": "save", "scope": "project", "name": "k", "content": "v"} "cid",
{
"action": "save",
"scope": "project",
"name": "k",
"content": "v",
"description": "Test memory",
},
) )
assert "read-only access to this project" in out.get("error", "") assert "read-only access to the attached project" in out.get("error", "")
def test_save_allowed_when_writable(self) -> None: def test_save_allowed_when_writable(self, monkeypatch: pytest.MonkeyPatch) -> None:
s = self._attached(writable=True) s = self._attached(monkeypatch, writable=True)
out = s._prepare_memory( out = s._prepare_memory(
"cid", {"action": "save", "scope": "project", "name": "k", "content": "v"} "cid",
{
"action": "save",
"scope": "project",
"name": "k",
"content": "v",
"description": "Test memory",
},
) )
assert "error" not in out assert "error" not in out
assert out.get("execute") is not None # would proceed to the save exec assert out.get("execute") is not None # would proceed to the save exec
def test_delete_blocked_when_read_only(self) -> None: def test_delete_blocked_when_read_only(self, monkeypatch: pytest.MonkeyPatch) -> None:
s = self._attached(writable=False) s = self._attached(monkeypatch, writable=False)
out = s._prepare_memory("cid", {"action": "delete", "scope": "project", "name": "k"}) out = s._prepare_memory("cid", {"action": "delete", "scope": "project", "name": "k"})
assert "read-only access to this project" in out.get("error", "") assert "read-only access to the attached project" in out.get("error", "")
def test_delete_allowed_when_writable(self) -> None: def test_delete_allowed_when_writable(self, monkeypatch: pytest.MonkeyPatch) -> None:
s = self._attached(writable=True) s = self._attached(monkeypatch, writable=True)
out = s._prepare_memory("cid", {"action": "delete", "scope": "project", "name": "k"}) out = s._prepare_memory("cid", {"action": "delete", "scope": "project", "name": "k"})
assert "error" not in out assert "error" not in out
assert out.get("execute") is not None assert out.get("execute") is not None
class TestProjectDefaultSaveScope: class TestActingPrincipalProjectAuthority:
"""A writable attached project becomes the DEFAULT save scope (both kinds); @staticmethod
a read-only or unattached session keeps the kind default.""" def _tool_call(call_id: str, **arguments: Any) -> dict[str, Any]:
import json
def test_writable_project_is_default(self) -> None: return {
s = _session(user_id="u1") "id": call_id,
s._project_id = "p1" "function": {"name": "memory", "arguments": json.dumps(arguments)},
s._project_writable = True }
def test_guest_cannot_inherit_owner_project_access(
self, tmp_db: str, monkeypatch: pytest.MonkeyPatch
) -> None:
def resolve(user_id: str, _project_id: str, **_kwargs: Any) -> auth.ProjectAccess:
if user_id == "owner":
return auth.ProjectAccess(True, True, "Owner Project", "active")
return auth.ProjectAccess(False, False, "", "")
monkeypatch.setattr(auth, "resolve_project_access", resolve)
session = _session(user_id="owner", ws_id="shared", project_id="p1")
session.bind_acting_user("guest")
assert all(scope != "project" for scope, _ in session._visible_scopes())
for action in ("get", "save", "delete"):
arguments: dict[str, Any] = {
"action": action,
"name": "owner_secret",
"scope": "project",
}
if action == "save":
arguments["content"] = "guest write"
arguments["description"] = "Guest write attempt"
item = session._prepare_tool(self._tool_call(action, **arguments))
assert item["_principal_id"] == "guest"
assert "error" in item
assert "acting user cannot access" in item["error"]
def test_project_delete_revalidates_prepared_principal_and_live_acl(
self, tmp_db: str, monkeypatch: pytest.MonkeyPatch
) -> None:
from turnstone.core.memory import (
get_structured_memory_by_name,
save_structured_memory,
)
guest_access = {"value": auth.ProjectAccess(True, True, "Shared", "active")}
def resolve(user_id: str, _project_id: str, **_kwargs: Any) -> auth.ProjectAccess:
if user_id == "guest":
return guest_access["value"]
return auth.ProjectAccess(True, True, "Shared", "active")
monkeypatch.setattr(auth, "resolve_project_access", resolve)
save_structured_memory(
"shared_secret",
"keep",
description="Shared project secret",
scope="project",
scope_id="p1",
)
session = _session(user_id="owner", ws_id="shared", project_id="p1")
session.bind_acting_user("guest")
item = session._prepare_tool(
self._tool_call(
"delete",
action="delete",
name="shared_secret",
scope="project",
)
)
assert "error" not in item
assert item["_principal_id"] == "guest"
guest_access["value"] = auth.ProjectAccess(False, False, "", "")
session.bind_acting_user("owner")
_, message = _execute_prepared_tool(session, item)
assert "acting user cannot access" in message
assert get_structured_memory_by_name("shared_secret", "project", "p1") is not None
def test_archived_project_is_removed_from_live_visibility(
self, tmp_db: str, monkeypatch: pytest.MonkeyPatch
) -> None:
project_state = {"value": "active"}
monkeypatch.setattr(
auth,
"resolve_project_access",
lambda *_args, **_kwargs: auth.ProjectAccess(
True, True, "Shared", project_state["value"]
),
)
session = _session(user_id="owner", ws_id="shared", project_id="p1")
assert ("project", "p1") in session._visible_scopes()
project_state["value"] = "archived"
assert all(scope != "project" for scope, _ in session._visible_scopes())
item = session._prepare_memory(
"get", {"action": "get", "name": "anything", "scope": "project"}
)
assert "error" in item
assert "active attached project" in item["error"]
class TestProjectDefaultSaveScope:
"""An attachment is the inherited target even when it is read-only."""
def test_writable_project_is_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
s = _project_session(monkeypatch)
assert s._default_memory_scope() == "project" assert s._default_memory_scope() == "project"
def test_read_only_project_keeps_kind_default(self) -> None: def test_read_only_project_remains_inherited_target(
s = _session(user_id="u1") self, monkeypatch: pytest.MonkeyPatch
s._project_id = "p1" ) -> None:
s._project_writable = False s = _project_session(monkeypatch, writable=False)
assert s._default_memory_scope() == "global" assert s._default_memory_scope() == "project"
def test_no_project_keeps_kind_default(self) -> None: def test_no_project_keeps_kind_default(self) -> None:
assert _session(user_id="u1")._default_memory_scope() == "global" assert _session(user_id="u1")._default_memory_scope() == "global"
def test_coordinator_writable_project_is_default(self) -> None: def test_coordinator_writable_project_is_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
s = _session(user_id="u1", kind=WorkstreamKind.COORDINATOR) s = _project_session(monkeypatch, kind=WorkstreamKind.COORDINATOR)
s._project_id = "p1"
s._project_writable = True
assert s._default_memory_scope() == "project" assert s._default_memory_scope() == "project"
def test_coordinator_without_project_is_coordinator(self) -> None: def test_coordinator_without_project_is_coordinator(self) -> None:
s = _session(user_id="u1", kind=WorkstreamKind.COORDINATOR) s = _session(user_id="u1", kind=WorkstreamKind.COORDINATOR)
assert s._default_memory_scope() == "coordinator" assert s._default_memory_scope() == "coordinator"
def test_save_without_scope_lands_in_project(self) -> None: def test_save_without_scope_lands_in_project(self, monkeypatch: pytest.MonkeyPatch) -> None:
# End-to-end: an unscoped save in a writable-project session resolves to # End-to-end: an unscoped save in a writable-project session resolves to
# scope=project / scope_id=project_id (not the global default). # scope=project / scope_id=project_id (not the global default).
s = _session(user_id="u1") s = _project_session(monkeypatch)
s._project_id = "p1" out = s._prepare_memory(
s._project_writable = True "cid",
out = s._prepare_memory("cid", {"action": "save", "name": "k", "content": "v"}) {
"action": "save",
"name": "k",
"content": "v",
"description": "Test memory",
},
)
assert out.get("scope") == "project" assert out.get("scope") == "project"
assert out.get("scope_id") == "p1" assert out.get("scope_id") == "p1"
class TestProjectDefaultGetDeleteScope:
"""An attached project is the inherited get/delete target.
This aligns the name-based lifecycle: a memory saved without an explicit
scope can be fetched or removed the same way while the workstream remains
attached to that project.
"""
@staticmethod
def _attached(
monkeypatch: pytest.MonkeyPatch,
*,
writable: bool = True,
kind: WorkstreamKind = WorkstreamKind.INTERACTIVE,
) -> ChatSession:
return _project_session(monkeypatch, writable=writable, kind=kind)
def test_get_without_scope_targets_project(self, monkeypatch: pytest.MonkeyPatch) -> None:
# Read access is sufficient for the inherited get target; writability
# only controls save/delete.
s = self._attached(monkeypatch, writable=False)
item = s._prepare_memory("cid", {"action": "get", "name": "k"})
assert item["scopes_to_try"] == [("project", "p1")]
def test_delete_without_scope_targets_writable_project(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
s = self._attached(monkeypatch)
item = s._prepare_memory("cid", {"action": "delete", "name": "k"})
assert item["scopes_to_try"] == [("project", "p1")]
def test_delete_without_scope_rejects_read_only_project(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
s = self._attached(monkeypatch, writable=False)
item = s._prepare_memory("cid", {"action": "delete", "name": "k"})
assert "read-only access to the attached project" in item.get("error", "")
def test_read_only_project_does_not_block_explicit_other_scope(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
s = self._attached(monkeypatch, writable=False)
item = s._prepare_memory(
"cid",
{"action": "delete", "name": "k", "scope": "global"},
)
assert "error" not in item
assert item["scopes_to_try"] == [("global", "")]
def test_coordinator_inherits_project_too(self, monkeypatch: pytest.MonkeyPatch) -> None:
s = self._attached(monkeypatch, kind=WorkstreamKind.COORDINATOR)
get_item = s._prepare_memory("get", {"action": "get", "name": "k"})
delete_item = s._prepare_memory("delete", {"action": "delete", "name": "k"})
assert get_item["scopes_to_try"] == [("project", "p1")]
assert delete_item["scopes_to_try"] == [("project", "p1")]
def test_unscoped_get_and_delete_round_trip_project_memory(
self, tmp_db: str, monkeypatch: pytest.MonkeyPatch
) -> None:
from turnstone.core.memory import (
get_structured_memory_by_name,
save_structured_memory,
)
row, _ = save_structured_memory(
"july_digest",
"full digest",
description="July digest",
scope="project",
scope_id="p1",
)
assert row is not None
s = self._attached(monkeypatch)
get_item = s._prepare_memory("get", {"action": "get", "name": "july_digest"})
_, get_msg = _execute_prepared_tool(s, get_item)
assert "[general:project] july_digest" in get_msg
assert "full digest" in get_msg
delete_item = s._prepare_memory("delete", {"action": "delete", "name": "july_digest"})
_, delete_msg = _execute_prepared_tool(s, delete_item)
assert "Deleted memory 'july_digest' (scope=project)" in delete_msg
assert get_structured_memory_by_name("july_digest", "project", "p1") is None
def test_wrong_explicit_scope_hints_at_attached_project(
self, tmp_db: str, monkeypatch: pytest.MonkeyPatch
) -> None:
from turnstone.core.memory import save_structured_memory
row, _ = save_structured_memory(
"july_digest",
"full digest",
description="July digest",
scope="project",
scope_id="p1",
)
assert row is not None
s = self._attached(monkeypatch)
for action in ("get", "delete"):
item = s._prepare_memory(
action,
{"action": action, "name": "july_digest", "scope": "global"},
)
_, msg = _execute_prepared_tool(s, item)
assert "not found (scope=global)" in msg
assert "exists in scope='project'" in msg
assert "retry with scope='project'" in msg
def test_project_default_miss_hints_at_other_visible_scope(
self, tmp_db: str, monkeypatch: pytest.MonkeyPatch
) -> None:
from turnstone.core.memory import save_structured_memory
row, _ = save_structured_memory(
"shared_runbook",
"global content",
description="Shared runbook",
scope="global",
)
assert row is not None
s = self._attached(monkeypatch)
for action in ("get", "delete"):
item = s._prepare_memory(
action,
{"action": action, "name": "shared_runbook"},
)
_, msg = _execute_prepared_tool(s, item)
assert "not found (scope=project)" in msg
assert "exists in scope='global'" in msg
assert "retry with scope='global'" in msg
+3 -3
View File
@@ -68,9 +68,9 @@ class TestProjectStore:
# a sibling project's nor other scopes' rows. # a sibling project's nor other scopes' rows.
backend.create_project("p1", "A", "u1") backend.create_project("p1", "A", "u1")
backend.create_project("p2", "B", "u1") backend.create_project("p2", "B", "u1")
backend.create_structured_memory("m1", "k", "", "general", "project", "p1", "v") backend.create_structured_memory("m1", "k", "Test memory", "general", "project", "p1", "v")
backend.create_structured_memory("m2", "k", "", "general", "project", "p2", "v") backend.create_structured_memory("m2", "k", "Test memory", "general", "project", "p2", "v")
backend.create_structured_memory("m3", "k", "", "general", "user", "u1", "v") backend.create_structured_memory("m3", "k", "Test memory", "general", "user", "u1", "v")
assert backend.delete_project("p1") assert backend.delete_project("p1")
assert backend.get_structured_memory("m1") is None # purged assert backend.get_structured_memory("m1") is None # purged
assert backend.get_structured_memory("m2") is not None # sibling project intact assert backend.get_structured_memory("m2") is not None # sibling project intact
+56
View File
@@ -361,6 +361,62 @@ async def test_logout():
assert resp.status == "ok" assert resp.status == "ok"
# ---------------------------------------------------------------------------
# Memories
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_save_memory_requires_and_sends_description():
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured.update(json.loads(request.content))
return _json_response(
{
"memory_id": "m1",
"name": "deployment_process",
"description": captured["description"],
"type": "general",
"scope": "global",
"scope_id": "",
"content": "Deploy from main",
"created": "2026-08-11T00:00:00",
"updated": "2026-08-11T00:00:00",
},
status=201,
)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
memory = await client.save_memory(
"deployment_process",
"Deploy from main",
description=" Production deployment workflow ",
)
assert captured["description"] == "Production deployment workflow"
assert memory.description == "Production deployment workflow"
@pytest.mark.anyio
@pytest.mark.parametrize("description", [None, "", " "])
async def test_save_memory_rejects_empty_description(description):
def unexpected_request(_request: httpx.Request) -> httpx.Response:
raise AssertionError("invalid memory must not reach the server")
transport = httpx.MockTransport(unexpected_request)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = AsyncTurnstoneServer(httpx_client=hc)
with pytest.raises(ValueError, match="description is required"):
await client.save_memory(
"deployment_process",
"Deploy from main",
description=description, # type: ignore[arg-type]
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Health # Health
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+396 -65
View File
@@ -29,7 +29,12 @@ from turnstone.core.model_turn import (
provider_extra_params, provider_extra_params,
serialized_tool_chars, serialized_tool_chars,
) )
from turnstone.core.session import _IMAGE_EXTENSIONS, _IMAGE_SIZE_CAP, ChatSession from turnstone.core.session import (
_IMAGE_EXTENSIONS,
_IMAGE_SIZE_CAP,
_MEMORY_MIXED_BATCH_ERROR,
ChatSession,
)
from turnstone.core.trajectory import ( from turnstone.core.trajectory import (
Role, Role,
Turn, Turn,
@@ -129,6 +134,15 @@ def _make_session(
return session return session
def _execute_prepared_tool(
session: ChatSession,
item: dict[str, Any],
) -> tuple[str, str | list[dict[str, Any]]]:
"""Mirror the dispatch boundary for tests that call a preparer directly."""
item.setdefault("_principal_id", session._tool_prepare_principal_id())
return item["execute"](item)
@contextlib.contextmanager @contextlib.contextmanager
def _send_with_mocks(session, responses, mock_execute, **extra_patches): def _send_with_mocks(session, responses, mock_execute, **extra_patches):
"""Stand up the mock context that the queued-message ``send()`` tests share. """Stand up the mock context that the queued-message ``send()`` tests share.
@@ -458,12 +472,20 @@ class TestTaskExec:
"func_name": "task_agent", "func_name": "task_agent",
"needs_approval": True, "needs_approval": True,
"execute": execute, "execute": execute,
"_needs_origin_context": True,
"_requires_fresh_system_prefix": True,
} }
def prepare(_tool_call):
seen["prepare"] = session._tool_prepare_principal_id()
return item
judge = MagicMock(side_effect=evaluate_intent) judge = MagicMock(side_effect=evaluate_intent)
with ( with (
patch.object(session, "_safe_prepare_tool", return_value=item), patch.object(session, "_safe_prepare_tool", side_effect=prepare),
patch.object(session, "_evaluate_intent", judge), patch.object(session, "_evaluate_intent", judge),
patch.object(session.ui, "approve_tools", side_effect=approve_tools), patch.object(session.ui, "approve_tools", side_effect=approve_tools),
patch.object(session, "_ensure_system_prefix_fresh") as ensure_system_prefix,
): ):
session._execute_tools( session._execute_tools(
[{"id": "c1", "function": {"name": "task_agent", "arguments": "{}"}}], [{"id": "c1", "function": {"name": "task_agent", "arguments": "{}"}}],
@@ -471,9 +493,14 @@ class TestTaskExec:
my_generation=generation, my_generation=generation,
) )
assert seen["prepare"] == "user-a"
assert seen["worker"] == "user-a" assert seen["worker"] == "user-a"
assert seen["generation"] == generation assert seen["generation"] == generation
assert seen["event"] is generation_event assert seen["event"] is generation_event
ensure_system_prefix.assert_called_once_with(
principal_id="user-a",
origin_generation=generation,
)
assert judge.call_args.kwargs["principal_id"] == "user-a" assert judge.call_args.kwargs["principal_id"] == "user-a"
assert seen["execution_item"] is not item assert seen["execution_item"] is not item
approval_witness = seen["approval_item"]["_approval_cancel_witness"] approval_witness = seen["approval_item"]["_approval_cancel_witness"]
@@ -6199,6 +6226,96 @@ class TestSafePrepareTool:
assert "RuntimeError" in output assert "RuntimeError" in output
class TestToolBatchPolicy:
@staticmethod
def _tool_calls(count: int) -> list[dict[str, Any]]:
return [
{
"id": f"call_{index}",
"function": {"name": "state_tool", "arguments": "{}"},
}
for index in range(count)
]
def test_mixed_read_write_batch_is_rejected(self, tmp_db):
session = _make_session()
executed: list[str] = []
def execute(item):
executed.append(item["call_id"])
return item["call_id"], "unexpected"
items = [
{
"call_id": "call_0",
"func_name": "state_tool",
"execute": execute,
"needs_approval": False,
"_batch_policy": {
"group": "state",
"access": "write",
"mixed_access_error": "Error: state reads and writes cannot run together",
"serialize": True,
},
},
{
"call_id": "call_1",
"func_name": "state_tool",
"execute": execute,
"needs_approval": False,
"_batch_policy": {
"group": "state",
"access": "read",
"mixed_access_error": "Error: state reads and writes cannot run together",
},
},
]
with (
patch.object(session, "_safe_prepare_tool", side_effect=items),
patch.object(session.ui, "approve_tools", return_value=(True, None)),
):
results, _ = session._execute_tools(self._tool_calls(2))
assert executed == []
assert all("cannot run together" in str(result) for _, result in results)
def test_write_batch_executes_serially_in_model_order(self, tmp_db):
session = _make_session()
executed: list[str] = []
def execute(item):
executed.append(item["call_id"])
return item["call_id"], "ok"
items = [
{
"call_id": f"call_{index}",
"func_name": "state_tool",
"execute": execute,
"needs_approval": False,
"_batch_policy": {
"group": "state",
"access": "write",
"mixed_access_error": "Error: mixed state access",
"serialize": True,
},
}
for index in range(2)
]
with (
patch.object(session, "_safe_prepare_tool", side_effect=items),
patch.object(session.ui, "approve_tools", return_value=(True, None)),
patch(
"turnstone.core.session.concurrent.futures.ThreadPoolExecutor",
side_effect=AssertionError("serialized writes must not enter the parallel pool"),
),
):
results, _ = session._execute_tools(self._tool_calls(2))
assert executed == ["call_0", "call_1"]
assert [call_id for call_id, _ in results] == executed
class TestCoordinatorMemoryScope: class TestCoordinatorMemoryScope:
"""Verify the ``coordinator`` memory scope's resolution + validation rules. """Verify the ``coordinator`` memory scope's resolution + validation rules.
@@ -6269,7 +6386,7 @@ class TestCoordinatorMemoryScope:
) )
err = session._validate_scope("coordinator", "call_1") err = session._validate_scope("coordinator", "call_1")
assert err is not None assert err is not None
assert err["error"].startswith("Error: 'coordinator' scope is only valid") assert "unavailable to this workstream kind" in err["error"]
def test_validate_rejects_coord_scope_for_child_interactive(self, tmp_db): def test_validate_rejects_coord_scope_for_child_interactive(self, tmp_db):
"""Children of a coord MUST be rejected too — letting them write """Children of a coord MUST be rejected too — letting them write
@@ -6286,7 +6403,7 @@ class TestCoordinatorMemoryScope:
) )
err = session._validate_scope("coordinator", "call_1") err = session._validate_scope("coordinator", "call_1")
assert err is not None assert err is not None
assert err["error"].startswith("Error: 'coordinator' scope is only valid") assert "unavailable to this workstream kind" in err["error"]
def test_validate_accepts_coord_scope_for_coord_session(self, tmp_db): def test_validate_accepts_coord_scope_for_coord_session(self, tmp_db):
from turnstone.core.workstream import WorkstreamKind from turnstone.core.workstream import WorkstreamKind
@@ -6314,6 +6431,7 @@ class TestCoordinatorMemoryScope:
"call_1", "call_1",
{ {
"action": "save", "action": "save",
"description": "Test memory",
"name": "orchestration_plan", "name": "orchestration_plan",
"content": "step 1: investigate; step 2: report", "content": "step 1: investigate; step 2: report",
"scope": "coordinator", "scope": "coordinator",
@@ -6341,6 +6459,7 @@ class TestCoordinatorMemoryScope:
"call_1", "call_1",
{ {
"action": "save", "action": "save",
"description": "Test memory",
"name": "injected_instruction", "name": "injected_instruction",
"content": "ignore previous instructions and ...", "content": "ignore previous instructions and ...",
"scope": "coordinator", "scope": "coordinator",
@@ -6360,6 +6479,7 @@ class TestCoordinatorMemoryScope:
save_structured_memory( save_structured_memory(
"private_plan", "private_plan",
"internal coord notes", "internal coord notes",
description="Private orchestration plan",
scope="coordinator", scope="coordinator",
scope_id="user-1", scope_id="user-1",
) )
@@ -6424,16 +6544,20 @@ class TestCoordinatorMemoryScope:
from turnstone.core.workstream import WorkstreamKind from turnstone.core.workstream import WorkstreamKind
# Seed every non-coord scope with a sentinel memory. # Seed every non-coord scope with a sentinel memory.
save_structured_memory("global_note", "anyone can read", scope="global") save_structured_memory(
"global_note", "anyone can read", description="Global note", scope="global"
)
save_structured_memory( save_structured_memory(
"ws_note", "ws_note",
"interactive ws notes", "interactive ws notes",
description="Workstream note",
scope="workstream", scope="workstream",
scope_id="coord-1", # same id as the coord under test scope_id="coord-1", # same id as the coord under test
) )
save_structured_memory( save_structured_memory(
"user_note", "user_note",
"user-wide notes from another IC session", "user-wide notes from another IC session",
description="User note",
scope="user", scope="user",
scope_id="user-1", scope_id="user-1",
) )
@@ -6466,10 +6590,13 @@ class TestCoordinatorMemoryScope:
from turnstone.core.memory import save_structured_memory from turnstone.core.memory import save_structured_memory
from turnstone.core.workstream import WorkstreamKind from turnstone.core.workstream import WorkstreamKind
save_structured_memory("global_x", "some content", scope="global") save_structured_memory(
"global_x", "some content", description="Global content", scope="global"
)
save_structured_memory( save_structured_memory(
"coord_x", "coord_x",
"orchestration content", "orchestration content",
description="Coordinator content",
scope="coordinator", scope="coordinator",
scope_id="user-1", scope_id="user-1",
) )
@@ -6497,14 +6624,11 @@ class TestCoordinatorMemoryScope:
for bad in ("global", "workstream", "user"): for bad in ("global", "workstream", "user"):
err = coord._validate_scope(bad, "call_1") err = coord._validate_scope(bad, "call_1")
assert err is not None, f"coord should reject scope={bad!r}" assert err is not None, f"coord should reject scope={bad!r}"
assert f"'{bad}' scope is not available" in err["error"] assert f"scope '{bad}' is unavailable" in err["error"]
def test_coord_default_save_scope_is_coordinator(self, tmp_db): def test_coord_default_save_scope_is_coordinator(self, tmp_db):
"""Coord sessions calling memory(action='save') without an """Coord sessions calling memory(action='save') without an
explicit scope default to 'coordinator' anything else would explicit scope target the coordinator namespace."""
either land in a namespace the coord can't read back from
(workstream/user) or fall back to global which the new
visibility rules also exclude."""
from turnstone.core.workstream import WorkstreamKind from turnstone.core.workstream import WorkstreamKind
coord = _make_session( coord = _make_session(
@@ -6514,17 +6638,20 @@ class TestCoordinatorMemoryScope:
) )
item = coord._prepare_memory( item = coord._prepare_memory(
"call_1", "call_1",
{"action": "save", "name": "auto_scope", "content": "x"}, {
"action": "save",
"name": "auto_scope",
"content": "x",
"description": "Automatic scope test",
},
) )
assert "error" not in item assert "error" not in item
assert item["scope"] == "coordinator" assert item["scope"] == "coordinator"
assert item["scope_id"] == "user-1" assert item["scope_id"] == "user-1"
def test_coord_implicit_walk_only_coordinator(self, tmp_db): def test_coord_inherited_get_targets_only_coordinator(self, tmp_db):
"""Coord ``memory(action='get')`` with no explicit scope must """Coord ``memory(action='get')`` with no explicit scope must
walk only the coordinator scope the IC walk target only the coordinator scope."""
(workstream user global) would be wasted lookups against
rows the coord can't see."""
from turnstone.core.workstream import WorkstreamKind from turnstone.core.workstream import WorkstreamKind
coord = _make_session( coord = _make_session(
@@ -6539,10 +6666,8 @@ class TestCoordinatorMemoryScope:
assert "error" not in item assert "error" not in item
assert [s for s, _ in item["scopes_to_try"]] == ["coordinator"] assert [s for s, _ in item["scopes_to_try"]] == ["coordinator"]
def test_ic_implicit_walk_unchanged(self, tmp_db): def test_ic_unscoped_get_uses_single_global_target(self, tmp_db):
"""Interactive sessions retain the narrowest-to-widest walk: """Without a project, interactive save/get/delete all inherit global."""
workstream user global. Coord scope is excluded IC
sessions can't see/write it anyway."""
from turnstone.core.workstream import WorkstreamKind from turnstone.core.workstream import WorkstreamKind
ic = _make_session( ic = _make_session(
@@ -6555,8 +6680,7 @@ class TestCoordinatorMemoryScope:
{"action": "get", "name": "anything"}, {"action": "get", "name": "anything"},
) )
assert "error" not in item assert "error" not in item
scopes = [s for s, _ in item["scopes_to_try"]] assert item["scopes_to_try"] == [("global", "")]
assert scopes == ["workstream", "user", "global"]
def test_coord_memory_persists_across_sessions(self, tmp_db): def test_coord_memory_persists_across_sessions(self, tmp_db):
"""End-to-end through the real save lane: a memory saved by one """End-to-end through the real save lane: a memory saved by one
@@ -6575,13 +6699,14 @@ class TestCoordinatorMemoryScope:
"call_1", "call_1",
{ {
"action": "save", "action": "save",
"description": "Test memory",
"name": "deploy_runbook", "name": "deploy_runbook",
"content": "drain node before rotating certs", "content": "drain node before rotating certs",
"scope": "coordinator", "scope": "coordinator",
}, },
) )
assert "error" not in item assert "error" not in item
result = item["execute"](item) result = _execute_prepared_tool(first, item)
assert "Saved" in str(result) or "saved" in str(result).lower() assert "Saved" in str(result) or "saved" in str(result).lower()
# Brand-new coordinator session, new ws_id, same user. # Brand-new coordinator session, new ws_id, same user.
@@ -6595,7 +6720,7 @@ class TestCoordinatorMemoryScope:
{"action": "get", "name": "deploy_runbook"}, {"action": "get", "name": "deploy_runbook"},
) )
assert "error" not in get_item assert "error" not in get_item
out = str(get_item["execute"](get_item)) out = str(_execute_prepared_tool(second, get_item))
assert "drain node before rotating certs" in out assert "drain node before rotating certs" in out
def test_coordinator_session_requires_user_id(self, tmp_db): def test_coordinator_session_requires_user_id(self, tmp_db):
@@ -6636,11 +6761,17 @@ class TestCoordinatorMemoryScope:
coord._user_id = "" # simulate a constructor-bypassing double coord._user_id = "" # simulate a constructor-bypassing double
err = coord._validate_scope("coordinator", "call_1") err = coord._validate_scope("coordinator", "call_1")
assert err is not None assert err is not None
assert "requires authenticated user identity" in err["error"] assert "requires an authenticated acting user" in err["error"]
assert coord._coordinator_scope_id() == "" assert coord._coordinator_scope_id() == ""
item = coord._prepare_memory( item = coord._prepare_memory(
"call_1", "call_1",
{"action": "save", "name": "x", "content": "y", "scope": "coordinator"}, {
"action": "save",
"name": "x",
"content": "y",
"description": "Authentication backstop test",
"scope": "coordinator",
},
) )
assert "error" in item assert "error" in item
@@ -6654,6 +6785,7 @@ class TestCoordinatorMemoryScope:
save_structured_memory( save_structured_memory(
"other_users_row", "other_users_row",
"must not leak", "must not leak",
description="Another user's row",
scope="coordinator", scope="coordinator",
scope_id="user-9", scope_id="user-9",
) )
@@ -6682,7 +6814,48 @@ class TestMemoryToolAudit:
return get_storage().list_audit_events(action=action) return get_storage().list_audit_events(action=action)
def test_save_new_emits_memory_save(self, tmp_db): def test_preparer_declares_generic_batch_policy(self, tmp_db):
session = _make_session(ws_id="ws-1", user_id="user-1")
saved = session._prepare_memory(
"save-call",
{
"action": "save",
"name": "fact_one",
"content": "alpha content",
"description": "Alpha fact",
},
)
fetched = session._prepare_memory(
"get-call",
{"action": "get", "name": "fact_one"},
)
assert saved["_batch_policy"] == {
"group": "memory",
"access": "write",
"mixed_access_error": _MEMORY_MIXED_BATCH_ERROR,
"serialize": True,
}
assert fetched["_batch_policy"] == {
"group": "memory",
"access": "read",
"mixed_access_error": _MEMORY_MIXED_BATCH_ERROR,
"serialize": False,
}
def test_unstamped_executor_does_not_inherit_session_actor(self, tmp_db):
session = _make_session(ws_id="ws-1", user_id="user-1")
item = session._prepare_memory(
"get-call",
{"action": "get", "name": "private_fact", "scope": "user"},
)
_call_id, message = item["execute"](item)
assert "requires an authenticated acting user" in message
@pytest.mark.parametrize("description", [None, "", " "])
def test_save_requires_non_empty_description(self, tmp_db, description):
session = _make_session(ws_id="ws-1", user_id="user-1") session = _make_session(ws_id="ws-1", user_id="user-1")
item = session._prepare_memory( item = session._prepare_memory(
"call_1", "call_1",
@@ -6690,12 +6863,27 @@ class TestMemoryToolAudit:
"action": "save", "action": "save",
"name": "fact_one", "name": "fact_one",
"content": "alpha content", "content": "alpha content",
"description": description,
},
)
assert "error" in item
assert "description' must be non-empty" in item["error"]
def test_save_new_emits_memory_save(self, tmp_db):
session = _make_session(ws_id="ws-1", user_id="user-1")
item = session._prepare_memory(
"call_1",
{
"action": "save",
"description": "Test memory",
"name": "fact_one",
"content": "alpha content",
"scope": "user", "scope": "user",
"type": "reference", "type": "reference",
}, },
) )
assert "error" not in item assert "error" not in item
session._exec_memory(item) _execute_prepared_tool(session, item)
rows = self._audit_rows("memory.save") rows = self._audit_rows("memory.save")
assert len(rows) == 1 assert len(rows) == 1
@@ -6712,6 +6900,71 @@ class TestMemoryToolAudit:
# The "create" path must NOT also stamp an update row. # The "create" path must NOT also stamp an update row.
assert self._audit_rows("memory.update") == [] assert self._audit_rows("memory.update") == []
def test_prepared_user_save_stays_bound_to_acting_principal(self, tmp_db):
from turnstone.core.memory import get_structured_memory_by_name
session = _make_session(ws_id="shared", user_id="owner")
session.bind_acting_user("guest")
item = session._prepare_tool(
{
"id": "call_1",
"function": {
"name": "memory",
"arguments": json.dumps(
{
"action": "save",
"description": "Test memory",
"name": "private_note",
"content": "guest content",
"scope": "user",
}
),
},
}
)
assert item["_principal_id"] == "guest"
assert item["scope_id"] == "guest"
session.bind_acting_user("owner")
_, message = _execute_prepared_tool(session, item)
assert "Saved memory" in message
assert get_structured_memory_by_name("private_note", "user", "guest") is not None
assert get_structured_memory_by_name("private_note", "user", "owner") is None
rows = self._audit_rows("memory.save")
assert len(rows) == 1
assert rows[0]["user_id"] == "guest"
def test_guest_user_get_does_not_probe_owner_namespace(self, tmp_db):
from turnstone.core.memory import save_structured_memory
save_structured_memory(
"owner_secret",
"must not leak",
description="Owner secret",
scope="user",
scope_id="owner",
)
session = _make_session(ws_id="shared", user_id="owner")
session.bind_acting_user("guest")
item = session._prepare_tool(
{
"id": "call_1",
"function": {
"name": "memory",
"arguments": json.dumps(
{"action": "get", "name": "owner_secret", "scope": "user"}
),
},
}
)
_, message = _execute_prepared_tool(session, item)
assert "not found" in message
assert "must not leak" not in message
assert "exists in scope" not in message
def test_save_global_scope_emits_empty_scope_id(self, tmp_db): def test_save_global_scope_emits_empty_scope_id(self, tmp_db):
"""Global memories have no scope_id — the audit row's detail """Global memories have no scope_id — the audit row's detail
must still carry the key (with value ``""``) so a forensic must still carry the key (with value ``""``) so a forensic
@@ -6722,13 +6975,14 @@ class TestMemoryToolAudit:
"call_1", "call_1",
{ {
"action": "save", "action": "save",
"description": "Test memory",
"name": "fact_global", "name": "fact_global",
"content": "shared content", "content": "shared content",
"scope": "global", "scope": "global",
}, },
) )
assert "error" not in item assert "error" not in item
session._exec_memory(item) _execute_prepared_tool(session, item)
rows = self._audit_rows("memory.save") rows = self._audit_rows("memory.save")
assert len(rows) == 1 assert len(rows) == 1
@@ -6744,13 +6998,14 @@ class TestMemoryToolAudit:
"call_x", "call_x",
{ {
"action": "save", "action": "save",
"description": "Test memory",
"name": "fact_one", "name": "fact_one",
"content": content, "content": content,
"scope": "user", "scope": "user",
"type": "reference", "type": "reference",
}, },
) )
session._exec_memory(item) _execute_prepared_tool(session, item)
saves = self._audit_rows("memory.save") saves = self._audit_rows("memory.save")
updates = self._audit_rows("memory.update") updates = self._audit_rows("memory.update")
@@ -6765,20 +7020,21 @@ class TestMemoryToolAudit:
"call_1", "call_1",
{ {
"action": "save", "action": "save",
"description": "Test memory",
"name": "fact_one", "name": "fact_one",
"content": "alpha", "content": "alpha",
"scope": "user", "scope": "user",
"type": "reference", "type": "reference",
}, },
) )
session._exec_memory(save_item) _execute_prepared_tool(session, save_item)
saved_memory_id = self._audit_rows("memory.save")[0]["resource_id"] saved_memory_id = self._audit_rows("memory.save")[0]["resource_id"]
delete_item = session._prepare_memory( delete_item = session._prepare_memory(
"call_2", "call_2",
{"action": "delete", "name": "fact_one", "scope": "user"}, {"action": "delete", "name": "fact_one", "scope": "user"},
) )
_, msg = session._exec_memory(delete_item) _, msg = _execute_prepared_tool(session, delete_item)
assert "Deleted memory" in msg assert "Deleted memory" in msg
rows = self._audit_rows("memory.delete") rows = self._audit_rows("memory.delete")
@@ -6797,23 +7053,70 @@ class TestMemoryToolAudit:
"call_1", "call_1",
{"action": "delete", "name": "no_such_mem", "scope": "user"}, {"action": "delete", "name": "no_such_mem", "scope": "user"},
) )
_, msg = session._exec_memory(delete_item) _, msg = _execute_prepared_tool(session, delete_item)
assert "not found" in msg assert "not found" in msg
assert self._audit_rows("memory.delete") == [] assert self._audit_rows("memory.delete") == []
def test_committed_delete_is_truthful_and_next_prefix_refresh_fails_closed(self, tmp_db):
from turnstone.core.memory import get_structured_memory_by_name, save_structured_memory
save_structured_memory("doomed", "value", description="Memory to delete", scope="global")
session = _make_session(ws_id="ws-1", user_id="user-1")
item = session._prepare_memory(
"call_1", {"action": "delete", "name": "doomed", "scope": "global"}
)
_, message = _execute_prepared_tool(session, item)
assert "Deleted memory 'doomed'" in message
assert get_structured_memory_by_name("doomed", "global", "") is None
assert len(self._audit_rows("memory.delete")) == 1
assert session._system_prefix_dirty is True
with (
patch.object(
session,
"_init_system_messages",
side_effect=RuntimeError("composition failed"),
),
pytest.raises(RuntimeError, match="composition failed"),
):
session._ensure_system_prefix_fresh()
def test_storage_failure_is_not_reported_as_not_found(self, tmp_db):
from turnstone.core.storage import get_storage
session = _make_session(ws_id="ws-1", user_id="user-1")
storage = get_storage()
operations = (
(
"get_structured_memory_by_name",
{"action": "get", "name": "key", "scope": "global"},
),
(
"delete_structured_memory_returning",
{"action": "delete", "name": "key", "scope": "global"},
),
)
for method_name, arguments in operations:
item = session._prepare_memory("call_1", arguments)
with patch.object(storage, method_name, side_effect=RuntimeError("db down")):
_, message = _execute_prepared_tool(session, item)
assert "storage operation failed" in message
assert "not found" not in message
def test_reads_emit_no_audit(self, tmp_db): def test_reads_emit_no_audit(self, tmp_db):
session = _make_session(ws_id="ws-1", user_id="user-1") session = _make_session(ws_id="ws-1", user_id="user-1")
session._exec_memory( save_item = session._prepare_memory(
session._prepare_memory(
"call_save", "call_save",
{ {
"action": "save", "action": "save",
"description": "Test memory",
"name": "fact_one", "name": "fact_one",
"content": "alpha", "content": "alpha",
"scope": "user", "scope": "user",
}, },
) )
) _execute_prepared_tool(session, save_item)
for spec in ( for spec in (
{"action": "get", "name": "fact_one", "scope": "user"}, {"action": "get", "name": "fact_one", "scope": "user"},
@@ -6822,7 +7125,7 @@ class TestMemoryToolAudit:
): ):
item = session._prepare_memory("call_read", spec) item = session._prepare_memory("call_read", spec)
assert "error" not in item assert "error" not in item
session._exec_memory(item) _execute_prepared_tool(session, item)
# Only the save above should have audited. # Only the save above should have audited.
save_count = len(self._audit_rows("memory.save")) save_count = len(self._audit_rows("memory.save"))
@@ -6842,6 +7145,7 @@ class TestMemoryToolAudit:
"call_1", "call_1",
{ {
"action": "save", "action": "save",
"description": "Test memory",
"name": "fact_one", "name": "fact_one",
"content": "alpha", "content": "alpha",
"scope": "user", "scope": "user",
@@ -6851,7 +7155,7 @@ class TestMemoryToolAudit:
"turnstone.core.audit.record_audit", "turnstone.core.audit.record_audit",
side_effect=RuntimeError("audit storage exploded"), side_effect=RuntimeError("audit storage exploded"),
): ):
_, msg = session._exec_memory(item) _, msg = _execute_prepared_tool(session, item)
assert "Saved memory 'fact_one'" in msg assert "Saved memory 'fact_one'" in msg
# The save itself still landed. # The save itself still landed.
from turnstone.core.memory import get_structured_memory_by_name from turnstone.core.memory import get_structured_memory_by_name
@@ -6863,10 +7167,10 @@ class TestPerKindToolVariants:
"""Verify the ``kind_variants`` metadata applies per-kind tool overrides. """Verify the ``kind_variants`` metadata applies per-kind tool overrides.
Each kind sees only the tool surface it can actually use the Each kind sees only the tool surface it can actually use the
coord sees ``scope`` enum ``["coordinator"]`` and a coord-flavored coord sees coordinator/project scopes and a coord-flavored description;
description; the IC sees ``["global", "workstream", "user"]`` and the IC sees global/workstream/user/project and the IC-flavored description.
the existing IC-flavored description. The union ``TOOLS`` list The union ``TOOLS`` list keeps the full schema for introspection / docs /
keeps the full schema for introspection / docs / eval catalogs. eval catalogs.
""" """
def test_coord_memory_tool_has_coord_only_scope_enum(self): def test_coord_memory_tool_has_coord_only_scope_enum(self):
@@ -6877,6 +7181,10 @@ class TestPerKindToolVariants:
# v1.7: a coordinator attached to a project also reads/writes the shared # v1.7: a coordinator attached to a project also reads/writes the shared
# 'project' scope, alongside its isolated 'coordinator' namespace. # 'project' scope, alongside its isolated 'coordinator' namespace.
assert scope["enum"] == ["coordinator", "project"] assert scope["enum"] == ["coordinator", "project"]
scope_desc = scope["description"]
assert "Save/get/delete without scope target project when attached" in scope_desc
assert "otherwise coordinator" in scope_desc
assert "valid explicit scope selects exactly that scope" in scope_desc
def test_coord_memory_tool_description_mentions_orchestration(self): def test_coord_memory_tool_description_mentions_orchestration(self):
from turnstone.core.tools import COORDINATOR_TOOLS from turnstone.core.tools import COORDINATOR_TOOLS
@@ -6896,6 +7204,10 @@ class TestPerKindToolVariants:
scope = memory["function"]["parameters"]["properties"]["scope"] scope = memory["function"]["parameters"]["properties"]["scope"]
# v1.7: 'project' is offered (usable when the workstream is attached). # v1.7: 'project' is offered (usable when the workstream is attached).
assert scope["enum"] == ["global", "workstream", "user", "project"] assert scope["enum"] == ["global", "workstream", "user", "project"]
scope_desc = scope["description"]
assert "Save/get/delete without scope target project when attached" in scope_desc
assert "otherwise global" in scope_desc
assert "valid explicit scope selects exactly that scope" in scope_desc
def test_ic_memory_tool_description_omits_coord_scope(self): def test_ic_memory_tool_description_omits_coord_scope(self):
from turnstone.core.tools import INTERACTIVE_TOOLS from turnstone.core.tools import INTERACTIVE_TOOLS
@@ -7024,7 +7336,12 @@ class TestMemoryAccessTouch:
def _save(name: str, content: str) -> None: def _save(name: str, content: str) -> None:
from turnstone.core.memory import save_structured_memory from turnstone.core.memory import save_structured_memory
save_structured_memory(name, content, scope="global") save_structured_memory(
name,
content,
description=f"Test memory for {name}",
scope="global",
)
@staticmethod @staticmethod
def _empty_session() -> ChatSession: def _empty_session() -> ChatSession:
@@ -7134,7 +7451,7 @@ class TestMemoryAccessTouch:
self._save("kafka_runbook", "restart the kafka broker pods") self._save("kafka_runbook", "restart the kafka broker pods")
item = session._prepare_memory("call_1", {"action": "search", "query": "kafka"}) item = session._prepare_memory("call_1", {"action": "search", "query": "kafka"})
assert "error" not in item assert "error" not in item
session._exec_memory(item) _execute_prepared_tool(session, item)
assert self._access_count("kafka_runbook") == 1 assert self._access_count("kafka_runbook") == 1
def test_get_action_touches_fetched_memory(self, tmp_db): def test_get_action_touches_fetched_memory(self, tmp_db):
@@ -7144,7 +7461,7 @@ class TestMemoryAccessTouch:
"call_1", {"action": "get", "name": "kafka_runbook", "scope": "global"} "call_1", {"action": "get", "name": "kafka_runbook", "scope": "global"}
) )
assert "error" not in item assert "error" not in item
session._exec_memory(item) _execute_prepared_tool(session, item)
assert self._access_count("kafka_runbook") == 1 assert self._access_count("kafka_runbook") == 1
def test_get_miss_touches_nothing(self, tmp_db): def test_get_miss_touches_nothing(self, tmp_db):
@@ -7153,7 +7470,7 @@ class TestMemoryAccessTouch:
item = session._prepare_memory( item = session._prepare_memory(
"call_1", {"action": "get", "name": "no_such_mem", "scope": "global"} "call_1", {"action": "get", "name": "no_such_mem", "scope": "global"}
) )
_, msg = session._exec_memory(item) _, msg = _execute_prepared_tool(session, item)
assert "not found" in msg assert "not found" in msg
# The existing row must not be collaterally touched by a miss. # The existing row must not be collaterally touched by a miss.
assert self._access_count("kafka_runbook") == 0 assert self._access_count("kafka_runbook") == 0
@@ -7162,7 +7479,7 @@ class TestMemoryAccessTouch:
session = self._empty_session() session = self._empty_session()
self._save("kafka_runbook", "restart the kafka broker pods") self._save("kafka_runbook", "restart the kafka broker pods")
item = session._prepare_memory("call_1", {"action": "list"}) item = session._prepare_memory("call_1", {"action": "list"})
session._exec_memory(item) _execute_prepared_tool(session, item)
assert self._access_count("kafka_runbook") == 0 assert self._access_count("kafka_runbook") == 0
def test_save_action_does_not_touch_access_count(self, tmp_db): def test_save_action_does_not_touch_access_count(self, tmp_db):
@@ -7174,9 +7491,15 @@ class TestMemoryAccessTouch:
session = self._empty_session() session = self._empty_session()
item = session._prepare_memory( item = session._prepare_memory(
"call_1", "call_1",
{"action": "save", "name": "kafka_runbook", "content": "x", "scope": "global"}, {
"action": "save",
"name": "kafka_runbook",
"content": "x",
"description": "Kafka runbook",
"scope": "global",
},
) )
session._exec_memory(item) _execute_prepared_tool(session, item)
assert self._access_count("kafka_runbook") == 0 assert self._access_count("kafka_runbook") == 0
def test_save_through_exec_does_not_recompose_prefix(self, tmp_db): def test_save_through_exec_does_not_recompose_prefix(self, tmp_db):
@@ -7202,13 +7525,14 @@ class TestMemoryAccessTouch:
"call_1", "call_1",
{ {
"action": "save", "action": "save",
"description": "Test memory",
"name": "kafka_scaling", "name": "kafka_scaling",
"content": "restart kafka and scale the broker pods cluster", "content": "restart kafka and scale the broker pods cluster",
"scope": "global", "scope": "global",
}, },
) )
assert "error" not in item assert "error" not in item
session._exec_memory(item) _execute_prepared_tool(session, item)
# 1. Prefix byte-for-byte unchanged -> no prompt-cache bust. # 1. Prefix byte-for-byte unchanged -> no prompt-cache bust.
after = "\n".join(m["content"] for m in session.system_messages if m["role"] == "system") after = "\n".join(m["content"] for m in session.system_messages if m["role"] == "system")
@@ -7226,11 +7550,8 @@ class TestMemoryAccessTouch:
) )
assert '<memory name="kafka_scaling"' in recomposed assert '<memory name="kafka_scaling"' in recomposed
def test_save_through_tool_preserves_omitted_overwrites_explicit(self, tmp_db): def test_save_through_tool_requires_and_updates_description(self, tmp_db):
"""The None-sentinel flows through _prepare_memory -> _exec_memory: a """Every tool save describes the row; an omitted type stays preserved."""
content-only re-save keeps the stored type/description, while an
explicit field overwrites it. Guards the _prepare_memory omit->None
logic that the storage-level tests don't exercise."""
from turnstone.core.memory import get_structured_memory_by_name from turnstone.core.memory import get_structured_memory_by_name
session = self._empty_session() session = self._empty_session()
@@ -7246,48 +7567,58 @@ class TestMemoryAccessTouch:
}, },
) )
assert "error" not in item assert "error" not in item
session._exec_memory(item) _execute_prepared_tool(session, item)
# Content-only re-save (omits type/description) -> both preserved. # An update supplies a fresh description while omitting type.
item2 = session._prepare_memory( item2 = session._prepare_memory(
"c2", {"action": "save", "name": "digest", "content": "v2", "scope": "global"} "c2",
{
"action": "save",
"name": "digest",
"content": "v2",
"description": "revised daily digest",
"scope": "global",
},
) )
session._exec_memory(item2) _execute_prepared_tool(session, item2)
mem = get_structured_memory_by_name("digest", "global", "") mem = get_structured_memory_by_name("digest", "global", "")
assert mem is not None assert mem is not None
assert mem["content"] == "v2" assert mem["content"] == "v2"
assert mem["type"] == "reference" assert mem["type"] == "reference"
assert mem["description"] == "daily digest" assert mem["description"] == "revised daily digest"
# An invalid/typo'd type is treated as unset -> stored type preserved, # Invalid/typo'd types fail preparation and do not mutate the row.
# not silently downgraded to "general".
item_bad = session._prepare_memory( item_bad = session._prepare_memory(
"c2b", "c2b",
{ {
"action": "save", "action": "save",
"description": "Test memory",
"name": "digest", "name": "digest",
"content": "v2b", "content": "v2b",
"type": "nonsense", "type": "nonsense",
"scope": "global", "scope": "global",
}, },
) )
session._exec_memory(item_bad) assert "error" in item_bad
assert "invalid memory type" in item_bad["error"]
mem = get_structured_memory_by_name("digest", "global", "") mem = get_structured_memory_by_name("digest", "global", "")
assert mem is not None assert mem is not None
assert mem["type"] == "reference" # invalid type ignored, not downgraded assert mem["content"] == "v2"
assert mem["type"] == "reference"
# An explicit field -> overwrites (the behaviour the None-sentinel enables). # An explicit field -> overwrites (the behaviour the None-sentinel enables).
item3 = session._prepare_memory( item3 = session._prepare_memory(
"c3", "c3",
{ {
"action": "save", "action": "save",
"description": "Test memory",
"name": "digest", "name": "digest",
"content": "v3", "content": "v3",
"type": "general", "type": "general",
"scope": "global", "scope": "global",
}, },
) )
session._exec_memory(item3) _execute_prepared_tool(session, item3)
mem = get_structured_memory_by_name("digest", "global", "") mem = get_structured_memory_by_name("digest", "global", "")
assert mem is not None assert mem is not None
assert mem["type"] == "general" assert mem["type"] == "general"
+4 -3
View File
@@ -124,9 +124,10 @@ def test_nonfork_resume_rebinds_project_memory_context_before_recomposition(tmp_
assert session.resume("target-ws") is True assert session.resume("target-ws") is True
assert session.ws_id == "target-ws" assert session.ws_id == "target-ws"
assert session._project_id == "target-project" access = session._memory_access()
assert session._project_name == "Target Project" assert access.project_id == "target-project"
assert session._project_writable is True assert access.project_name == "Target Project"
assert access.project_writable is True
assert ("project", "target-project") in session._visible_scopes() assert ("project", "target-project") in session._visible_scopes()
assert ("project", "source-project") not in session._visible_scopes() assert ("project", "source-project") not in session._visible_scopes()
assert stale_cache_key not in session._mem_search_cache assert stale_cache_key not in session._mem_search_cache
+9 -8
View File
@@ -6,6 +6,7 @@ hint pattern, and the skill catalog disclosure in system messages.
from __future__ import annotations from __future__ import annotations
import threading
from typing import Any from typing import Any
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -1375,15 +1376,14 @@ class TestSkillCatalogDisclosure:
session._tools = [] session._tools = []
session._client_type = ClientType.CLI session._client_type = ClientType.CLI
session._username = "" session._username = ""
# _init_system_messages renders the attached project into the Session # This __new__-built session skips __init__'s attachment setup.
# Context; this __new__-built session skips __init__'s project resolution, session._memory_attached_project_id = ""
# so seed the (unattached) defaults it reads. session._system_prefix_lock = threading.RLock()
session._project_name = "" session._system_prefix_dirty = True
session._project_id = "" session._system_prefix_signature = None
session._project_writable = False
session._kind = "interactive" session._kind = "interactive"
# Persona snapshot attrs (set by __init__, bypassed here) — legacy # Persona snapshot attrs (set by __init__, bypassed here): open
# defaults: no override, unrestricted tools, MCP + memory on. # defaults with no override, unrestricted tools, MCP + memory on.
session._persona_name = "" session._persona_name = ""
session._persona_prompt = "" session._persona_prompt = ""
session._persona_tools = None session._persona_tools = None
@@ -1393,6 +1393,7 @@ class TestSkillCatalogDisclosure:
session._memory_config = MagicMock() session._memory_config = MagicMock()
session._memory_config.fetch_limit = 0 session._memory_config.fetch_limit = 0
session._user_id = "test-user" session._user_id = "test-user"
session._acting_user_id = ""
# _init_system_messages -> _recompute_shared_state reads the session # _init_system_messages -> _recompute_shared_state reads the session
# owner (_mcp_user_id) to decide shared-workstream framing; __init__ # owner (_mcp_user_id) to decide shared-workstream framing; __init__
# normally sets it from user_id, so seed it here for the __new__ build. # normally sets it from user_id, so seed it here for the __new__ build.
+43 -30
View File
@@ -1,5 +1,7 @@
"""Tests for turnstone.core.memory — structured memory facade functions.""" """Tests for turnstone.core.memory — structured memory facade functions."""
import pytest
from turnstone.core.memory import ( from turnstone.core.memory import (
count_structured_memories, count_structured_memories,
delete_structured_memory, delete_structured_memory,
@@ -7,31 +9,42 @@ from turnstone.core.memory import (
list_structured_memories, list_structured_memories,
normalize_key, normalize_key,
save_structured_memory, save_structured_memory,
save_structured_memory_strict,
search_structured_memories, search_structured_memories,
) )
def _save(name, content, **kwargs):
kwargs.setdefault("description", "test memory description")
return save_structured_memory(name, content, **kwargs)
class TestSaveStructuredMemory: class TestSaveStructuredMemory:
@pytest.mark.parametrize("description", [None, "", " "])
def test_description_is_required(self, tmp_db, description):
with pytest.raises(ValueError, match="description is required"):
save_structured_memory_strict("test_key", "hello world", description=description)
def test_save_new(self, tmp_db): def test_save_new(self, tmp_db):
row, was_update = save_structured_memory("test_key", "hello world") row, was_update = _save("test_key", "hello world")
assert row and row["memory_id"] assert row and row["memory_id"]
assert was_update is False assert was_update is False
def test_save_upsert(self, tmp_db): def test_save_upsert(self, tmp_db):
row1, was_update1 = save_structured_memory("test_key", "first") row1, was_update1 = _save("test_key", "first")
row2, was_update2 = save_structured_memory("test_key", "second") row2, was_update2 = _save("test_key", "second")
assert was_update1 is False assert was_update1 is False
assert was_update2 is True assert was_update2 is True
assert row2 and row1 and row2["memory_id"] == row1["memory_id"] # same row assert row2 and row1 and row2["memory_id"] == row1["memory_id"] # same row
assert row2["content"] == "second" assert row2["content"] == "second"
def test_save_normalizes_key(self, tmp_db): def test_save_normalizes_key(self, tmp_db):
save_structured_memory("My-Key", "value") _save("My-Key", "value")
mems = list_structured_memories() mems = list_structured_memories()
assert any(m["name"] == "my_key" for m in mems) assert any(m["name"] == "my_key" for m in mems)
def test_save_with_type_and_scope(self, tmp_db): def test_save_with_type_and_scope(self, tmp_db):
save_structured_memory("k", "v", mem_type="user", scope="workstream", scope_id="ws1") _save("k", "v", mem_type="user", scope="workstream", scope_id="ws1")
mems = list_structured_memories(scope="workstream", scope_id="ws1") mems = list_structured_memories(scope="workstream", scope_id="ws1")
assert len(mems) == 1 assert len(mems) == 1
assert mems[0]["type"] == "user" assert mems[0]["type"] == "user"
@@ -39,14 +52,14 @@ class TestSaveStructuredMemory:
class TestDeleteStructuredMemory: class TestDeleteStructuredMemory:
def test_delete_existing(self, tmp_db): def test_delete_existing(self, tmp_db):
save_structured_memory("mykey", "val") _save("mykey", "val")
assert delete_structured_memory("mykey") assert delete_structured_memory("mykey")
def test_delete_nonexistent(self, tmp_db): def test_delete_nonexistent(self, tmp_db):
assert not delete_structured_memory("nope") assert not delete_structured_memory("nope")
def test_delete_normalizes_key(self, tmp_db): def test_delete_normalizes_key(self, tmp_db):
save_structured_memory("my_key", "val") _save("my_key", "val")
assert delete_structured_memory("My-Key") assert delete_structured_memory("My-Key")
@@ -55,25 +68,25 @@ class TestListStructuredMemories:
assert list_structured_memories() == [] assert list_structured_memories() == []
def test_list_returns_saved(self, tmp_db): def test_list_returns_saved(self, tmp_db):
save_structured_memory("a", "alpha") _save("a", "alpha")
save_structured_memory("b", "beta") _save("b", "beta")
mems = list_structured_memories() mems = list_structured_memories()
assert len(mems) == 2 assert len(mems) == 2
class TestSearchStructuredMemories: class TestSearchStructuredMemories:
def test_search_finds_match(self, tmp_db): def test_search_finds_match(self, tmp_db):
save_structured_memory("db_host", "localhost", description="database hostname") _save("db_host", "localhost", description="database hostname")
save_structured_memory("api_url", "http://example.com") _save("api_url", "http://example.com")
results = search_structured_memories("database") results = search_structured_memories("database")
assert len(results) >= 1 assert len(results) >= 1
assert any(r["name"] == "db_host" for r in results) assert any(r["name"] == "db_host" for r in results)
def test_multiword_or_matches_partial(self, tmp_db): def test_multiword_or_matches_partial(self, tmp_db):
"""OR-of-terms: memory matching only 1 of 3 query terms is returned.""" """OR-of-terms: memory matching only 1 of 3 query terms is returned."""
save_structured_memory("postgres_config", "host=localhost port=5432") _save("postgres_config", "host=localhost port=5432")
save_structured_memory("redis_config", "host=redis port=6379") _save("redis_config", "host=redis port=6379")
save_structured_memory("unrelated", "nothing relevant here") _save("unrelated", "nothing relevant here")
# "postgres missing_word_a missing_word_b": only postgres_config matches "postgres" # "postgres missing_word_a missing_word_b": only postgres_config matches "postgres"
results = search_structured_memories("postgres missing_word_a missing_word_b") results = search_structured_memories("postgres missing_word_a missing_word_b")
@@ -83,9 +96,9 @@ class TestSearchStructuredMemories:
def test_multiword_or_multiple_partial_matches(self, tmp_db): def test_multiword_or_multiple_partial_matches(self, tmp_db):
"""Multiple memories each matching different terms are all returned.""" """Multiple memories each matching different terms are all returned."""
save_structured_memory("key_alpha", "alpha content here") _save("key_alpha", "alpha content here")
save_structured_memory("key_beta", "beta content here") _save("key_beta", "beta content here")
save_structured_memory("key_other", "completely different") _save("key_other", "completely different")
results = search_structured_memories("alpha beta") results = search_structured_memories("alpha beta")
names = {r["name"] for r in results} names = {r["name"] for r in results}
@@ -95,9 +108,9 @@ class TestSearchStructuredMemories:
def test_search_scope_filtering_preserved(self, tmp_db): def test_search_scope_filtering_preserved(self, tmp_db):
"""Search with scope filter only returns memories in that scope.""" """Search with scope filter only returns memories in that scope."""
save_structured_memory("ws1_fact", "alpha info", scope="workstream", scope_id="ws1") _save("ws1_fact", "alpha info", scope="workstream", scope_id="ws1")
save_structured_memory("ws2_fact", "alpha info", scope="workstream", scope_id="ws2") _save("ws2_fact", "alpha info", scope="workstream", scope_id="ws2")
save_structured_memory("global_fact", "alpha info", scope="global") _save("global_fact", "alpha info", scope="global")
results = search_structured_memories("alpha", scope="workstream", scope_id="ws1") results = search_structured_memories("alpha", scope="workstream", scope_id="ws1")
names = {r["name"] for r in results} names = {r["name"] for r in results}
@@ -108,7 +121,7 @@ class TestSearchStructuredMemories:
class TestGetStructuredMemoryByName: class TestGetStructuredMemoryByName:
def test_get_existing(self, tmp_db): def test_get_existing(self, tmp_db):
save_structured_memory("my_mem", "full content here that is quite long") _save("my_mem", "full content here that is quite long")
mem = get_structured_memory_by_name("my_mem", "global", "") mem = get_structured_memory_by_name("my_mem", "global", "")
assert mem is not None assert mem is not None
assert mem["content"] == "full content here that is quite long" assert mem["content"] == "full content here that is quite long"
@@ -118,12 +131,12 @@ class TestGetStructuredMemoryByName:
assert get_structured_memory_by_name("nope", "global", "") is None assert get_structured_memory_by_name("nope", "global", "") is None
def test_get_wrong_scope(self, tmp_db): def test_get_wrong_scope(self, tmp_db):
save_structured_memory("ws_mem", "data", scope="workstream", scope_id="ws1") _save("ws_mem", "data", scope="workstream", scope_id="ws1")
assert get_structured_memory_by_name("ws_mem", "global", "") is None assert get_structured_memory_by_name("ws_mem", "global", "") is None
assert get_structured_memory_by_name("ws_mem", "workstream", "ws1") is not None assert get_structured_memory_by_name("ws_mem", "workstream", "ws1") is not None
def test_get_normalizes_key(self, tmp_db): def test_get_normalizes_key(self, tmp_db):
save_structured_memory("My-Key", "value") _save("My-Key", "value")
mem = get_structured_memory_by_name("My-Key", "global", "") mem = get_structured_memory_by_name("My-Key", "global", "")
assert mem is not None assert mem is not None
assert mem["name"] == "my_key" assert mem["name"] == "my_key"
@@ -134,8 +147,8 @@ class TestCountStructuredMemories:
assert count_structured_memories() == 0 assert count_structured_memories() == 0
def test_count_after_save(self, tmp_db): def test_count_after_save(self, tmp_db):
save_structured_memory("a", "1") _save("a", "1")
save_structured_memory("b", "2") _save("b", "2")
assert count_structured_memories() == 2 assert count_structured_memories() == 2
@@ -154,11 +167,11 @@ class TestScopeIsolation:
def _seed(self): def _seed(self):
"""Create memories across multiple scopes.""" """Create memories across multiple scopes."""
save_structured_memory("global_note", "visible to all", scope="global") _save("global_note", "visible to all", scope="global")
save_structured_memory("ws1_note", "belongs to ws1", scope="workstream", scope_id="ws1") _save("ws1_note", "belongs to ws1", scope="workstream", scope_id="ws1")
save_structured_memory("ws2_note", "belongs to ws2", scope="workstream", scope_id="ws2") _save("ws2_note", "belongs to ws2", scope="workstream", scope_id="ws2")
save_structured_memory("u1_note", "belongs to user1", scope="user", scope_id="u1") _save("u1_note", "belongs to user1", scope="user", scope_id="u1")
save_structured_memory("u2_note", "belongs to user2", scope="user", scope_id="u2") _save("u2_note", "belongs to user2", scope="user", scope_id="u2")
@staticmethod @staticmethod
def _list_visible(ws_id: str, user_id: str, mem_type: str = "", limit: int = 50): def _list_visible(ws_id: str, user_id: str, mem_type: str = "", limit: int = 50):
+212 -54
View File
@@ -2,6 +2,15 @@
class TestCreateAndGet: class TestCreateAndGet:
def test_create_requires_non_empty_description(self, backend):
import pytest
for description in (None, "", " "):
with pytest.raises(ValueError, match="description is required"):
backend.create_structured_memory(
"m1", "test_key", description, "general", "global", "", "data"
)
def test_create_and_get_by_id(self, backend): def test_create_and_get_by_id(self, backend):
backend.create_structured_memory("m1", "test_key", "desc", "general", "global", "", "data") backend.create_structured_memory("m1", "test_key", "desc", "general", "global", "", "data")
mem = backend.get_structured_memory("m1") mem = backend.get_structured_memory("m1")
@@ -43,34 +52,44 @@ class TestSaveUpsert:
import pytest import pytest
import sqlalchemy as sa import sqlalchemy as sa
backend.create_structured_memory("m1", "dup", "", "general", "global", "", "a") backend.create_structured_memory("m1", "dup", "Test memory", "general", "global", "", "a")
with pytest.raises(sa.exc.IntegrityError): with pytest.raises(sa.exc.IntegrityError):
backend.create_structured_memory("m2", "dup", "", "general", "global", "", "b") backend.create_structured_memory(
"m2", "dup", "Test memory", "general", "global", "", "b"
)
def test_save_same_key_updates_in_place(self, backend): def test_save_same_key_updates_in_place(self, backend):
from turnstone.core.memory import save_structured_memory from turnstone.core.memory import save_structured_memory
row1, was_update1 = save_structured_memory("upsert_key", "v1", scope="global") row1, was_update1 = save_structured_memory(
"upsert_key", "v1", description="first description", scope="global"
)
assert row1 and was_update1 is False # inserted assert row1 and was_update1 is False # inserted
row2, was_update2 = save_structured_memory("upsert_key", "v2", scope="global") row2, was_update2 = save_structured_memory(
"upsert_key", "v2", description="updated description", scope="global"
)
assert row2 and was_update2 is True # updated in place assert row2 and was_update2 is True # updated in place
assert row2["memory_id"] == row1["memory_id"] # same row, not a duplicate assert row2["memory_id"] == row1["memory_id"] # same row, not a duplicate
assert row2["content"] == "v2" assert row2["content"] == "v2"
names = [r["name"] for r in backend.list_structured_memories(scope="global")] names = [r["name"] for r in backend.list_structured_memories(scope="global")]
assert names.count("upsert_key") == 1 assert names.count("upsert_key") == 1
def test_save_same_key_preserves_description_and_type_on_default_resave(self, backend): def test_save_same_key_requires_and_updates_description(self, backend):
from turnstone.core.memory import save_structured_memory from turnstone.core.memory import save_structured_memory, save_structured_memory_strict
save_structured_memory( save_structured_memory(
"meta_key", "c1", description="orig desc", mem_type="fact", scope="global" "meta_key", "c1", description="orig desc", mem_type="fact", scope="global"
) )
# A re-save that omits description/type (defaults) must not clobber them. # Every update must describe the revised memory; type can still be omitted.
save_structured_memory("meta_key", "c2", scope="global") import pytest
with pytest.raises(ValueError, match="description is required"):
save_structured_memory_strict("meta_key", "c2", description=None, scope="global")
save_structured_memory("meta_key", "c2", description="revised description", scope="global")
row = backend.get_structured_memory_by_name("meta_key", "global", "") row = backend.get_structured_memory_by_name("meta_key", "global", "")
assert row["content"] == "c2" assert row["content"] == "c2"
assert row["description"] == "orig desc" assert row["description"] == "revised description"
assert row["type"] == "fact" assert row["type"] == "fact"
def test_upsert_method_updates_in_place_no_raise(self, backend): def test_upsert_method_updates_in_place_no_raise(self, backend):
@@ -89,20 +108,75 @@ class TestSaveUpsert:
names = [r["name"] for r in backend.list_structured_memories(scope="global")] names = [r["name"] for r in backend.list_structured_memories(scope="global")]
assert names.count("k") == 1 assert names.count("k") == 1
def test_upsert_none_preserves_explicit_overwrites(self, backend): def test_upsert_requires_description_and_preserves_omitted_type(self, backend):
"""None description/type keep the stored value on conflict; an explicit """Description is mandatory; an omitted type keeps the stored value."""
value (including "" / "general") overwrites it.""" import pytest
backend.create_structured_memory("m1", "k", "keepdesc", "fact", "global", "", "v1") backend.create_structured_memory("m1", "k", "keepdesc", "fact", "global", "", "v1")
# None -> preserve stored description/type (a content-only save). with pytest.raises(ValueError, match="description is required"):
row, _ = backend.upsert_structured_memory("m2", "k", None, None, "global", "", "v2") backend.upsert_structured_memory("m2", "k", None, None, "global", "", "v2")
with pytest.raises(ValueError, match="description is required"):
backend.upsert_structured_memory("m2", "k", " ", None, "global", "", "v2")
row, _ = backend.upsert_structured_memory(
"m2", "k", "new description", None, "global", "", "v2"
)
assert row["content"] == "v2" assert row["content"] == "v2"
assert row["description"] == "keepdesc" assert row["description"] == "new description"
assert row["type"] == "fact" assert row["type"] == "fact"
# Explicit "" / "general" -> overwrite. row2, _ = backend.upsert_structured_memory(
row2, _ = backend.upsert_structured_memory("m3", "k", "", "general", "global", "", "v3") "m3", "k", "final description", "general", "global", "", "v3"
assert row2["description"] == "" )
assert row2["description"] == "final description"
assert row2["type"] == "general" assert row2["type"] == "general"
def test_active_project_guard_accepts_only_active_project(self, backend):
import pytest
backend.create_project("active", "Active", "u1")
row, was_update = backend.upsert_structured_memory(
"m1",
"guarded",
"guarded description",
None,
"project",
"active",
"value",
require_active_project=True,
)
assert row["scope_id"] == "active"
assert was_update is False
backend.create_project("archived", "Archived", "u1", state="archived")
for project_id in ("archived", "missing"):
with pytest.raises(ValueError, match="missing, archived"):
backend.upsert_structured_memory(
f"m-{project_id}",
"guarded",
"guarded description",
None,
"project",
project_id,
"value",
require_active_project=True,
)
assert backend.get_structured_memory_by_name("guarded", "project", project_id) is None
def test_active_project_guard_rejects_non_project_scope(self, backend):
import pytest
with pytest.raises(ValueError, match="requires project scope"):
backend.upsert_structured_memory(
"m1",
"guarded",
"guarded description",
None,
"global",
"",
"value",
require_active_project=True,
)
class TestDelete: class TestDelete:
def test_delete_existing(self, backend): def test_delete_existing(self, backend):
@@ -118,63 +192,119 @@ class TestDelete:
assert not backend.delete_structured_memory("k", "global", "") assert not backend.delete_structured_memory("k", "global", "")
assert backend.delete_structured_memory("k", "workstream", "ws1") assert backend.delete_structured_memory("k", "workstream", "ws1")
def test_delete_returning_is_atomic_and_truthful(self, backend):
backend.create_structured_memory(
"m1", "k", "description", "reference", "user", "u1", "data"
)
deleted = backend.delete_structured_memory_returning("k", "user", "u1")
assert deleted is not None
assert deleted["memory_id"] == "m1"
assert deleted["description"] == "description"
assert deleted["type"] == "reference"
assert backend.get_structured_memory("m1") is None
assert backend.delete_structured_memory_returning("k", "user", "u1") is None
def test_delete_by_id_returning_is_atomic_and_truthful(self, backend):
backend.create_structured_memory("m1", "k", "Test memory", "general", "global", "", "data")
deleted = backend.delete_structured_memory_by_id_returning("m1")
assert deleted is not None
assert deleted["name"] == "k"
assert backend.get_structured_memory("m1") is None
assert backend.delete_structured_memory_by_id_returning("m1") is None
class TestFindScopes:
def test_finds_only_requested_same_name_scopes(self, backend):
backend.create_structured_memory("m1", "same", "Test memory", "general", "global", "", "g")
backend.create_structured_memory(
"m2", "same", "Test memory", "general", "user", "u1", "own"
)
backend.create_structured_memory(
"m3", "same", "Test memory", "general", "user", "victim", "secret"
)
backend.create_structured_memory(
"m4", "other", "Test memory", "general", "workstream", "ws1", "other"
)
found = backend.find_structured_memory_scopes(
"same", [("global", ""), ("user", "u1"), ("workstream", "ws1")]
)
assert set(found) == {("global", ""), ("user", "u1")}
class TestList: class TestList:
def test_list_all(self, backend): def test_list_all(self, backend):
backend.create_structured_memory("m1", "a", "", "general", "global", "", "1") backend.create_structured_memory("m1", "a", "Test memory", "general", "global", "", "1")
backend.create_structured_memory("m2", "b", "", "user", "global", "", "2") backend.create_structured_memory("m2", "b", "Test memory", "user", "global", "", "2")
mems = backend.list_structured_memories() mems = backend.list_structured_memories()
assert len(mems) == 2 assert len(mems) == 2
def test_list_by_type(self, backend): def test_list_by_type(self, backend):
backend.create_structured_memory("m1", "a", "", "general", "global", "", "1") backend.create_structured_memory("m1", "a", "Test memory", "general", "global", "", "1")
backend.create_structured_memory("m2", "b", "", "user", "global", "", "2") backend.create_structured_memory("m2", "b", "Test memory", "user", "global", "", "2")
mems = backend.list_structured_memories(mem_type="user") mems = backend.list_structured_memories(mem_type="user")
assert len(mems) == 1 assert len(mems) == 1
assert mems[0]["name"] == "b" assert mems[0]["name"] == "b"
def test_list_by_scope(self, backend): def test_list_by_scope(self, backend):
backend.create_structured_memory("m1", "a", "", "general", "global", "", "1") backend.create_structured_memory("m1", "a", "Test memory", "general", "global", "", "1")
backend.create_structured_memory("m2", "b", "", "general", "workstream", "ws1", "2") backend.create_structured_memory(
"m2", "b", "Test memory", "general", "workstream", "ws1", "2"
)
mems = backend.list_structured_memories(scope="workstream") mems = backend.list_structured_memories(scope="workstream")
assert len(mems) == 1 assert len(mems) == 1
def test_list_respects_limit(self, backend): def test_list_respects_limit(self, backend):
for i in range(10): for i in range(10):
backend.create_structured_memory(f"m{i}", f"k{i}", "", "general", "global", "", f"{i}") backend.create_structured_memory(
f"m{i}", f"k{i}", "Test memory", "general", "global", "", f"{i}"
)
mems = backend.list_structured_memories(limit=3) mems = backend.list_structured_memories(limit=3)
assert len(mems) == 3 assert len(mems) == 3
class TestSearch: class TestSearch:
def test_search_by_name(self, backend): def test_search_by_name(self, backend):
backend.create_structured_memory("m1", "database_config", "", "general", "global", "", "pg") backend.create_structured_memory(
backend.create_structured_memory("m2", "api_key", "", "general", "global", "", "secret") "m1", "database_config", "Test memory", "general", "global", "", "pg"
)
backend.create_structured_memory(
"m2", "api_key", "Test memory", "general", "global", "", "secret"
)
results = backend.search_structured_memories("database") results = backend.search_structured_memories("database")
assert len(results) == 1 assert len(results) == 1
assert results[0]["name"] == "database_config" assert results[0]["name"] == "database_config"
def test_search_by_content(self, backend): def test_search_by_content(self, backend):
backend.create_structured_memory("m1", "a", "", "general", "global", "", "postgresql host") backend.create_structured_memory(
"m1", "a", "Test memory", "general", "global", "", "postgresql host"
)
results = backend.search_structured_memories("postgresql") results = backend.search_structured_memories("postgresql")
assert len(results) == 1 assert len(results) == 1
def test_search_empty_lists_all(self, backend): def test_search_empty_lists_all(self, backend):
backend.create_structured_memory("m1", "a", "", "general", "global", "", "1") backend.create_structured_memory("m1", "a", "Test memory", "general", "global", "", "1")
backend.create_structured_memory("m2", "b", "", "general", "global", "", "2") backend.create_structured_memory("m2", "b", "Test memory", "general", "global", "", "2")
results = backend.search_structured_memories("") results = backend.search_structured_memories("")
assert len(results) == 2 assert len(results) == 2
class TestCount: class TestCount:
def test_count_all(self, backend): def test_count_all(self, backend):
backend.create_structured_memory("m1", "a", "", "general", "global", "", "1") backend.create_structured_memory("m1", "a", "Test memory", "general", "global", "", "1")
backend.create_structured_memory("m2", "b", "", "general", "global", "", "2") backend.create_structured_memory("m2", "b", "Test memory", "general", "global", "", "2")
assert backend.count_structured_memories() == 2 assert backend.count_structured_memories() == 2
def test_count_by_scope(self, backend): def test_count_by_scope(self, backend):
backend.create_structured_memory("m1", "a", "", "general", "global", "", "1") backend.create_structured_memory("m1", "a", "Test memory", "general", "global", "", "1")
backend.create_structured_memory("m2", "b", "", "general", "workstream", "ws1", "2") backend.create_structured_memory(
"m2", "b", "Test memory", "general", "workstream", "ws1", "2"
)
assert backend.count_structured_memories(scope="global") == 1 assert backend.count_structured_memories(scope="global") == 1
assert backend.count_structured_memories(scope="workstream") == 1 assert backend.count_structured_memories(scope="workstream") == 1
@@ -184,8 +314,12 @@ class TestSearchOrOfTerms:
def test_single_matching_term_in_multi_word_query(self, backend): def test_single_matching_term_in_multi_word_query(self, backend):
"""Memory with content 'apple' found when query is 'apple banana cherry'.""" """Memory with content 'apple' found when query is 'apple banana cherry'."""
backend.create_structured_memory("m1", "apple_mem", "", "general", "global", "", "apple") backend.create_structured_memory(
backend.create_structured_memory("m2", "other_mem", "", "general", "global", "", "grape") "m1", "apple_mem", "Test memory", "general", "global", "", "apple"
)
backend.create_structured_memory(
"m2", "other_mem", "Test memory", "general", "global", "", "grape"
)
results = backend.search_structured_memories("apple banana cherry") results = backend.search_structured_memories("apple banana cherry")
names = {r["name"] for r in results} names = {r["name"] for r in results}
@@ -194,10 +328,18 @@ class TestSearchOrOfTerms:
def test_partial_overlap_across_memories(self, backend): def test_partial_overlap_across_memories(self, backend):
"""Each memory matches one of three terms; all three are returned.""" """Each memory matches one of three terms; all three are returned."""
backend.create_structured_memory("m1", "alpha_doc", "", "general", "global", "", "alpha") backend.create_structured_memory(
backend.create_structured_memory("m2", "beta_doc", "", "general", "global", "", "beta") "m1", "alpha_doc", "Test memory", "general", "global", "", "alpha"
backend.create_structured_memory("m3", "gamma_doc", "", "general", "global", "", "gamma") )
backend.create_structured_memory("m4", "unrelated", "", "general", "global", "", "delta") backend.create_structured_memory(
"m2", "beta_doc", "Test memory", "general", "global", "", "beta"
)
backend.create_structured_memory(
"m3", "gamma_doc", "Test memory", "general", "global", "", "gamma"
)
backend.create_structured_memory(
"m4", "unrelated", "Test memory", "general", "global", "", "delta"
)
results = backend.search_structured_memories("alpha beta gamma") results = backend.search_structured_memories("alpha beta gamma")
names = {r["name"] for r in results} names = {r["name"] for r in results}
@@ -209,12 +351,14 @@ class TestSearchOrOfTerms:
def test_scope_filter_preserved(self, backend): def test_scope_filter_preserved(self, backend):
"""OR-of-terms search still respects scope / scope_id filters.""" """OR-of-terms search still respects scope / scope_id filters."""
backend.create_structured_memory( backend.create_structured_memory(
"m1", "ws1_note", "", "general", "workstream", "ws1", "info" "m1", "ws1_note", "Test memory", "general", "workstream", "ws1", "info"
) )
backend.create_structured_memory( backend.create_structured_memory(
"m2", "ws2_note", "", "general", "workstream", "ws2", "info" "m2", "ws2_note", "Test memory", "general", "workstream", "ws2", "info"
)
backend.create_structured_memory(
"m3", "global_note", "Test memory", "general", "global", "", "info"
) )
backend.create_structured_memory("m3", "global_note", "", "general", "global", "", "info")
results = backend.search_structured_memories("info", scope="workstream", scope_id="ws1") results = backend.search_structured_memories("info", scope="workstream", scope_id="ws1")
names = {r["name"] for r in results} names = {r["name"] for r in results}
@@ -224,9 +368,11 @@ class TestSearchOrOfTerms:
def test_term_cap_normalizes_unbounded_query(self, backend): def test_term_cap_normalizes_unbounded_query(self, backend):
"""A multi-KB query collapses to <= MAX terms (de-dupe + length filter).""" """A multi-KB query collapses to <= MAX terms (de-dupe + length filter)."""
backend.create_structured_memory("m1", "alpha_doc", "", "general", "global", "", "alpha")
backend.create_structured_memory( backend.create_structured_memory(
"m2", "other_doc", "", "general", "global", "", "irrelevant" "m1", "alpha_doc", "Test memory", "general", "global", "", "alpha"
)
backend.create_structured_memory(
"m2", "other_doc", "Test memory", "general", "global", "", "irrelevant"
) )
# Build a noisy query: same word repeated, plus 1-char tokens that # Build a noisy query: same word repeated, plus 1-char tokens that
@@ -241,10 +387,18 @@ class TestVisibleStructuredMemories:
"""Single-query union helpers used by the composition path.""" """Single-query union helpers used by the composition path."""
def test_list_visible_unions_global_workstream_user(self, backend): def test_list_visible_unions_global_workstream_user(self, backend):
backend.create_structured_memory("m1", "g_note", "", "general", "global", "", "g") backend.create_structured_memory(
backend.create_structured_memory("m2", "ws_note", "", "general", "workstream", "ws1", "w") "m1", "g_note", "Test memory", "general", "global", "", "g"
backend.create_structured_memory("m3", "u_note", "", "general", "user", "u1", "u") )
backend.create_structured_memory("m4", "other_ws", "", "general", "workstream", "ws2", "x") backend.create_structured_memory(
"m2", "ws_note", "Test memory", "general", "workstream", "ws1", "w"
)
backend.create_structured_memory(
"m3", "u_note", "Test memory", "general", "user", "u1", "u"
)
backend.create_structured_memory(
"m4", "other_ws", "Test memory", "general", "workstream", "ws2", "x"
)
scopes = [("global", ""), ("workstream", "ws1"), ("user", "u1")] scopes = [("global", ""), ("workstream", "ws1"), ("user", "u1")]
rows = backend.list_visible_structured_memories(scopes) rows = backend.list_visible_structured_memories(scopes)
@@ -252,12 +406,14 @@ class TestVisibleStructuredMemories:
assert names == {"g_note", "ws_note", "u_note"} # ws2 excluded assert names == {"g_note", "ws_note", "u_note"} # ws2 excluded
def test_search_visible_unions_scopes_and_terms(self, backend): def test_search_visible_unions_scopes_and_terms(self, backend):
backend.create_structured_memory("m1", "g_alpha", "", "general", "global", "", "alpha")
backend.create_structured_memory( backend.create_structured_memory(
"m2", "ws_beta", "", "general", "workstream", "ws1", "beta" "m1", "g_alpha", "Test memory", "general", "global", "", "alpha"
) )
backend.create_structured_memory( backend.create_structured_memory(
"m3", "ws_other", "", "general", "workstream", "ws2", "alpha" "m2", "ws_beta", "Test memory", "general", "workstream", "ws1", "beta"
)
backend.create_structured_memory(
"m3", "ws_other", "Test memory", "general", "workstream", "ws2", "alpha"
) )
scopes = [("global", ""), ("workstream", "ws1")] scopes = [("global", ""), ("workstream", "ws1")]
@@ -268,7 +424,9 @@ class TestVisibleStructuredMemories:
assert "ws_other" not in names # ws2 -> outside visibility assert "ws_other" not in names # ws2 -> outside visibility
def test_visible_helpers_handle_empty_scopes(self, backend): def test_visible_helpers_handle_empty_scopes(self, backend):
backend.create_structured_memory("m1", "anything", "", "general", "global", "", "x") backend.create_structured_memory(
"m1", "anything", "Test memory", "general", "global", "", "x"
)
assert backend.list_visible_structured_memories([]) == [] assert backend.list_visible_structured_memories([]) == []
assert backend.search_visible_structured_memories("x", []) == [] assert backend.search_visible_structured_memories("x", []) == []
@@ -288,7 +446,7 @@ class TestStableOrderingOnTimestampTies:
# batch lands them in the same second. # batch lands them in the same second.
for mid in ("zebra_id", "apple_id", "mango_id"): for mid in ("zebra_id", "apple_id", "mango_id"):
backend.create_structured_memory( backend.create_structured_memory(
mid, f"name_{mid}", "", "general", "global", "", "shared content" mid, f"name_{mid}", "Test memory", "general", "global", "", "shared content"
) )
import sqlalchemy as sa import sqlalchemy as sa
+19 -5
View File
@@ -753,10 +753,20 @@ MemoryScope = Literal["global", "workstream", "user"]
class SaveMemoryRequest(BaseModel): class SaveMemoryRequest(BaseModel):
name: str = Field(description="Memory identifier (normalized to snake_case)") name: str = Field(
content: str = Field(description="Memory content", max_length=65536) description="Memory identifier (normalized to snake_case)",
description: str = Field(default="", description="Short description for relevance matching") min_length=1,
type: MemoryType = Field(default="general", description="Memory type") max_length=256,
)
content: str = Field(description="Memory content", min_length=1, max_length=65536)
description: str = Field(
description="Required non-empty description used for relevance matching",
min_length=1,
)
type: MemoryType | None = Field(
default=None,
description="Memory type; omission preserves it on update and defaults on insert",
)
scope: MemoryScope = Field(default="global", description="Memory scope") scope: MemoryScope = Field(default="global", description="Memory scope")
scope_id: str = Field( scope_id: str = Field(
default="", default="",
@@ -765,6 +775,8 @@ class SaveMemoryRequest(BaseModel):
@model_validator(mode="after") @model_validator(mode="after")
def _validate_scope_scope_id(self) -> SaveMemoryRequest: def _validate_scope_scope_id(self) -> SaveMemoryRequest:
if not self.description.strip():
raise ValueError("description is required and must be non-empty")
scope_id = self.scope_id.strip() scope_id = self.scope_id.strip()
if self.scope == "global" and scope_id: if self.scope == "global" and scope_id:
raise ValueError("scope_id is not allowed with global scope") raise ValueError("scope_id is not allowed with global scope")
@@ -795,7 +807,7 @@ MemoryScopeFilter = Literal["", "global", "workstream", "user"]
class SearchMemoriesRequest(BaseModel): class SearchMemoriesRequest(BaseModel):
query: str = Field(description="Search query text") query: str = Field(description="Search query text", min_length=1)
type: MemoryTypeFilter = Field(default="", description="Filter by memory type") type: MemoryTypeFilter = Field(default="", description="Filter by memory type")
scope: MemoryScopeFilter = Field(default="", description="Filter by scope") scope: MemoryScopeFilter = Field(default="", description="Filter by scope")
scope_id: str = Field(default="", description="Filter by scope_id") scope_id: str = Field(default="", description="Filter by scope_id")
@@ -808,6 +820,8 @@ class SearchMemoriesRequest(BaseModel):
raise ValueError("scope_id is not allowed with global scope") raise ValueError("scope_id is not allowed with global scope")
if scope_id and not self.scope: if scope_id and not self.scope:
raise ValueError("scope is required when scope_id is provided") raise ValueError("scope is required when scope_id is provided")
if self.scope == "workstream" and not scope_id:
raise ValueError("scope_id is required for workstream scope")
return self return self
+7 -5
View File
@@ -496,16 +496,17 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [
EndpointSpec( EndpointSpec(
"/v1/api/memories", "/v1/api/memories",
"GET", "GET",
"List structured memories", "List structured memories. Without a scope, returns global plus the authenticated user's memories; workstream scope is owner-bound.",
response_model=ListMemoriesResponse, response_model=ListMemoriesResponse,
query_params=[ query_params=[
QueryParam("type", "Filter by memory type"), QueryParam("type", "Filter by memory type"),
QueryParam("scope", "Filter by scope"), QueryParam("scope", "Filter by public scope: global, workstream, or user"),
QueryParam("scope_id", "Filter by scope identifier"), QueryParam("scope_id", "Filter by scope identifier"),
QueryParam( QueryParam(
"limit", "Max results (default 100, max 200)", schema_type="integer", default=100 "limit", "Max results (default 100, max 200)", schema_type="integer", default=100
), ),
], ],
error_codes=[400, 403, 404, 500],
tags=["Memories"], tags=["Memories"],
), ),
EndpointSpec( EndpointSpec(
@@ -514,15 +515,16 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [
"Save (upsert) a structured memory", "Save (upsert) a structured memory",
request_model=SaveMemoryRequest, request_model=SaveMemoryRequest,
response_model=MemoryInfo, response_model=MemoryInfo,
error_codes=[400], error_codes=[400, 403, 404, 500],
tags=["Memories"], tags=["Memories"],
), ),
EndpointSpec( EndpointSpec(
"/v1/api/memories/search", "/v1/api/memories/search",
"POST", "POST",
"Search structured memories by query", "Search structured memories by query. Without a scope, searches global plus the authenticated user's memories.",
request_model=SearchMemoriesRequest, request_model=SearchMemoriesRequest,
response_model=ListMemoriesResponse, response_model=ListMemoriesResponse,
error_codes=[400, 403, 404, 500],
tags=["Memories"], tags=["Memories"],
), ),
EndpointSpec( EndpointSpec(
@@ -534,7 +536,7 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [
QueryParam("scope", "Scope (default: global)"), QueryParam("scope", "Scope (default: global)"),
QueryParam("scope_id", "Scope identifier"), QueryParam("scope_id", "Scope identifier"),
], ],
error_codes=[404], error_codes=[400, 403, 404, 500],
tags=["Memories"], tags=["Memories"],
), ),
# --- Admin settings --- # --- Admin settings ---
+5 -3
View File
@@ -9544,12 +9544,14 @@ async def admin_delete_memory(request: Request) -> JSONResponse:
return err return err
memory_id = request.path_params["memory_id"] memory_id = request.path_params["memory_id"]
existing = storage.get_structured_memory(memory_id) try:
existing = storage.delete_structured_memory_by_id_returning(memory_id)
except Exception:
log.warning("memory.admin_delete_failed memory_id=%s", memory_id, exc_info=True)
return JSONResponse({"error": "Failed to delete memory"}, status_code=500)
if not existing: if not existing:
return JSONResponse({"error": "Memory not found"}, status_code=404) return JSONResponse({"error": "Memory not found"}, status_code=404)
storage.delete_structured_memory_by_id(memory_id)
audit_uid, ip = _audit_context(request) audit_uid, ip = _audit_context(request)
record_audit( record_audit(
storage, storage,
+88 -9
View File
@@ -858,10 +858,12 @@ def search_history_recent(limit: int = 20, *, user_id: str | None = None) -> lis
def save_structured_memory( def save_structured_memory(
name: str, name: str,
content: str, content: str,
description: str | None = None, description: str,
mem_type: str | None = None, mem_type: str | None = None,
scope: str = "global", scope: str = "global",
scope_id: str = "", scope_id: str = "",
*,
require_active_project: bool = False,
) -> tuple[dict[str, str] | None, bool]: ) -> tuple[dict[str, str] | None, bool]:
"""Save a structured memory as a single atomic upsert by name+scope+scope_id. """Save a structured memory as a single atomic upsert by name+scope+scope_id.
@@ -872,22 +874,62 @@ def save_structured_memory(
IntegrityError round-trip, no TOCTOU window. ``(row, was_update)`` comes IntegrityError round-trip, no TOCTOU window. ``(row, was_update)`` comes
straight from that upsert (this passes a fresh ``memory_id``, so a differing straight from that upsert (this passes a fresh ``memory_id``, so a differing
returned id means an existing row was updated in place). A ``None`` returned id means an existing row was updated in place). A ``None``
description / ``mem_type`` means "leave unset" -- the column default applies ``description`` is required and must contain non-whitespace text for both
on insert and the stored value is kept on conflict. inserts and updates. A ``None`` ``mem_type`` keeps the stored value on an
update and uses the column default on insert.
""" """
import uuid
name = normalize_key(name)
try: try:
row, was_update = get_storage().upsert_structured_memory( return save_structured_memory_strict(
str(uuid.uuid4()), name, description, mem_type, scope, scope_id, content name,
content,
description=description,
mem_type=mem_type,
scope=scope,
scope_id=scope_id,
require_active_project=require_active_project,
) )
return (row, was_update) if row else (None, False)
except Exception: except Exception:
log.warning("Failed to save structured memory name=%s", name, exc_info=True) log.warning("Failed to save structured memory name=%s", name, exc_info=True)
return None, False return None, False
def save_structured_memory_strict(
name: str,
content: str,
description: str,
mem_type: str | None = None,
scope: str = "global",
scope_id: str = "",
*,
require_active_project: bool = False,
) -> tuple[dict[str, str], bool]:
"""Strict structured-memory upsert for mutation-facing boundaries.
Unlike :func:`save_structured_memory`, storage failures propagate so an
API or tool cannot report a database outage as an ordinary failed/not-found
result. Prompt composition keeps using the best-effort facade.
"""
import uuid
normalized = normalize_key(name)
normalized_description = (description or "").strip()
if not normalized_description:
raise ValueError("memory description is required and must be non-empty")
row, was_update = get_storage().upsert_structured_memory(
str(uuid.uuid4()),
normalized,
normalized_description,
mem_type,
scope,
scope_id,
content,
require_active_project=require_active_project,
)
if not row:
raise RuntimeError("structured memory upsert returned no row")
return row, was_update
def get_structured_memory_by_name( def get_structured_memory_by_name(
name: str, scope: str = "global", scope_id: str = "" name: str, scope: str = "global", scope_id: str = ""
) -> dict[str, str] | None: ) -> dict[str, str] | None:
@@ -900,6 +942,13 @@ def get_structured_memory_by_name(
return None return None
def get_structured_memory_by_name_strict(
name: str, scope: str = "global", scope_id: str = ""
) -> dict[str, str] | None:
"""Strict scoped-name lookup; storage failures propagate."""
return get_storage().get_structured_memory_by_name(normalize_key(name), scope, scope_id)
def delete_structured_memory(name: str, scope: str = "global", scope_id: str = "") -> bool: def delete_structured_memory(name: str, scope: str = "global", scope_id: str = "") -> bool:
"""Delete a structured memory by name+scope. Returns True if existed.""" """Delete a structured memory by name+scope. Returns True if existed."""
name = normalize_key(name) name = normalize_key(name)
@@ -919,6 +968,36 @@ def delete_structured_memory_by_id(memory_id: str) -> bool:
return False return False
def delete_structured_memory_returning_strict(
name: str, scope: str = "global", scope_id: str = ""
) -> dict[str, str] | None:
"""Atomically delete and return one scoped-name memory.
Storage failures propagate. A ``None`` return therefore means only that
no matching row existed at the mutation point.
"""
return get_storage().delete_structured_memory_returning(normalize_key(name), scope, scope_id)
def delete_structured_memory_by_id_returning_strict(
memory_id: str,
) -> dict[str, str] | None:
"""Atomically delete and return one memory by id; failures propagate."""
return get_storage().delete_structured_memory_by_id_returning(memory_id)
def find_structured_memory_scopes(
name: str,
scopes: list[tuple[str, str]],
) -> list[tuple[str, str]]:
"""Find visible same-name scope pairs in one metadata-only query."""
try:
return get_storage().find_structured_memory_scopes(normalize_key(name), scopes)
except Exception:
log.warning("Failed to find structured memory scopes name=%s", name, exc_info=True)
return []
def list_structured_memories( def list_structured_memories(
mem_type: str = "", mem_type: str = "",
scope: str = "", scope: str = "",
+656 -493
View File
File diff suppressed because it is too large Load Diff
+79 -14
View File
@@ -4746,6 +4746,9 @@ class PostgreSQLBackend(_KeyedAttachmentSaveWrappers):
scope_id: str, scope_id: str,
content: str, content: str,
) -> None: ) -> None:
if description is None or not description.strip():
raise ValueError("memory description is required and must be non-empty")
description = description.strip()
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn: with self._conn() as conn:
conn.execute( conn.execute(
@@ -4770,19 +4773,24 @@ class PostgreSQLBackend(_KeyedAttachmentSaveWrappers):
self, self,
memory_id: str, memory_id: str,
name: str, name: str,
description: str | None, description: str,
mem_type: str | None, mem_type: str | None,
scope: str, scope: str,
scope_id: str, scope_id: str,
content: str, content: str,
*,
require_active_project: bool = False,
) -> tuple[dict[str, str], bool]: ) -> tuple[dict[str, str], bool]:
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
if description is None or not description.strip():
raise ValueError("memory description is required and must be non-empty")
description = description.strip()
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
insert_stmt = pg_insert(structured_memories).values( insert_stmt = pg_insert(structured_memories).values(
memory_id=memory_id, memory_id=memory_id,
name=name, name=name,
description="" if description is None else description, description=description,
type="general" if mem_type is None else mem_type, type="general" if mem_type is None else mem_type,
scope=scope, scope=scope,
scope_id=scope_id, scope_id=scope_id,
@@ -4792,15 +4800,13 @@ class PostgreSQLBackend(_KeyedAttachmentSaveWrappers):
last_accessed=now, last_accessed=now,
access_count=0, access_count=0,
) )
# On conflict, refresh content + timestamps. description/type are # On conflict, refresh content, description, and timestamps. A None type means
# overwritten only when the caller supplied them; None means "unset" -> # "unset" -> keep the stored value. created/access_count stay untouched.
# keep the stored value. created and access_count are left untouched.
set_: dict[str, Any] = { set_: dict[str, Any] = {
"content": insert_stmt.excluded.content, "content": insert_stmt.excluded.content,
"updated": now, "updated": now,
"last_accessed": now, "last_accessed": now,
} }
if description is not None:
set_["description"] = insert_stmt.excluded.description set_["description"] = insert_stmt.excluded.description
if mem_type is not None: if mem_type is not None:
set_["type"] = insert_stmt.excluded.type set_["type"] = insert_stmt.excluded.type
@@ -4809,6 +4815,22 @@ class PostgreSQLBackend(_KeyedAttachmentSaveWrappers):
set_=set_, set_=set_,
).returning(structured_memories) ).returning(structured_memories)
with self._conn() as conn: with self._conn() as conn:
if require_active_project:
if scope != "project" or not scope_id:
raise ValueError("active-project guard requires project scope")
project = conn.execute(
sa.select(projects.c.project_id)
.where(
sa.and_(
projects.c.project_id == scope_id,
projects.c.state == "active",
)
)
.with_for_update(read=True)
).fetchone()
if project is None:
conn.rollback()
raise ValueError("project is missing, archived, or no longer writable")
row = conn.execute(stmt).fetchone() row = conn.execute(stmt).fetchone()
conn.commit() conn.commit()
if row is None: # unreachable: ON CONFLICT DO UPDATE returns one row if row is None: # unreachable: ON CONFLICT DO UPDATE returns one row
@@ -4841,26 +4863,58 @@ class PostgreSQLBackend(_KeyedAttachmentSaveWrappers):
def delete_structured_memory( def delete_structured_memory(
self, name: str, scope: str = "global", scope_id: str = "" self, name: str, scope: str = "global", scope_id: str = ""
) -> bool: ) -> bool:
with self._conn() as conn: return self.delete_structured_memory_returning(name, scope, scope_id) is not None
result = conn.execute(
sa.delete(structured_memories).where( def delete_structured_memory_returning(
self, name: str, scope: str = "global", scope_id: str = ""
) -> dict[str, str] | None:
stmt = (
sa.delete(structured_memories)
.where(
sa.and_( sa.and_(
structured_memories.c.name == name, structured_memories.c.name == name,
structured_memories.c.scope == scope, structured_memories.c.scope == scope,
structured_memories.c.scope_id == scope_id, structured_memories.c.scope_id == scope_id,
) )
) )
.returning(structured_memories)
) )
with self._conn() as conn:
row = conn.execute(stmt).fetchone()
conn.commit() conn.commit()
return result.rowcount > 0 return dict(row._mapping) if row is not None else None
def delete_structured_memory_by_id(self, memory_id: str) -> bool: def delete_structured_memory_by_id(self, memory_id: str) -> bool:
return self.delete_structured_memory_by_id_returning(memory_id) is not None
def delete_structured_memory_by_id_returning(self, memory_id: str) -> dict[str, str] | None:
with self._conn() as conn: with self._conn() as conn:
result = conn.execute( row = conn.execute(
sa.delete(structured_memories).where(structured_memories.c.memory_id == memory_id) sa.delete(structured_memories)
) .where(structured_memories.c.memory_id == memory_id)
.returning(structured_memories)
).fetchone()
conn.commit() conn.commit()
return result.rowcount > 0 return dict(row._mapping) if row is not None else None
def find_structured_memory_scopes(
self,
name: str,
scopes: list[tuple[str, str]],
) -> list[tuple[str, str]]:
if not scopes:
return []
with self._conn() as conn:
scope_clauses, params = self._build_scope_or_clause(scopes)
rows = conn.execute(
sa.text(
"SELECT scope, scope_id FROM structured_memories "
f"WHERE name = :name AND ({scope_clauses}) "
"ORDER BY scope, scope_id"
),
{**params, "name": name},
).fetchall()
return [(str(row.scope), str(row.scope_id)) for row in rows]
def list_structured_memories( def list_structured_memories(
self, self,
@@ -5905,6 +5959,17 @@ class PostgreSQLBackend(_KeyedAttachmentSaveWrappers):
def delete_project(self, project_id: str) -> bool: def delete_project(self, project_id: str) -> bool:
with self._conn() as conn: with self._conn() as conn:
# Serialize with guarded project-memory upserts. If a writer got
# the row first, its memory is committed before our purge; if this
# delete wins, the later writer's active-project check finds no row.
project = conn.execute(
sa.select(projects.c.project_id)
.where(projects.c.project_id == project_id)
.with_for_update()
).fetchone()
if project is None:
conn.rollback()
return False
# No FK cascade in the schema family, so purge the project's scoped # No FK cascade in the schema family, so purge the project's scoped
# memory + member rows explicitly (same transaction) before the # memory + member rows explicitly (same transaction) before the
# project row — honouring the "destroys the container AND its scoped # project row — honouring the "destroys the container AND its scoped
+31 -6
View File
@@ -829,34 +829,41 @@ class StorageBackend(Protocol):
scope_id: str, scope_id: str,
content: str, content: str,
) -> None: ) -> None:
"""Create a structured memory record.""" """Create a structured memory record with a non-empty description."""
... ...
def upsert_structured_memory( def upsert_structured_memory(
self, self,
memory_id: str, memory_id: str,
name: str, name: str,
description: str | None, description: str,
mem_type: str | None, mem_type: str | None,
scope: str, scope: str,
scope_id: str, scope_id: str,
content: str, content: str,
*,
require_active_project: bool = False,
) -> tuple[dict[str, str], bool]: ) -> tuple[dict[str, str], bool]:
"""Insert a structured memory, or update it in place on a """Insert a structured memory, or update it in place on a
``(name, scope, scope_id)`` conflict. ``(name, scope, scope_id)`` conflict.
Atomic ``INSERT ... ON CONFLICT DO UPDATE ... RETURNING`` no Atomic ``INSERT ... ON CONFLICT DO UPDATE ... RETURNING`` no
IntegrityError round-trip, race-safe under concurrent saves of the same IntegrityError round-trip, race-safe under concurrent saves of the same
key. ``description`` / ``mem_type`` of ``None`` mean "unset": the key. ``description`` must contain non-whitespace text on every insert
column default ("" / "general") is used on insert and the stored value or update. A ``mem_type`` of ``None`` means "unset": the column default
is kept on conflict; a non-``None`` value (including "" or "general") is is used on insert and the stored value is kept on conflict.
written.
Returns ``(row, was_update)`` (like Django's ``update_or_create``): the Returns ``(row, was_update)`` (like Django's ``update_or_create``): the
full saved row, and ``True`` when an existing row was updated rather full saved row, and ``True`` when an existing row was updated rather
than inserted. Callers MUST supply a fresh unique ``memory_id`` it is than inserted. Callers MUST supply a fresh unique ``memory_id`` it is
compared against the returned row's id to tell INSERT from UPDATE, so a compared against the returned row's id to tell INSERT from UPDATE, so a
reused id would report ``was_update=False`` on a real update. reused id would report ``was_update=False`` on a real update.
When ``require_active_project`` is true, ``scope`` must be
``"project"`` and the backend must verify that the referenced project
still exists and is active in the same transaction as the upsert. The
project row is locked where the backend supports row locks so a
concurrent project delete cannot leave an orphaned memory behind.
""" """
... ...
@@ -876,10 +883,28 @@ class StorageBackend(Protocol):
"""Delete a structured memory by (name, scope, scope_id). Returns True if existed.""" """Delete a structured memory by (name, scope, scope_id). Returns True if existed."""
... ...
def delete_structured_memory_returning(
self, name: str, scope: str = "global", scope_id: str = ""
) -> dict[str, str] | None:
"""Atomically delete and return a memory selected by its scoped name."""
...
def delete_structured_memory_by_id(self, memory_id: str) -> bool: def delete_structured_memory_by_id(self, memory_id: str) -> bool:
"""Delete a structured memory by its primary key. Returns True if existed.""" """Delete a structured memory by its primary key. Returns True if existed."""
... ...
def delete_structured_memory_by_id_returning(self, memory_id: str) -> dict[str, str] | None:
"""Atomically delete and return a memory selected by primary key."""
...
def find_structured_memory_scopes(
self,
name: str,
scopes: list[tuple[str, str]],
) -> list[tuple[str, str]]:
"""Return visible scope pairs containing ``name`` in one small query."""
...
def list_structured_memories( def list_structured_memories(
self, self,
mem_type: str = "", mem_type: str = "",
+73 -14
View File
@@ -4818,6 +4818,9 @@ class SQLiteBackend(_KeyedAttachmentSaveWrappers):
scope_id: str, scope_id: str,
content: str, content: str,
) -> None: ) -> None:
if description is None or not description.strip():
raise ValueError("memory description is required and must be non-empty")
description = description.strip()
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._conn() as conn: with self._conn() as conn:
conn.execute( conn.execute(
@@ -4842,19 +4845,24 @@ class SQLiteBackend(_KeyedAttachmentSaveWrappers):
self, self,
memory_id: str, memory_id: str,
name: str, name: str,
description: str | None, description: str,
mem_type: str | None, mem_type: str | None,
scope: str, scope: str,
scope_id: str, scope_id: str,
content: str, content: str,
*,
require_active_project: bool = False,
) -> tuple[dict[str, str], bool]: ) -> tuple[dict[str, str], bool]:
from sqlalchemy.dialects.sqlite import insert as sqlite_insert from sqlalchemy.dialects.sqlite import insert as sqlite_insert
if description is None or not description.strip():
raise ValueError("memory description is required and must be non-empty")
description = description.strip()
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
insert_stmt = sqlite_insert(structured_memories).values( insert_stmt = sqlite_insert(structured_memories).values(
memory_id=memory_id, memory_id=memory_id,
name=name, name=name,
description="" if description is None else description, description=description,
type="general" if mem_type is None else mem_type, type="general" if mem_type is None else mem_type,
scope=scope, scope=scope,
scope_id=scope_id, scope_id=scope_id,
@@ -4864,15 +4872,13 @@ class SQLiteBackend(_KeyedAttachmentSaveWrappers):
last_accessed=now, last_accessed=now,
access_count=0, access_count=0,
) )
# On conflict, refresh content + timestamps. description/type are # On conflict, refresh content, description, and timestamps. A None type means
# overwritten only when the caller supplied them; None means "unset" -> # "unset" -> keep the stored value. created/access_count stay untouched.
# keep the stored value. created and access_count are left untouched.
set_: dict[str, Any] = { set_: dict[str, Any] = {
"content": insert_stmt.excluded.content, "content": insert_stmt.excluded.content,
"updated": now, "updated": now,
"last_accessed": now, "last_accessed": now,
} }
if description is not None:
set_["description"] = insert_stmt.excluded.description set_["description"] = insert_stmt.excluded.description
if mem_type is not None: if mem_type is not None:
set_["type"] = insert_stmt.excluded.type set_["type"] = insert_stmt.excluded.type
@@ -4881,6 +4887,24 @@ class SQLiteBackend(_KeyedAttachmentSaveWrappers):
set_=set_, set_=set_,
).returning(structured_memories) ).returning(structured_memories)
with self._conn() as conn: with self._conn() as conn:
if require_active_project:
if scope != "project" or not scope_id:
raise ValueError("active-project guard requires project scope")
# SQLite has no row locks. Taking the writer lock before the
# existence check serializes this transaction with
# ``delete_project`` (which uses the same prologue).
conn.execute(sa.text("BEGIN IMMEDIATE"))
project = conn.execute(
sa.select(projects.c.project_id).where(
sa.and_(
projects.c.project_id == scope_id,
projects.c.state == "active",
)
)
).fetchone()
if project is None:
conn.rollback()
raise ValueError("project is missing, archived, or no longer writable")
row = conn.execute(stmt).fetchone() row = conn.execute(stmt).fetchone()
conn.commit() conn.commit()
if row is None: # unreachable: ON CONFLICT DO UPDATE returns one row if row is None: # unreachable: ON CONFLICT DO UPDATE returns one row
@@ -4913,26 +4937,58 @@ class SQLiteBackend(_KeyedAttachmentSaveWrappers):
def delete_structured_memory( def delete_structured_memory(
self, name: str, scope: str = "global", scope_id: str = "" self, name: str, scope: str = "global", scope_id: str = ""
) -> bool: ) -> bool:
with self._conn() as conn: return self.delete_structured_memory_returning(name, scope, scope_id) is not None
result = conn.execute(
sa.delete(structured_memories).where( def delete_structured_memory_returning(
self, name: str, scope: str = "global", scope_id: str = ""
) -> dict[str, str] | None:
stmt = (
sa.delete(structured_memories)
.where(
sa.and_( sa.and_(
structured_memories.c.name == name, structured_memories.c.name == name,
structured_memories.c.scope == scope, structured_memories.c.scope == scope,
structured_memories.c.scope_id == scope_id, structured_memories.c.scope_id == scope_id,
) )
) )
.returning(structured_memories)
) )
with self._conn() as conn:
row = conn.execute(stmt).fetchone()
conn.commit() conn.commit()
return result.rowcount > 0 return dict(row._mapping) if row is not None else None
def delete_structured_memory_by_id(self, memory_id: str) -> bool: def delete_structured_memory_by_id(self, memory_id: str) -> bool:
return self.delete_structured_memory_by_id_returning(memory_id) is not None
def delete_structured_memory_by_id_returning(self, memory_id: str) -> dict[str, str] | None:
with self._conn() as conn: with self._conn() as conn:
result = conn.execute( row = conn.execute(
sa.delete(structured_memories).where(structured_memories.c.memory_id == memory_id) sa.delete(structured_memories)
) .where(structured_memories.c.memory_id == memory_id)
.returning(structured_memories)
).fetchone()
conn.commit() conn.commit()
return result.rowcount > 0 return dict(row._mapping) if row is not None else None
def find_structured_memory_scopes(
self,
name: str,
scopes: list[tuple[str, str]],
) -> list[tuple[str, str]]:
if not scopes:
return []
with self._conn() as conn:
scope_clauses, params = self._build_scope_or_clause(scopes)
rows = conn.execute(
sa.text(
"SELECT scope, scope_id FROM structured_memories "
f"WHERE name = :name AND ({scope_clauses}) "
"ORDER BY scope, scope_id"
),
{**params, "name": name},
).fetchall()
return [(str(row.scope), str(row.scope_id)) for row in rows]
def list_structured_memories( def list_structured_memories(
self, self,
@@ -5961,6 +6017,9 @@ class SQLiteBackend(_KeyedAttachmentSaveWrappers):
def delete_project(self, project_id: str) -> bool: def delete_project(self, project_id: str) -> bool:
with self._conn() as conn: with self._conn() as conn:
# Serialize with guarded project-memory upserts before inspecting
# or deleting the container.
conn.execute(sa.text("BEGIN IMMEDIATE"))
# No FK cascade in the schema family, so purge the project's scoped # No FK cascade in the schema family, so purge the project's scoped
# memory + member rows explicitly (same transaction) before the # memory + member rows explicitly (same transaction) before the
# project row — honouring the "destroys the container AND its scoped # project row — honouring the "destroys the container AND its scoped
+5 -6
View File
@@ -64,12 +64,11 @@ def _apply_kind_variant(tool: dict[str, Any], kind: str, meta: dict[str, Any]) -
"""Return a kind-specific copy of ``tool`` with description / params overridden. """Return a kind-specific copy of ``tool`` with description / params overridden.
Each kind sees only the surface it can actually use for ``memory``, Each kind sees only the surface it can actually use for ``memory``,
coord sessions get a description + scope enum that mention only the coord sessions get ``coordinator`` plus the attach-dependent ``project``
``coordinator`` scope, while interactive sessions get a description scope, while interactive sessions get global/workstream/user plus
+ scope enum that omit ``coordinator`` entirely. This keeps the ``project``. This keeps the LLM contract tight: the model never sees enum
LLM contract tight: the model never sees enum values it can't use, values it can't use, and never reads description sentences explaining why
and never reads description sentences explaining why a scope is a scope is forbidden.
forbidden.
No-op (returns the input tool unchanged) when the tool has no No-op (returns the input tool unchanged) when the tool has no
``kind_variants`` metadata or no entry for ``kind``. Otherwise ``kind_variants`` metadata or no entry for ``kind``. Otherwise
+5 -6
View File
@@ -259,9 +259,8 @@ def _tasks_action_enum() -> frozenset[str]:
# #
# Neither half is a literal here. The action VOCABULARY comes from the # Neither half is a literal here. The action VOCABULARY comes from the
# tool's own schema (:func:`_tasks_action_enum`) and the READ half is # tool's own schema (:func:`_tasks_action_enum`) and the READ half is
# ``ChatSession._TASKS_READ_ACTIONS``, production's own classifier # ``_TASKS_READ_ACTIONS``, production's own preparer classifier.
# the one its parallel-batch guard and approval path rule on. Mutating # Mutating is the remainder, so an action added to the schema counts as a
# is the remainder, so an action added to the schema counts as a
# mutation until production classifies it as a read: a new write can # mutation until production classifies it as a read: a new write can
# never be silently dropped from the bookkeeping test, and the drift # never be silently dropped from the bookkeeping test, and the drift
# that IS possible (a new read) is caught statically by # that IS possible (a new read) is caught statically by
@@ -824,7 +823,7 @@ def _seed_world(storage: Any, case: dict[str, Any]) -> None:
saved, _was_update = save_structured_memory( saved, _was_update = save_structured_memory(
row["name"], row["name"],
row["content"], row["content"],
row.get("description"), row["description"],
row.get("type"), row.get("type"),
scope=row.get("scope", "global"), scope=row.get("scope", "global"),
scope_id=row.get("scope_id", ""), scope_id=row.get("scope_id", ""),
@@ -1969,7 +1968,7 @@ def _check_world_is_seedable(case: dict[str, Any]) -> str | None:
Recognized keys only (``memory`` / ``nodes``) an unrecognized key Recognized keys only (``memory`` / ``nodes``) an unrecognized key
is a silent no-op seed, which reads as "seeded" while leaving the is a silent no-op seed, which reads as "seeded" while leaving the
hollow world the block exists to fill. Memory rows need non-empty hollow world the block exists to fill. Memory rows need non-empty
string ``name`` and ``content`` (the production upsert's own string ``name``, ``description``, and ``content`` (the production upsert's own
requirements, surfaced at authoring time); node rows need a requirements, surfaced at authoring time); node rows need a
non-empty string ``node_id``. non-empty string ``node_id``.
""" """
@@ -1984,7 +1983,7 @@ def _check_world_is_seedable(case: dict[str, Any]) -> str | None:
for i, row in enumerate(world.get("memory", ())): for i, row in enumerate(world.get("memory", ())):
if not isinstance(row, dict): if not isinstance(row, dict):
return f"world.memory[{i}] must be a dict" return f"world.memory[{i}] must be a dict"
for field in ("name", "content"): for field in ("name", "content", "description"):
v = row.get(field) v = row.get(field)
if not isinstance(v, str) or not v.strip(): if not isinstance(v, str) or not v.strip():
return f"world.memory[{i}].{field} must be a non-empty string" return f"world.memory[{i}].{field} must be a non-empty string"
+2
View File
@@ -95,6 +95,7 @@ NUDGE_CELLS: list[dict[str, Any]] = [
"memory": [ "memory": [
{ {
"name": "acme-api-project", "name": "acme-api-project",
"description": "Acme API repository and deployment context",
"type": "reference", "type": "reference",
"content": ( "content": (
"acme-api: FastAPI service. Repo layout: " "acme-api: FastAPI service. Repo layout: "
@@ -105,6 +106,7 @@ NUDGE_CELLS: list[dict[str, Any]] = [
}, },
{ {
"name": "auth-backend-migration-status", "name": "auth-backend-migration-status",
"description": "Current authentication migration status",
"content": ( "content": (
"migrations/007_auth_backend.sql applied on the " "migrations/007_auth_backend.sql applied on the "
"staging replica; auth service suite green " "staging replica; auth service suite green "
+6 -4
View File
@@ -525,19 +525,21 @@ class AsyncTurnstoneServer(_BaseClient):
name: str, name: str,
content: str, content: str,
*, *,
description: str = "", description: str,
mem_type: str = "general", mem_type: str = "general",
scope: str = "global", scope: str = "global",
scope_id: str = "", scope_id: str = "",
) -> MemoryInfo: ) -> MemoryInfo:
description = (description or "").strip()
if not description:
raise ValueError("memory description is required and must be non-empty")
body: dict[str, Any] = { body: dict[str, Any] = {
"name": name, "name": name,
"content": content, "content": content,
"description": description,
"type": mem_type, "type": mem_type,
"scope": scope, "scope": scope,
} }
if description:
body["description"] = description
if scope_id: if scope_id:
body["scope_id"] = scope_id body["scope_id"] = scope_id
return await self._request( return await self._request(
@@ -868,7 +870,7 @@ class TurnstoneServer:
name: str, name: str,
content: str, content: str,
*, *,
description: str = "", description: str,
mem_type: str = "general", mem_type: str = "general",
scope: str = "global", scope: str = "global",
scope_id: str = "", scope_id: str = "",
+211 -49
View File
@@ -3495,31 +3495,157 @@ def _resolve_user_scope_id(
return uid, None return uid, None
def _resolve_workstream_memory_scope_id(
request: Request,
scope_id: str,
) -> tuple[str, JSONResponse | None]:
"""Bind REST workstream-memory access to the authenticated owner."""
from turnstone.core.storage._registry import get_storage
resolved = scope_id.strip()
if not resolved:
return "", JSONResponse(
{"error": "scope_id is required for workstream scope"},
status_code=400,
)
owner = get_storage().get_workstream_owner(resolved)
if owner is None:
return "", JSONResponse({"error": "Workstream not found"}, status_code=404)
caller = _auth_user_id(request)
if "service" not in _auth_scopes(request) and (not caller or owner != caller):
return "", JSONResponse(
{"error": "Cannot access another user's workstream memories"},
status_code=403,
)
return resolved, None
def _resolve_rest_memory_scope(
request: Request,
scope: str,
scope_id: str,
*,
allow_empty: bool,
) -> tuple[str, str, JSONResponse | None]:
"""Validate a public memory scope and bind its caller-controlled id."""
normalized_scope = scope.strip().lower()
normalized_id = scope_id.strip()
if not normalized_scope and allow_empty:
err = _validate_scope_scope_id(normalized_scope, normalized_id)
return normalized_scope, normalized_id, err
if normalized_scope not in _VALID_MEMORY_SCOPES:
return (
"",
"",
JSONResponse(
{
"error": (
f"invalid scope: {normalized_scope}; "
f"must be one of {sorted(_VALID_MEMORY_SCOPES)}"
)
},
status_code=400,
),
)
if normalized_scope == "user":
normalized_id, err = _resolve_user_scope_id(request, normalized_id)
if err:
return "", "", err
elif normalized_scope == "workstream":
normalized_id, err = _resolve_workstream_memory_scope_id(request, normalized_id)
if err:
return "", "", err
err = _validate_scope_scope_id(
normalized_scope,
normalized_id,
require_scope_id=True,
)
return normalized_scope, normalized_id, err
def _rest_visible_memory_scopes(request: Request) -> list[tuple[str, str]]:
"""Default public read envelope: global plus the caller's user scope."""
scopes = [("global", "")]
uid = _auth_user_id(request)
if uid:
scopes.append(("user", uid))
return scopes
def _audit_rest_memory_mutation(
request: Request,
action: str,
row: dict[str, str],
) -> None:
"""Record one authenticated REST/SDK memory mutation."""
from turnstone.core.audit import record_audit
from turnstone.core.storage._registry import get_storage
uid, ip = _audit_context(request)
record_audit(
get_storage(),
uid,
action,
"memory",
row["memory_id"],
{
"name": row["name"],
"scope": row["scope"],
"scope_id": row["scope_id"],
"type": row["type"],
"surface": "rest",
},
ip,
)
async def list_memories(request: Request) -> JSONResponse: async def list_memories(request: Request) -> JSONResponse:
"""GET /v1/api/memories — list memories with optional filters.""" """GET /v1/api/memories — list memories with optional filters."""
from turnstone.core.memory import list_structured_memories from turnstone.core.storage._registry import get_storage
mem_type = request.query_params.get("type", "") mem_type = request.query_params.get("type", "").strip().lower()
scope = request.query_params.get("scope", "") scope = request.query_params.get("scope", "")
scope_id = request.query_params.get("scope_id", "") scope_id = request.query_params.get("scope_id", "")
if mem_type and mem_type not in _VALID_MEMORY_TYPES:
return JSONResponse({"error": f"invalid type: {mem_type}"}, status_code=400)
try: try:
limit = min(int(request.query_params.get("limit", "100")), 200) limit = int(request.query_params.get("limit", "100"))
except (ValueError, TypeError): except (ValueError, TypeError):
return JSONResponse({"error": "limit must be an integer"}, status_code=400) return JSONResponse({"error": "limit must be an integer"}, status_code=400)
err = _validate_scope_scope_id(scope, scope_id) if not 1 <= limit <= 200:
return JSONResponse({"error": "limit must be between 1 and 200"}, status_code=400)
scope, scope_id, err = _resolve_rest_memory_scope(
request,
scope,
scope_id,
allow_empty=True,
)
if err: if err:
return err return err
if scope == "user": try:
scope_id, err = _resolve_user_scope_id(request, scope_id) storage = get_storage()
if err: if scope:
return err rows = storage.list_structured_memories(
rows = list_structured_memories(mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit) mem_type=mem_type,
scope=scope,
scope_id=scope_id,
limit=limit,
)
else:
rows = storage.list_visible_structured_memories(
_rest_visible_memory_scopes(request),
mem_type=mem_type,
limit=limit,
)
except Exception:
log.warning("memory.rest_list_failed", exc_info=True)
return JSONResponse({"error": "Memory storage unavailable"}, status_code=500)
return JSONResponse({"memories": rows, "total": len(rows)}) return JSONResponse({"memories": rows, "total": len(rows)})
async def save_memory(request: Request) -> JSONResponse: async def save_memory(request: Request) -> JSONResponse:
"""POST /v1/api/memories — save (upsert) a structured memory.""" """POST /v1/api/memories — save (upsert) a structured memory."""
from turnstone.core.memory import save_structured_memory from turnstone.core.memory import save_structured_memory_strict
from turnstone.core.web_helpers import read_json_or_400 from turnstone.core.web_helpers import read_json_or_400
body = await read_json_or_400(request) body = await read_json_or_400(request)
@@ -3536,12 +3662,15 @@ async def save_memory(request: Request) -> JSONResponse:
{"error": f"content exceeds {_MAX_MEMORY_CONTENT} character limit"}, {"error": f"content exceeds {_MAX_MEMORY_CONTENT} character limit"},
status_code=400, status_code=400,
) )
# None (field omitted) means "leave unset": the upsert keeps the stored
# value on update and defaults on insert; an explicit value overwrites.
raw_desc = body.get("description") raw_desc = body.get("description")
description = None if raw_desc is None else str(raw_desc) description = "" if raw_desc is None else str(raw_desc).strip()
if not description:
return JSONResponse(
{"error": "description is required and must be non-empty"},
status_code=400,
)
raw_type = body.get("type") raw_type = body.get("type")
mem_type = None if raw_type is None else str(raw_type) mem_type = None if raw_type is None else str(raw_type).strip().lower()
scope = str(body.get("scope", "global")) scope = str(body.get("scope", "global"))
scope_id = str(body.get("scope_id", "")) scope_id = str(body.get("scope_id", ""))
if mem_type is not None and mem_type not in _VALID_MEMORY_TYPES: if mem_type is not None and mem_type not in _VALID_MEMORY_TYPES:
@@ -3549,24 +3678,31 @@ async def save_memory(request: Request) -> JSONResponse:
{"error": f"invalid type: {mem_type}; must be one of {sorted(_VALID_MEMORY_TYPES)}"}, {"error": f"invalid type: {mem_type}; must be one of {sorted(_VALID_MEMORY_TYPES)}"},
status_code=400, status_code=400,
) )
if scope not in _VALID_MEMORY_SCOPES: scope, scope_id, err = _resolve_rest_memory_scope(
return JSONResponse( request,
{"error": f"invalid scope: {scope}; must be one of {sorted(_VALID_MEMORY_SCOPES)}"}, scope,
status_code=400, scope_id,
allow_empty=False,
) )
if scope == "user":
scope_id, err = _resolve_user_scope_id(request, scope_id)
if err: if err:
return err return err
err = _validate_scope_scope_id(scope, scope_id, require_scope_id=True) try:
if err: row, was_update = save_structured_memory_strict(
return err name,
# The upsert RETURNINGs the full saved row, so no follow-up read is needed. content,
row, was_update = save_structured_memory( description=description,
name, content, description=description, mem_type=mem_type, scope=scope, scope_id=scope_id mem_type=mem_type,
scope=scope,
scope_id=scope_id,
) )
if not row: except Exception:
log.warning("memory.rest_save_failed", name=name, exc_info=True)
return JSONResponse({"error": "Failed to save memory"}, status_code=500) return JSONResponse({"error": "Failed to save memory"}, status_code=500)
_audit_rest_memory_mutation(
request,
"memory.update" if was_update else "memory.save",
row,
)
return JSONResponse(row, status_code=200 if was_update else 201) return JSONResponse(row, status_code=200 if was_update else 201)
@@ -3575,7 +3711,7 @@ async def search_memories(request: Request) -> JSONResponse:
Uses POST for the request body but requires only read scope (non-mutating). Uses POST for the request body but requires only read scope (non-mutating).
""" """
from turnstone.core.memory import search_structured_memories as search_fn from turnstone.core.storage._registry import get_storage
from turnstone.core.web_helpers import read_json_or_400 from turnstone.core.web_helpers import read_json_or_400
body = await read_json_or_400(request) body = await read_json_or_400(request)
@@ -3584,46 +3720,72 @@ async def search_memories(request: Request) -> JSONResponse:
query = str(body.get("query", "")).strip() query = str(body.get("query", "")).strip()
if not query: if not query:
return JSONResponse({"error": "query is required"}, status_code=400) return JSONResponse({"error": "query is required"}, status_code=400)
mem_type = str(body.get("type", "")) mem_type = str(body.get("type", "")).strip().lower()
scope = str(body.get("scope", "")) scope = str(body.get("scope", ""))
scope_id = str(body.get("scope_id", "")) scope_id = str(body.get("scope_id", ""))
try: try:
limit = min(int(body.get("limit", 20)), 50) limit = int(body.get("limit", 20))
except (ValueError, TypeError): except (ValueError, TypeError):
return JSONResponse({"error": "limit must be an integer"}, status_code=400) return JSONResponse({"error": "limit must be an integer"}, status_code=400)
err = _validate_scope_scope_id(scope, scope_id) if mem_type and mem_type not in _VALID_MEMORY_TYPES:
return JSONResponse({"error": f"invalid type: {mem_type}"}, status_code=400)
if not 1 <= limit <= 50:
return JSONResponse({"error": "limit must be between 1 and 50"}, status_code=400)
scope, scope_id, err = _resolve_rest_memory_scope(
request,
scope,
scope_id,
allow_empty=True,
)
if err: if err:
return err return err
if scope == "user": try:
scope_id, err = _resolve_user_scope_id(request, scope_id) storage = get_storage()
if err: if scope:
return err rows = storage.search_structured_memories(
rows = search_fn(query, mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit) query,
mem_type=mem_type,
scope=scope,
scope_id=scope_id,
limit=limit,
)
else:
rows = storage.search_visible_structured_memories(
query,
_rest_visible_memory_scopes(request),
mem_type=mem_type,
limit=limit,
)
except Exception:
log.warning("memory.rest_search_failed", exc_info=True)
return JSONResponse({"error": "Memory storage unavailable"}, status_code=500)
return JSONResponse({"memories": rows, "total": len(rows)}) return JSONResponse({"memories": rows, "total": len(rows)})
async def delete_memory_endpoint(request: Request) -> JSONResponse: async def delete_memory_endpoint(request: Request) -> JSONResponse:
"""DELETE /v1/api/memories/{name} — delete a memory by name and scope.""" """DELETE /v1/api/memories/{name} — delete a memory by name and scope."""
from turnstone.core.memory import delete_structured_memory, normalize_key from turnstone.core.memory import delete_structured_memory_returning_strict, normalize_key
name = normalize_key(request.path_params["name"]) name = normalize_key(request.path_params["name"])
scope = request.query_params.get("scope", "global") scope = request.query_params.get("scope", "global")
if scope not in _VALID_MEMORY_SCOPES:
return JSONResponse(
{"error": f"invalid scope: {scope}; must be one of {sorted(_VALID_MEMORY_SCOPES)}"},
status_code=400,
)
scope_id = request.query_params.get("scope_id", "") scope_id = request.query_params.get("scope_id", "")
if scope == "user": scope, scope_id, err = _resolve_rest_memory_scope(
scope_id, err = _resolve_user_scope_id(request, scope_id) request,
scope,
scope_id,
allow_empty=False,
)
if err: if err:
return err return err
err = _validate_scope_scope_id(scope, scope_id, require_scope_id=True) try:
if err: deleted = delete_structured_memory_returning_strict(name, scope, scope_id)
return err except Exception:
if delete_structured_memory(name, scope, scope_id): log.warning("memory.rest_delete_failed", name=name, exc_info=True)
return JSONResponse({"status": "ok", "name": name}) return JSONResponse({"error": "Failed to delete memory"}, status_code=500)
if deleted is None:
return JSONResponse({"error": f"Memory '{name}' not found"}, status_code=404) return JSONResponse({"error": f"Memory '{name}' not found"}, status_code=404)
_audit_rest_memory_mutation(request, "memory.delete", deleted)
return JSONResponse({"status": "ok", "name": name})
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+9 -7
View File
@@ -1,6 +1,6 @@
{ {
"name": "memory", "name": "memory",
"description": "Persistent memory across sessions. Actions: 'save' stores a memory, 'get' retrieves full content by name, 'search' finds memories by query, 'delete' removes a memory, 'list' shows all memories. Use 'get' to read full content — search/list truncate previews to 200 chars. Memories have a type (user/general/feedback/reference) and a scope.", "description": "Persistent memory across sessions. Actions: 'save' stores a memory and always requires a non-empty description, 'get' retrieves full content by name, 'search' finds memories by query, 'delete' removes a memory, 'list' shows all memories. Use 'get' to read full content — search/list truncate previews to 200 chars. Memories have a type (user/general/feedback/reference) and a scope. Save/get/delete inherit one target: an attached active project, otherwise the session-kind default. A read-only project permits get but makes inherited save/delete fail; they never fall back to another scope. Pass the displayed scope explicitly when following a search/list result from another scope.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -11,6 +11,7 @@
}, },
"name": { "name": {
"type": "string", "type": "string",
"maxLength": 256,
"description": "Memory identifier (required for 'save', 'get', and 'delete'). Short snake_case key." "description": "Memory identifier (required for 'save', 'get', and 'delete'). Short snake_case key."
}, },
"content": { "content": {
@@ -19,7 +20,8 @@
}, },
"description": { "description": {
"type": "string", "type": "string",
"description": "Short description for relevance matching (recommended for 'save')." "minLength": 1,
"description": "Required non-empty description for relevance matching on every 'save' (create or update)."
}, },
"type": { "type": {
"type": "string", "type": "string",
@@ -29,7 +31,7 @@
"scope": { "scope": {
"type": "string", "type": "string",
"enum": ["global", "workstream", "user", "coordinator", "project"], "enum": ["global", "workstream", "user", "coordinator", "project"],
"description": "Memory scope. 'global' = shared across everything; 'workstream' = private to this workstream; 'user' = follows the user across workstreams; 'project' = the shared bucket of the project this session is attached to (available only when attached, writable only with project write access). Default: the attached project when you can write it, otherwise 'global'." "description": "Memory scope. 'global' = shared across everything; 'workstream' = private to this workstream; 'user' = follows the acting user across workstreams; 'project' = the shared bucket of the active project this session is attached to. Save/get/delete without scope inherit exactly one target: project when attached, otherwise the session-kind default. A read-only project permits get but rejects save/delete without falling back. Search/list without scope use all visible scopes. A valid explicit scope selects exactly that scope."
}, },
"query": { "query": {
"type": "string", "type": "string",
@@ -46,20 +48,20 @@
"interactive": true, "interactive": true,
"kind_variants": { "kind_variants": {
"interactive": { "interactive": {
"description": "Persistent memory across sessions. Actions: 'save' stores a memory, 'get' retrieves full content by name, 'search' finds memories by query, 'delete' removes a memory, 'list' shows all memories. Use 'get' to read full content — search/list truncate previews to 200 chars. Memories have a type (user/general/feedback/reference) and a scope: 'global' (shared everywhere), 'workstream' (this workstream only), 'user' (follows you across workstreams), and 'project' (the shared bucket of an attached project). When this workstream is attached to a project you can write, new memories default to the project; otherwise to 'global'.", "description": "Persistent memory across sessions. Actions: 'save' stores a memory and always requires a non-empty description, 'get' retrieves full content by name, 'search' finds memories by query, 'delete' removes a memory, 'list' shows all memories. Use 'get' to read full content — search/list truncate previews to 200 chars. Memories have a type (user/general/feedback/reference) and a scope: 'global' (shared everywhere), 'workstream' (this workstream only), 'user' (follows the acting user across workstreams), and 'project' (the shared bucket of an attached project). Unscoped save/get/delete target project when attached, otherwise global. A read-only project permits get but rejects save/delete without falling back. Pass the displayed scope explicitly when following a search/list result from another scope.",
"parameter_overrides": { "parameter_overrides": {
"scope": { "scope": {
"enum": ["global", "workstream", "user", "project"], "enum": ["global", "workstream", "user", "project"],
"description": "Memory scope. 'global' = shared across everything; 'workstream' = private to this workstream; 'user' = follows the user across workstreams; 'project' = the shared bucket of the project this workstream is attached to (available only when attached, and writable only with project write access). Default: the attached project when you can write it, otherwise 'global'." "description": "Memory scope. 'global' = shared across everything; 'workstream' = private to this workstream; 'user' = follows the acting user across workstreams; 'project' = the shared bucket of the active project this workstream is attached to. Save/get/delete without scope target project when attached, otherwise global. A read-only project permits get but rejects save/delete without falling back. Search/list without scope use all visible scopes. A valid explicit scope selects exactly that scope."
} }
} }
}, },
"coordinator": { "coordinator": {
"description": "Persistent orchestration memory shared by all of your user's coordinator sessions. Actions: 'save' stores a memory, 'get' retrieves full content by name, 'search' finds memories by query, 'delete' removes a memory, 'list' shows all memories. Use 'get' to read full content — search/list truncate previews to 200 chars. Memories have a type (user/general/feedback/reference). Coordinator memories survive across coordinator sessions — save orchestration knowledge worth keeping (recurring procedures, environment facts, lessons from past runs). They are NOT visible to child workstreams.", "description": "Persistent orchestration memory for the acting user. Actions: 'save' stores a memory and always requires a non-empty description, 'get' retrieves full content by name, 'search' finds memories by query, 'delete' removes a memory, 'list' shows all memories. Use 'get' to read full content — search/list truncate previews to 200 chars. Coordinator memories survive across that user's coordinator sessions and are NOT visible to child workstreams. Unscoped save/get/delete target project when attached, otherwise coordinator. A read-only project permits get but rejects save/delete without falling back. Pass the displayed scope explicitly when following a search/list result from another scope.",
"parameter_overrides": { "parameter_overrides": {
"scope": { "scope": {
"enum": ["coordinator", "project"], "enum": ["coordinator", "project"],
"description": "'coordinator' = the per-user orchestration namespace, durable across coordinator sessions; 'project' = the shared bucket of the project this coordinator is attached to (available only when attached, writable only with project write access). Default: the attached project when you can write it, otherwise 'coordinator'." "description": "'coordinator' = the acting user's private orchestration namespace, durable across their coordinator sessions; 'project' = the shared bucket of the active project this coordinator is attached to. Save/get/delete without scope target project when attached, otherwise coordinator. A read-only project permits get but rejects save/delete without falling back. Search/list without scope use all visible scopes. A valid explicit scope selects exactly that scope."
} }
} }
} }